From ca294e8cdb4903924c8a04658d04576b169add7e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:24:37 +0100 Subject: [PATCH 01/17] chore(deps)(deps): bump ip-address from 10.2.0 to 10.4.0 in /gitnexus (#2818) --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 98555cd48..d1e8da477 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -3477,9 +3477,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" From 38be4c6bd2064b69c076e3067652f4f637f2a557 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:48:55 +0000 Subject: [PATCH 02/17] chore(deps)(deps): bump node-addon-api from 8.9.0 to 8.9.1 in /gitnexus (#2816) --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index d1e8da477..d22b1e8ea 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -4198,9 +4198,9 @@ } }, "node_modules/node-addon-api": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", - "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz", + "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", "license": "MIT", "engines": { "node": "^18 || ^20 || >= 21" From 4c0a78fcfbe859af729c0e8366e9e34f6a04659a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:23:53 +0100 Subject: [PATCH 03/17] chore(deps)(deps): bump hono from 4.12.31 to 4.13.0 in /gitnexus (#2822) Bumps [hono](https://github.com/honojs/hono) from 4.12.31 to 4.13.0. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.31...v4.13.0) --- updated-dependencies: - dependency-name: hono dependency-version: 4.13.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index d22b1e8ea..21e5ee0e0 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -3404,9 +3404,9 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" From 4a2f4c8ddd809a8cef0ec8e22d3f5fa06c168b13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:48:05 +0000 Subject: [PATCH 04/17] chore(deps)(deps): bump the npm_and_yarn group across 1 directory with 1 update (#2817) --- gitnexus-web/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index a2c175aae..5e7c3ce0d 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -2180,9 +2180,9 @@ "license": "MIT" }, "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3228,16 +3228,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { From 62d07cca5da6ad68e5590102741c804d5e3883f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:32:20 +0100 Subject: [PATCH 05/17] chore(deps)(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#2823) Bumps the npm_and_yarn group with 2 updates in the /gitnexus-web directory: [fast-uri](https://github.com/fastify/fast-uri) and [postcss](https://github.com/postcss/postcss). Updates `fast-uri` from 3.1.4 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5) Updates `postcss` from 8.5.22 to 8.5.25 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.22...8.5.25) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: postcss dependency-version: 8.5.25 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 5e7c3ce0d..b3bb6be34 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -4391,9 +4391,9 @@ "license": "Unlicense" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -7274,9 +7274,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", From e7503b6ea5bab379f1494233467b06e204b1c341 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:33:12 +0100 Subject: [PATCH 06/17] chore(deps)(deps): bump @modelcontextprotocol/sdk in /gitnexus (#2815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) from 1.29.0 to 1.30.0. - [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases) - [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.29.0...1.30.0) --- updated-dependencies: - dependency-name: "@modelcontextprotocol/sdk" dependency-version: 1.30.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus/package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 21e5ee0e0..b86193998 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1342,12 +1342,12 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", From f3b4806389ab36af51ce17921a213ed6810b57d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:53:24 +0000 Subject: [PATCH 07/17] chore(deps)(deps): bump fast-uri from 3.1.4 to 3.1.5 in /gitnexus (#2821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index b86193998..403c92be8 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -3043,9 +3043,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", From 6ae35f1e71202d28ed71802059b9d617189a2f5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:55:59 +0000 Subject: [PATCH 08/17] chore(deps): bump aiohttp in /eval in the uv group across 1 directory (#2825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- updated-dependencies: - dependency-name: aiohttp dependency-version: 3.14.3 dependency-type: indirect dependency-group: uv ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- eval/uv.lock | 204 +++++++++++++++++++++++++-------------------------- 1 file changed, 102 insertions(+), 102 deletions(-) diff --git a/eval/uv.lock b/eval/uv.lock index 6bb3889b2..cd43b5faf 100644 --- a/eval/uv.lock +++ b/eval/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" 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/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] From b2cd1c2ad637657125248c0dd2046de71ceea965 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:29:51 +0100 Subject: [PATCH 09/17] chore(deps)(deps): bump @hono/node-server in /gitnexus (#2827) Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.14 to 2.1.0. - [Release notes](https://github.com/honojs/node-server/releases) - [Commits](https://github.com/honojs/node-server/compare/v1.19.14...v2.1.0) --- updated-dependencies: - dependency-name: "@hono/node-server" dependency-version: 2.1.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 403c92be8..16e374d9e 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -692,12 +692,12 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" From c1103f38f2be56ac4b5197c9b0878e1e8b3976b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 4 Aug 2026 19:31:25 +0100 Subject: [PATCH 10/17] fix: type an inference-typed class field so it can act as a call receiver (#2807) (#2810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(helpers): add the shared temp-repo lifecycle helper `createTempDirPool` gives a suite one owner for its temp fixture repos — create on demand, remove them all in one `afterAll` — instead of a hand-rolled mkdtemp/rmSync pair per file. The PDG receiver pin added in the next commit uses it. Cherry-picked verbatim from ec36c6dda on the #2802 branch, where it was extracted to collapse five hand-rolled cleanups. Identical content, so if both branches land the add resolves as a duplicate rather than a divergence. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(typescript): type a class field from its initializer so it can be a receiver A field whose type had to be inferred from its initializer produced no CALLS edge at all — not a truncated chain, nothing. `this.p.inner().compute(x)` lost `Outer.inner` too, an ordinary named-receiver call, because `typeOfMemberOnClass` found no `typeBindings` entry for `p` and `foldReceiverChain` declines at its first untypeable step rather than folding on a guessed owner. The initializer was never invisible: `new Outer()` emitted its own constructor edge exactly as the annotated twin does. What was missing was the step turning that initializer into a TYPE BINDING, i.e. capture patterns for the two shapes the query never covered: private p = new Outer(); // public_field_definition value: private p; constructor() { this.p = new … } // this. = new … Both are `@type-binding.constructor`, so `annotation` still outranks them in `typeBindingStrength` and an annotated field keeps resolving through its annotation. The assignment form carries a narrow `@type-binding.this-field` marker on its `(this)` node — anchorCaptureFor takes the broadest range, so the statement stays the anchor — which `tsBindingScopeFor` reads to hoist the binding onto the Class scope, the only place `typeOfMemberOnClass` looks. The marker must stay specific to that pattern: hoisting every constructor-inferred binding would move method-local `const o = new Outer()` out of its own scope. Kotlin and Swift needed no such pattern for the initializer form because one grammar node (property_declaration) covers both a local and a stored property; TypeScript splits them, and only the local half was ever covered. Both self-diffing pins flip and gain rows: a method-assigned field, and a deliberately mistyped `private p: Mismatch = new Outer()` that asserts the source-strength tie-break executably. That row also pins a pre-existing artifact — `Inner.compute` still resolves through the hoisted module-level return-type binding — verified byte-identical on the pre-fix tree. Fixes #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(javascript): type a class field from its initializer so it can be a receiver JavaScript has no field annotations at all, so a class field's type can only ever come from its initializer — which made this the strictly worse half of #2807: `class C { p = new Outer(); }` gave `this.p` no type, and `this.p.inner()` emitted nothing. `synthesizeConstructorFieldBindings` in captures.ts already covered the sibling shape, `this.p = new Outer()`, which is why THAT row resolved — but it only walks `constructor` bodies, so a field initialized at its declaration matched no pattern anywhere. Adds the `field_definition` + `value: (new_expression)` patterns (the JS grammar names the field `property:`, not `name:`), anchored so the binding lands in the class body scope where `typeOfMemberOnClass` reads it. No hook change needed: `jsBindingScopeFor` already delegates to `tsBindingScopeFor`, so it inherits the `@type-binding.this-field` branch too. Measured: `InferredField.run` now emits `Outer.inner`, exact parity with both the local-const control and the constructor-assigned row. The second chain link (`Inner.compute`) stays absent in ALL THREE rows — that is JavaScript's separate return-type-inference gap, not this one. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(python): infer an instance field's type from the constructor it calls `self.outer = Outer()` in `__init__` bound nothing, so `self.outer.inner()` had no receiver type and the fold declined the whole chain — the Python half of #2807. An annotated field (`self.outer: Outer = ...`) or one assigned from an annotated parameter already worked. `synthesizeConstructorFieldTypeBindings` deliberately refused to infer "from arbitrary unannotated RHS expressions ... not a name-only guess". A CALL is not that: Python has no `new`, so a call to a plain (or dotted) name is the only syntactic construction form there is, and it is the same positive evidence every other language reads from `= new X()`. A bare name, subscript, await or comprehension is still refused. Adds it as a THIRD and weakest tier. The existing explicit/parameter boolean becomes a rank, so precedence is now explicit annotation > parameter annotation > construction, and a later same-tier assignment still wins (the last write in `__init__` is the live one). `interpretPythonTypeBinding` maps the new marker to `constructor-inferred` (strength 1) — checked before the parameter branch, which would otherwise have read the absent parameter marker as `annotation` and promoted a guess to the strongest tier. The Class-scope hoist needed no change: `@type-binding.instance-field` already carries it in `pythonBindingScopeFor`. Measured: `AssignedField.run` now emits `Outer.inner`, exact parity with the annotated-field and local-const rows. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(ruby): infer an instance variable's type from the constructor it calls `@service = UserService.new` in `initialize` bound nothing, so `@service.inner` had no receiver type and the fold declined the whole chain — the Ruby half of #2807. An instance variable is the ONLY way a Ruby object gets a field, and Ruby has no annotations, so this was the single shape that could have worked and did not: the existing constructor-inferred patterns bind a local (`x = Foo.new`) and a constant (`SERVICE = Foo.new`), never an ivar. Adds the plain and `Foo::Bar` qualified ivar forms. `@type-binding.name` is captured on the `instance_variable` node so the bound name keeps its `@` sigil and matches the receiver text at the call site verbatim — the resolver compares spellings, and `service` would never have matched `@service`. `rubyBindingScopeFor` gains a Class hoist gated on a narrow `@type-binding.ivar-field` marker riding the same node: an ivar declares a field of the enclosing class, so the binding must live on the Class scope or no other method can see it. Gated on the dedicated marker, never on `@type-binding.constructor` at large, which also fires for `x = Foo.new` locals that must stay in their own method. Measured: `AssignedField.run` now emits BOTH chain links, exact parity with the local-const control. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * test(resolvers): pin inference-typed field receivers across eight languages #2807 was filed against TypeScript, but the defect class is cross-language: "can a field whose type is inferred act as a call receiver". This measures all eight languages where the shape exists at all, in one table. The metric is parity with EACH LANGUAGE'S OWN CONTROL ROW, not "both chain links present". JavaScript, Python, Dart and PHP lose the second link (`Inner.compute`) even for a plain local, because nothing annotates `inner()`'s return type — a separate return-type-inference gap. Scoring against "both links" would have accused those four of a bug they do not have; scoring against their own control isolates the field-typing question cleanly. Recorded state: TypeScript, JavaScript, Python and Ruby now match their controls. Kotlin and PHP already did before #2807 and are pinned so the shared fold cannot regress them unnoticed — the languages that got receiver typing for free are precisely the ones nobody re-checks. Two rows stay pinned BROKEN, at their exact current value: Dart — real and narrow: the annotated control resolves, the inferred one does not. Its bindings are synthesized in dart/captures.ts rather than by a query, so the fix is its own change. Swift — blocked by a different defect found while measuring: with several classes each defining `run`, every `run`'s edges are attributed to the FIRST-declared one, which collects duplicates while its siblings — including the ANNOTATED control — collect none. Receiver typing cannot be measured there until that is fixed, and "fixing" it against this observable would be fitting to a broken measurement. Both gap rows carry a `callerExists` probe in the same assertion object, so an empty list can never read as "resolved fine, wrong node id", plus a whole-matrix guard that every language keeps a resolving control — that is what makes a gap row mean "broken" instead of "fixture never worked". Targets are deduplicated before comparison: Swift emits one edge more than once per call site, and edge multiplicity is a different question from whether the receiver typed at all. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(python): a method call on the receiver is not a construction Review finding on f4e1ead0d. `constructorCallTypeName` accepted ANY call with an identifier or attribute callee, so `self.p = self.build()` bound `p` to the non-type `"self.build"` — and because that shares the weakest tier with a real construction, a later such assignment DISPLACED an earlier `self.p = Outer()` and left the field untyped again. Measured before the fix: `self.p = Outer()` followed by `self.p = self.rebuild()` emitted no CALLS edge at all from a method chaining off `self.p`, and `self.q = self.make()` bound a type name that resolves to nothing. After: the real construction survives the reassignment, and a pure method call binds nothing rather than something wrong. Rejects a callee rooted at the receiver name. `models.Outer()` still binds — only `self`-rooted callees are refused, which is exactly the method-call shape. The matrix gains a `reassigned-from-method-call` row that fails without this rejection; that discrimination is the only reason the row exists. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(swift): resolve a method def to its own node when the labels disagree Two classes in one Swift file each declaring `func run` collapsed onto one node: every call in BOTH bodies was attributed to whichever `run` registered first, which collected duplicate edges while its twin collected none. Renaming one method fixed it; moving it to another file fixed it; so the collision was name-keyed and per-file, not positional. Root cause is a LABEL split, not a name. Swift's structure phase emits a type's methods as `Function` nodes, while the scope extractor derives `Method` from the `@declaration.method` anchor. Every key in `resolveDefGraphId` — qualified, parameter-types, arity, shape — is label-scoped, so such a pair misses all of them and lands on the bottom fallback, `simpleKey(filePath, name)`, which is deliberately label-agnostic and first-write-wins. Fixed at both ends: - Swift qualifies a method def as `.`, matching the qualifier the structure phase already encoded in the node id. `class`, `struct` and `extension` all parse to `class_declaration`, so one ancestor walk covers them; a generic `class Box` and an `extension Foo` wrapping a `user_type` both reduce to the bare owner name. - The bridge retries the qualified keys under the sibling callable label. Gated on the name containing a dot: `A.run` names one construct whatever the label, while a bare `run` is exactly the top-level-vs-method aliasing the label was added to prevent, so the original guarantee is untouched. This also unmasked Swift's #2807 row. `let p = Outer()` had always bound correctly — its edges were being credited to the wrong caller, so the inference-typed receiver looked broken when it was not. `InferredField.run` now emits `Outer.inner`, matching its control. Verified on the full resolver + CFG suite: 3165 passed, 0 failed, against a 3164-passing baseline. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(dart): declare inference-typed class fields so they can be receivers `var b = Outer();` produced no `@declaration.property` capture at all — no Property node, and nothing for the capture layer to hang a type binding on — so `b.inner()` could not type its receiver while the annotated twin `Outer b = Outer();` resolved fine (#2807). The gap was in the query, one layer below where the binding is emitted: both class-field patterns require a leading `(type_identifier)` or `(nullable_type)`, i.e. a WRITTEN type. Dart puts the keyword there instead for an inferred field, and spells it two ways — `inferred_type` for `var`, `final_builtin` for `final` and `late final`. Covering only `var` would have left the more idiomatic Dart style broken, so both are matched. With the field declared, the capture layer types it from the constructor its initializer calls, as `constructor-inferred` — the weakest source, and the annotated branch returns before it, so an annotated field is untouched. Only a direct construction is accepted (a bare identifier followed by a `selector` carrying an `argument_part`, the same shape `findDirectCallValue` accepts for locals); a literal, member call or await is left alone rather than guessed at. Note this is the LOCAL/field split that made the gap invisible: `emitVarTypeBinding` already handled `initialized_variable_definition`, but a class field is `declaration(, initialized_identifier_list(initialized_identifier))`. `InferredField.run` now emits `Outer.inner`, matching its control. Dart's `var r; C() { r = Outer(); }` shape stays pinned as a known gap: Dart writes the field with no receiver prefix, so binding it means treating assignment to a bare identifier as a field write, indistinguishable from a constructor-local. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * test(resolvers): record Swift and Dart reaching parity in the matrix Both languages' inference-typed field rows move from KNOWN GAP to resolving, which is the self-diffing signal this file was built to produce: closing either gap failed it with the newly resolved ids in the diff. The header table and prose are corrected together with the rows, as the file's own instructions require — including WHY Swift moved. Its `let p = Outer()` binding had always been correct; a separate label-split defect attributed the second same-named method's calls to the first, which masked this row entirely. Recording that is the point: a future reader comparing the table against the code needs to know the row was never a receiver-typing failure. One row stays pinned: Dart's `var r; C() { r = Outer(); }`. Dart writes fields without a receiver prefix, so binding it means treating assignment to a bare identifier as a field write — indistinguishable from a constructor-local until the field set is known. Idiomatic Dart writes `final r = Outer();`, which the inferred-field row now covers. Every language keeps its resolving control row, so the remaining gap still means "broken" rather than "fixture never worked". Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(dart): type a field from a constructor assigned to it `var r; C() { r = Outer(); }` bound nothing, so `r.inner()` had no receiver type — the last inference-typed field shape still failing after the initializer form was fixed (#2807). Dart is the one language here that writes a field with NO receiver prefix, so `r = Outer()` inside a constructor is syntactically identical to assigning a constructor-local. That ambiguity is why this was initially left pinned — but the field set IS knowable: the class body declares `var r`, which the initializer fix already turned into a property declaration. So a bare name binds exactly when Dart itself resolves it to the field: the enclosing class declares it AND the enclosing body declares no local of that name. A `this.`-prefixed write is unambiguous and needs neither test. The shadowing case is asserted, not assumed: with a body-local `var s = Outer()` in scope, the field stays unbound while the local still resolves on its own. Binds `constructor-inferred` (weakest source, so an annotation still wins), and only for a direct construction — an identifier followed by a `selector` carrying an `argument_part`, the same shape accepted for locals. The narrow `@type-binding.dart-field` marker drives the Class-scope hoist in `dartBindingScopeFor`; gating on it rather than on `@type-binding.constructor` at large is what keeps genuine locals in their own scope. All three shapes now match their control: bare `r = Outer()`, `this.s = …`, and a non-constructor `setUp()` assignment. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(swift): type an optional field and read through its force-unwrap Swift cannot declare a stored property with neither a type nor an initializer, so its "declare now, assign in init" idiom is an OPTIONAL field read back through a force-unwrap. That shape resolved nothing, and it was broken in two independent places — each alone leaves it broken: 1. `var a: Outer?` parses as `type_annotation(optional_type(user_type(…)))`, but the property-annotation pattern required the `user_type` to be a DIRECT child, so an optional field was never typed at all. The pattern added here captures the INNER `type_identifier`, so the binding is `Outer` without relying on `stripOptional` reducing an `Outer?` spelling. 2. `self.a!` is a `postfix_expression`, which the receiver walk did not peel, so even a typed field could not be read through the unwrap. For (2), `postfix_expression` is NOT added to `TRANSPARENT_RECEIVER_WRAPPERS` outright: unlike TypeScript's `non_null_expression` — which is only ever `!` — Swift's node also carries user-defined postfix operators, which can return anything. Peeling those would type the receiver as the operand and mint a confidently WRONG owner, the failure mode compound-receiver.ts calls strictly worse than no edge. So the peel is operator-gated: transparent only when the node's text ends in `!`, which is provably type-preserving. Verified: force-unwrap `self.a!.inner()`, optional chain `self.b?.inner()`, and the plain annotated field all resolve; previously only the plain one did. The gate keeps this off every other language — `postfix_expression` is not a node type the other grammars produce here — and the full resolver + CFG suite is green at 3166 passed / 0 failed, against a 3165 baseline. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * test(resolvers): close the last two matrix gaps Dart's `assigned-field` and Swift's new `optional-assigned-field` rows now resolve, leaving no known-gap row in the matrix: every language reaches parity with its own control on both the initializer and the assigned shape it can express. The Swift row is new because the shape it covers did not exist in the fixture: Swift cannot declare a stored property with neither type nor initializer, so its assigned form is an optional field written in `init` and read through a force-unwrap — a shape that needed both an optional-annotation pattern and an operator-gated receiver peel, which is why the row's comment names both. The header records how the two hard cases were fixed, including the Dart shadowing rule the fix depends on: a bare `r = Outer()` binds only when the class declares that field and the body declares no local of the same name. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * chore(bench): rebaseline the receiver-resolution and scope-capture gates Both gates are exact-match, so the improvements in this branch fail CI until the baselines move and the movement is explained. Caught by running the CI gates locally — the resolver and CFG suites are green throughout and never see these. receiver-resolution — three shapes moved to RESOLVES, no drop-count changed: ruby.fieldReceiverCall INVISIBLE-GAP -> RESOLVES (`@ivar = Foo.new`) swift.decoratedFieldType INVISIBLE-GAP -> RESOLVES (`var a: Outer?`) kotlin.nonNullAssert VISIBLE-GAP -> RESOLVES (`x!!` receiver) scope-capture — swift and typescript fingerprints, both ADD captures and remove none; the per-language `_rebaselined_inferred_field_receiver_2807` notes carry the detail and the prior digests. The other 13 languages are unchanged, which is the check that this is the intended emission and not a capture regression. CORRECTION to d5d878033's message, which claimed the operator-gated `postfix_expression` peel "keeps this off every other language — postfix_expression is not a node type the other grammars produce here". That is wrong: Kotlin's grammar produces it too, and `kotlin.nonNullAssert` moving to RESOLVES is the proof. The peel is still correct there — Kotlin `!!` is a non-null assertion with exactly the type-preserving semantics the `!` gate tests for — but it is a BEHAVIOUR CHANGE IN KOTLIN, not Swift-only as stated. The gate is what surfaced it; the claim should have been verified rather than asserted. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(cache): bump SCHEMA_BUMP for the six-language capture change, + review fixes SCHEMA_BUMP 39 -> 40. THIS IS THE MERGE-BLOCKER of the review: every language change in this PR is PARSE-TIME capture emission, and `analyze` skips tree-sitter dispatch for byte-unchanged chunks (GUARDRAILS.md:34), so a warm cache replays the pre-fix capture set verbatim and the new receiver edges never appear — silently, no error. Exactly the v27/v30 failure mode this file already documents. The PR description's claim that "no schema or version constant applies" was wrong on both counts: a bump IS required, and a plain re-analyze does NOT surface the captures without it. Re-check against origin/main before merging — main was also at 39 when 40 was allocated, and this file records eight prior collisions. Also from the review: - dart/simple-hooks.ts hand-rolled a 9-line parent walk byte-identical to the shared `walkToScope(innermost, tree, 'Class')` that TypeScript and Ruby call in one line in this same PR. Now uses the helper. - utils/call-analysis.ts: the doc framed the postfix-`!` peel as Swift-only. It is not — Kotlin `!!` parses as the same node and is peeled too, which the receiver-resolution bench proved (kotlin.nonNullAssert VISIBLE-GAP -> RESOLVES). The comment now says so, and names the `!` gate rather than the language as the bound. - test/helpers/temp-dir-pool.ts: its doc claimed four consumers; on THIS branch only `pdg-chained-receiver-callees` uses it (the other three convert on #2802). Corrected, and the byte-identical-to-#2802 intent recorded. - inferred-field-receiver-matrix: adds the Dart shadowing assertion the header comment already CLAIMED to make but never did. First attempt was vacuous — `var s = Outer()` is a declaration, so it never produced the bare `assignment_expression` the guard inspects; removing the guard did not fail the row. Fixture corrected to `var s; s = Outer();`, and mutation-verified: guard present 35 pass, guard removed the row goes red. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * test(cache): move the SCHEMA_BUMP pin to 40 The pin at incremental-parse-cache.test.ts asserts the exact value on purpose — it exists to catch two branches claiming one number, and it has earned that eight times. Bumping the constant to 40 without moving the pin turned it red. Found by the Codex (gpt-5.6-sol) review leg, which flagged it as a deterministic committed-test failure. The Claude lanes could not have caught it: they were dispatched before the bump landed. The comment now records the 39 -> 40 movement and its reason, matching the existing convention in that block. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(dart): treat every binder as a field shadow, not just local declarations Review P1, reproduced by two independent reviewers. `emitDartFieldAssignmentBindings` binds a bare `r = Outer()` to the FIELD when the class declares `r` and the body declares no local `r` — but the shadow set was built by walking for `initialized_variable_definition` only. That is one binder form out of many, so a formal PARAMETER named like a field slipped through: void reset(Alpha r) { r = Alpha(); } // r is the PARAMETER retyped the FIELD to `Alpha`, fabricating an edge AND destroying the correct `Beta` binding the constructor had established. The mutation test shows exactly that: the pre-fix result is not a missing edge but a WRONG one (`Other.inner#0` instead of `Outer.inner#0`) — the failure mode compound-receiver.ts:519-537 calls strictly worse than no edge. The node types were chosen from real grammar output, not assumed. Two facts drove the design: formal parameters live on the SIBLING `method_signature`, never inside `function_body`, so no walk of the body could ever have seen them; and `formal_parameter` carries a `name` field only when typed — untyped, `this.` and `super.` forms do not. `collectDartBodyShadows` therefore walks the signature AND the body, collecting formal/closure/local-function/named/optional params, `this.`/`super.` constructor params, catch bindings, for-in variables, and both local-declarator forms. A parameter shape whose name cannot be read contributes nothing — declining to bind is the safe direction. A 27-case binder sweep passes: 26 shadow shapes bind nothing, the no-binder control still binds. Four new matrix rows (param, closure param, catch, loop var) assert a surviving POSITIVE target rather than an empty list — deliberately, because the pre-fix value is a different non-empty target, so these rows cannot pass vacuously the way an empty-assert row can. Mutation-verified: reverting captures.ts turns exactly those four red and leaves every pre-existing row green. SCHEMA_BUMP is already at 40 on this branch for the six-language capture change and has not shipped, so it covers this too; re-check against origin/main before merge. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(ruby): don't bind a class-object @ivar as an instance field Review P1. The `@ivar = Foo.new` patterns added on this branch hoist to the enclosing Class scope without asking WHOSE `self` owns the ivar. In Ruby an ivar written in singleton context belongs to the class object, not to instances, so def self.build; @pool = Alpha.new; end class << self; def make; @cache = Alpha.new; end; end bound `@pool`/`@cache` as INSTANCE fields, fabricating edges from instance methods that read an ivar which is never assigned on an instance. Three corrections came out of fixing it: 1. The detection premise was wrong. `def self.build` is NOT a `method` node with a `self` receiver — it is its own node type, `singleton_method`, and `childForFieldName('receiver')` returns NONE on it. Matching on a receiver field would have detected nothing, silently. Detection is by node type: `singleton_method` / `singleton_class`. 2. A THIRD form exists that the review did not name: a class-body-level `class C; @shared = Outer.new;` is the same defect (self is the class object), and is likewise new on this branch — before it, `left: (instance_variable)` matched nothing at all. 3. Dropping only the `@type-binding.ivar-field` marker is NOT sufficient, and the class-body case is what proves it: with the marker gone the binding falls back to its innermost scope, which at class-body level ALREADY IS the Class scope, so it still lands in the wrong place. The whole match is therefore discarded. The check lives in `languages/ruby/captures.ts` because `Capture` carries only `{name, range, text}` — no AST node — so `rubyBindingScopeFor` structurally cannot ask whose `self` owns the ivar. All Ruby logic stays under `languages/ruby/`. `method` alone is not a sufficient "instance" signal, since a `def` inside `class << self` is reached through a `method` node first. Cost relative to main is zero: a class-object ivar goes back to binding nothing, exactly as before these patterns existed. The three new rows are structurally two-sided, not just mutation-checked: each empty row is paired with a non-empty `*-instance-ivar` row on the SAME fixture class, so breaking the hoist entirely turns the partner red while an unconditional hoist turns the empty row red. Mutation-verified in both directions. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(typescript,javascript): bind `this.field = new X()` only inside a class method Review P0, the most serious finding of the tri-review and reproduced by two independent reviewers. The `this. = new X()` patterns added on this branch were CONTEXT-FREE: they matched anywhere in the file, and `tsBindingScopeFor` hoisted to the nearest enclosing Class without asking whose `this` that was. Since the binding lands on the same Class scope with the same `constructor-inferred` source as the field-initializer pattern, and pass4CollectTypeBindings prefers the later match on `>=`, it OVERWROTE the class's real field type. Reproduced from a non-arrow callback, an object-literal method, a static method, and module level. Fixed STRUCTURALLY, in the query: both patterns are now nested under `class_body -> method_definition -> body: (statement_block) -> (expression_statement)`, which kills the callback, object-literal and top-level triggers with no runtime code and mirrors JavaScript's `synthesizeConstructorFieldBindings` discipline. TypeScript still accepts ANY method, not just `constructor`, so the setter case this branch deliberately supports keeps working. `static` needed one emit-side guard: it is an ANONYMOUS token on `method_definition` with no field name, and tree-sitter patterns cannot negate an anonymous token (checked against node-types.json), so `isStaticMethodThis` drops it in captures.ts. `simple-hooks.ts` is comment-only — the unconditional Class hoist is now documented as safe BECAUSE the marker's producers are bounded, with a note that widening them means re-establishing that. Also fixes a `.ts`/`.js` disagreement the narrowing itself created: JavaScript's synthesis matched `method_definition` ANYWHERE, so an object literal containing a method named `constructor` still typed the enclosing class's field. Measured on identical source — JS emitted `p -> Alien`, narrowed TS emitted nothing — and closed with a `node.parent?.type !== 'class_body'` guard in javascript/captures.ts. The two languages must not disagree about the same source. Deliberately NOT matched (a missing binding, never a wrong one — JS declines these too): an assignment in a nested block, or inside an arrow where `this` genuinely IS the instance. Evidence the narrowing removed nothing legitimate: `bench/scope-capture --check` passes with the TypeScript AND JavaScript fingerprints BYTE-IDENTICAL. The five new matrix rows use an `Alien` class that also declares `inner()`, so a regression SWAPS the target rather than emptying the set — they cannot pass vacuously. Mutation test: reverting the source turns exactly those rows red (`+ "Alien.inner#0"`, `- "Outer.inner#0"`). SCHEMA_BUMP stays at 40 — this PR's existing bump covers the capture change being narrowed, and the buggy variant never shipped outside this branch. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(resolution): consult the sibling callable label in the position key too Review P1. This branch added a sibling Method<->Function retry to the qualified keys in `resolveDefGraphId`, but not to the #2699 POSITION key or its fail-closed guard, which both stayed scoped to `def.type`. Since the premise of the whole fix is that Swift defs are `Method` while nodes are `Function`, the position lookup missed and the fail-closed guard could NEVER FIRE for exactly the case the retry serves — so a function-local `func helper` inside `Host.run` was deterministically aliased onto the class method `Host.helper`, even with differing arity. Two earlier reviewers REFUTED this by arguing the guard runs before `lookupTagged`. That is true and irrelevant: the guard is scoped to `def.type`, so in the label-split case it is unreachable. Recording it because two independent lanes agreeing on a refutation is not proof. `siblingCallableLabel(label)` is now the single definition, consulted by all three key families: - position key: retried under the sibling label, gated on `posHit === undefined` so an AMBIGUOUS_POSITION tombstone still falls through to the name keys rather than being resolved by relabelling. Deliberately NOT dot-gated — a position key is not a name, so the aliasing risk the dot gate exists for does not apply. - fail-closed guard: mirrored unconditionally (it only ever returns undefined). - qualified retry: dot gate untouched. Measured before -> after on a Swift fixture: `Host.helper#1 -> sink` (the local body's call credited to the public 1-arg method) becomes `Host.run.helper@8:8#2 -> sink`, with the local's own node no longer edgeless. SCOPE CORRECTION to the P1 report: only the first consequence is a bridge defect. The second — "`run`'s call to the local resolves to the method" — is NOT reachable from ids.ts. Both defs carry qualifiedName `Host.helper` and label `Method`, and the binding hands the target side the class-member def, so the scope walk in free-call-fallback picks the member. No def->node mapping can change that; it is pinned as an explicitly labelled KNOWN GAP rather than left implied. Verification, on shared code so the full bar: resolvers+cfg 3170 passed / 1 skipped / 0 failed; `bench/receiver-resolution --check` OK; `bench/scope-capture --check` PASS (15 languages, Swift fingerprint unchanged) — i.e. the bridge change altered no capture output. The 3170 reconciles against the 3167 pre-existing at a5bf4c2da plus exactly 3 new tests; 3167 differs from the older 3166 baseline because 0418b0aac added the matrix's only known-gap row, which emits one extra `it`. Mutation test: with both arms reverted, 3 of the 5 new cases go red, each arm pinned independently — the guard case registers no position key, the position case registers no local-name key. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * refactor: apply cleanup-review findings across the receiver-typing change Four parallel quality lanes (reuse, simplification, efficiency, altitude) over `origin/main...HEAD`. Eleven fixes; both exact-match bench gates hold with every capture fingerprint BYTE-IDENTICAL, so none of this changed what the analyser emits. Reuse — stop re-rolling helpers that already exist: - `walkToScope` moved out of the TypeScript provider into a language-neutral `utils/scope-tree-walk.ts`. Ruby and Dart had begun importing it FROM `languages/typescript/`, which made three unrelated providers depend on the TS module for a generic `Scope`/`ScopeTree` walk. Python's hand-rolled copy — the one this PR's new `self.x = Outer()` path routes through — is folded in, so all six languages now share one traversal. - Swift stops string-parsing a type name. `swiftEnclosingTypeName` split on `<` and `.`; `swiftBaseTypeIdentifier` + `swiftQualifiedBaseTail` do it structurally and correctly skip the sibling `type_arguments` node, which the string form only guessed at. `findEnclosingTypeDeclaration` replaces the inlined ancestor walk. - TypeScript uses the canonical `hasKeyword(method, 'static')`. The previous `child.type === 'static'` is the exact form `isStaticMember` documents as grammar-version-fragile: "`static` can appear as an unnamed token or as a keyword node depending on grammar version; check text." - Both new test suites use `cleanupTempDirSync`, which exists because a pipeline test's open handle surfaces as EBUSY/EPERM on Windows and `force` does not suppress it. This repo shards Windows CI. LATENT DEFECT, found by the reuse lane and fixed: `var a = X(), b = Y();` parses as ONE `declaration` with two declarators, and the query matches it once per declarator with the SAME node — so the first-descendant search handed every declarator the FIRST one's initializer. `b` resolved as `X`. Now reads `nameNode.nextNamedSibling`, which is both correct and free. Pinned by a `multi-declarator-inferred-field` row ordered so the declarator under test is the second; reverting the fix turns exactly that row red with the wrong edge. Efficiency — measured, not asserted: - Dart's shadow set was built eagerly for EVERY method body and discarded 87-100% of the time (a `this.`-prefixed write never reads it). Now lazy and memoised per body, gated on `fields.has()`. Semantics are unchanged: the set is body-wide, so deferring construction cannot change its contents. Worth recording WHY CI could never have caught this: `bench/scope-capture` gates the SCALING RATIO, and the work is linear — ratio stays 1.0 against a 1.5 budget while a constant-factor regression passes straight through. - `isTransparentReceiverWrapper` crossed the `node.type` native getter twice on the common path. One hoisted read, and — since absent and ungated are distinguishable — one `get` replaces `has`+`get`. Simplification: - One `Map` replaces the parallel Set + Map that both expressed "this wrapper is transparent", with `null` meaning unconditional. - `ids.ts` computed `siblingCallableLabel` twice under two names. The three retry blocks are deliberately NOT collapsed — they use different key builders and materially different gates. - Python's `interpret.ts` nesting was only a consequence of arm ORDER; swapping the arms is unconditionally equivalent (the two differ only when both markers are present, and both orders then yield `constructor-inferred`). - One `isDirectConstruction` predicate replaces the construction-shape test that had been written four times in dart/captures.ts. Deliberately NOT done, each needing a fingerprint rebaseline or new node ids: unifying the six `@type-binding.*-field` markers into one canonical capture (it would change Python's anchor semantics, which must be verified not assumed); a Swift `labelOverride` mirroring Kotlin's four-line fix, which is the real cure for the Method/Function split the bridge currently compensates for; generalising the Swift optional-annotation pattern to `(type_annotation (_))` so the existing strippers handle every wrapper; and merging the TS query with the JS walker, which also carries a JSDoc branch no query can express. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * test(swift): regenerate the Swift captures golden for the optional-annotation pattern CI caught what my local runs did not: `swift-captures-golden.test.ts` pins `emitSwiftScopeCaptures` output across every `swift-*` fixture, and this branch changes that output. It is a THIRD capture gate, separate from the two exact-match benches already rebaselined here — `bench/scope-capture` hashes a different corpus, so its Swift fingerprint moving did not imply this one, and passing it was not evidence this was clean. The drift is digest-only: 37 changed lines, 37 in each direction, no capture entry added or removed. That is the expected shape for `(type_annotation (optional_type (user_type …)))` making optional properties emit an annotation binding they previously did not, plus the `@declaration.qualified_name` now carried on Swift method declarations. Regenerated with the mechanism the test itself prescribes (`UPDATE_GOLDEN=1`), not by relaxing the assertion. Verified after: all Swift unit + resolver suites green (4 files, 124 tests), and `bench/receiver-resolution --check` still exactly matches its baseline. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix: three more wrong-owner defects, found by a second review round A second tri-review of this PR found THREE new P1 wrong-owner defects — every one of them in code the FIRST round had already fixed. All three are the same root shape: an incomplete ENUMERATION of binder or scope forms. That class has now bitten this branch four times (formal parameters, then these), so two of the three fixes below deliberately attack the class rather than the instance. 1. DART 3 PATTERN BINDERS (P1, reproduced by two independent lanes). `addDartBinderName` enumerated five binder node types, and every Dart 3 pattern form parses into node types in NONE of them — so a pattern-bound local did not count as a shadow and its write retyped the CLASS FIELD: class Host { var session = Cache(); void load() { final (session, count) = (Session(), 2); } void use() { session.ping(); } // resolved Session.ping, not Cache.ping } The grammar hides every rule that would carry a binder (`_pattern_field`, `_list_pattern_element`, `_guarded_pattern`, …), inlining children onto the enclosing visible pattern node, so binders land as direct `identifier` children of just two leaf types. Covers all 10 pattern types that can hold one; the eight container types are defence, since the grammar demonstrably inlines identifiers onto containers already. THE COMPOUNDING PART: a grammar-derived coverage guard reads `nodeTypeInfo` and fails if the grammar declares a `*pattern*` type the fixtures do not exercise. A grammar bump adding an 11th type now turns the suite red instead of silently reopening this bug a third time. 2. RUBY BLOCK-RECEIVER `self` REBINDING (P1 here, both Claude lanes + Codex, which rated it P2 — the engines agreed the defect is real and disagreed on severity). `isRubyInstanceIvarWrite` enumerated `singleton_method`/`singleton_class` as the ways `self` gets rebound. A `def` inside a BLOCK attaches to the block's receiver, so `Struct.new(:x) do def warm; @a = Beta.new; end end`, `Class.new do … end`, `class_eval`, and `other.instance_eval { @a = … }` all published onto the nearest LEXICAL class. Deliberately NOT fixed by listing rebinding call names: that set is OPEN — `def helper(&blk) = Foo.class_eval(&blk)` rebinds a block it merely receives, and nothing in the block's own syntax reveals it. An allow-list of "safe" iterators would be the same defect one level down. The rule is structural: crossing ANY block boundary makes ownership unprovable, so discard. Complete by construction rather than by enumeration. ACCEPTED COST, asserted not hidden: `[1].each { @shared = X.new }` in an instance method really is the instance's `self`, and this drops it — that block is syntactically identical to the `instance_eval` one. It has its own row (`plain-block-self-ivar`) so the loss is visible rather than discovered later. 3. STATIC FIELD INITIALIZERS (P1, found by Codex/gpt-5.6-sol, corroborated). A `static` field initializer was captured as an ordinary instance binding, and since both land on one Class scope at the same `constructor-inferred` strength, the later wins the `>=` tie-break — so a static field retyped the instance field of the same name (`this.p.hit()` -> `Wrong.hit`). Unguarded in BOTH `javascript/query.ts` and `typescript/query.ts`; the existing `isStaticMethodThis` only ever covered the `this.x =` assignment form. Two things surfaced while fixing it: the TS `annotation` pattern collides identically and is PRE-EXISTING, not introduced here; and JS `static constructor(){}` had no guard where TS did — the .ts/.js divergence this PR's own comment claimed could not happen. Dart has no same-name twin (the language forbids it), but a static method's receiver-less write named a library-level variable and DISPLACED the constructor's binding. Fixed narrowly, with a counterweight row (`static-field-declaration-still-types-its-receiver`) that goes red if anyone widens the guard into "drop every static binding" — reading a static by bare name from an instance method is ordinary Dart and must keep working. ACCEPTED COST: `typeBindings` has one map per Class scope with no static/ instance split, so a static field is dropped rather than recorded separately, losing typing on a TS/JS `Host.p.hit()` static receiver chain. Missed edge over wrong edge, per compound-receiver.ts:519-537. Every new row asserts a SURVIVING POSITIVE target, never an empty set: the pre-fix value in each case is a DIFFERENT non-empty target, so none can pass vacuously — the trap this branch already fell into once. Mutation-verified per fix: reverting each turns exactly its own rows red (17 Dart, 6 Ruby blocks, 5 static) with every pre-existing row green. Matrix 49 -> 80 tests. Siblings 510 passed. tsc clean. scope-capture PASS (15 languages, all ratios within gate). Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(dart): mask a shadowed field on the READ side, not just the write side The review critic refused to pass this PR while this was open, and it was right to: this is the same wrong-owner shape as the three defects fixed in the previous commit, except this one is introduced BY this PR rather than merely missed by it. THE DEFECT. Typing an unannotated field from a constructor assignment (`var conn; Host() { conn = Alpha(); }` binds `conn` on the CLASS scope) is this PR's whole point. `emitDartFieldAssignmentBindings` correctly declines to WRITE that binding when a member body rebinds the name — but the shadow set gated writes ONLY. `collectDartBodyShadows` had exactly one call site, inside the bare-name write branch. Nothing consulted it on the read side, so a bare-name READ of a shadowing binder the resolver cannot type walked straight past the local and hit the field binding this feature mints: class Host { var conn; Host() { conn = Alpha(); } void probe(List xs) { for (final conn in xs) { conn.inner(); } // conn is a Beta element } } // resolved Alpha.inner, not Beta.inner Reproduced in SEVEN binder shapes, not the one the review reported: for-in (`final` and `var`), untyped formal parameter, plain local `var`, catch binding, closure parameter, and record pattern. Delete the constructor and the same read emits NOTHING — which is what proves this PR introduced it. "No edge" became "wrong edge", the one failure mode compound-receiver.ts:519-537 exists to prevent. THE FIX uses `Scope.ownsReceivers` (#2701), the primitive that already exists for exactly this, rather than inventing a mechanism. `scope/walkers.ts` consults `typeBindings` FIRST at every scope and only then honours the mask, so a shadow the resolver CAN type still wins — an annotated `void probe(Beta conn)` keeps `Beta`, because `synthesizeDartSignatureBindings` anchors parameter bindings on the same body node and they land on the same Function scope. The mask fires only where the alternative was a fabricated type. Plumbing follows TypeScript's `@receiver-owner.this` precedent: the marker rides the same synthesized match as `@scope.function` and sits outside the `@scope.` namespace so `anchorCaptureFor` cannot mistake it for the anchor. Dart differs only in that its function scopes are synthesized in captures.ts rather than declared in the .scm, so the names travel as capture TEXT — a `CaptureMatch` carries no AST node, so the reader cannot re-derive them. SCOPE, and the costs taken knowingly rather than hidden. The mask is `shadows ∩ fields` and nothing wider. Masking every locally bound name would also fix a library-level `var logger = Logger();` shadowed by a loop variable, but it changes resolution for code this PR never touched. Three consequences are documented on `dartShadowedFieldsCapture`, not buried: the wider case is left open; an ANNOTATED field shadowed by a binder is masked too (correct Dart, but it touches resolution predating #2807); and `mixin` bodies are reached, since the grammar gives them a `class_body`. PERFORMANCE, measured rather than asserted. The mask is emitted eagerly in Pass A, where `collectDartBodyShadows` used to be lazy — the replaced comment recorded 87-100% of eagerly built sets being discarded, ~15% of Dart emission. Actual cost on the scope-capture large corpus, median of 3: 405.6ms with the mask vs 390.0ms without, ≈ +4%. Fingerprint and capture_groups are byte-identical across both arms, so no corpus fixture emits a mask at all — that 4% is the cost of the CHECK alone. Not visible to `bench/scope-capture`, which gates the scaling RATIO and is blind to a linear constant factor; stated here because the gate cannot state it. (3 samples per arm, blocked not interleaved — an estimate, not a rigorous number.) A per-file memo keyed by node span makes both passes share one walk per body, so the write side no longer pays a second one. SCHEMA_BUMP 40 -> 41 with its exact-value pin, since capture emission changed. Re-check against origin/main immediately before merge — main was 39 at commit time. Mutation-verified both directions, which is the part that matters: - unwire `scopeOwnsReceivers`, rebuild -> exactly 2 rows red (`loop-var-read-does-not-see-the-field`, `pattern-read-does-not-see-the-field`), 83/85 green. - over-widen the mask (drop the `shadows.has` test) -> 28 Dart rows red, including `unshadowed-read-in-a-shadowing-class-still-resolves`. The three control rows stay green under the first mutation BY DESIGN — they guard overreach, not the defect; the second mutation is what proves they are live. Pre/post on the trigger row: `{Class:Alien, Alien.inner#0, Outer.inner#0}` -> `{Class:Alien, Alien.inner#0}`, so no row can pass vacuously. Matrix 80 -> 85 tests. Sweep 3220 passed (was 3215; exactly +5). tsc clean. All four capture gates green: receiver-resolution OK, scope-capture PASS (15 languages, no fingerprint moved, nothing rebaselined), callable-value-flow PASS, swift golden 9. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(ts,js): let each language name its own class-field node type CI caught a defect the whole review round missed. `grammar-literal-validation`: 1 dead grammar literal(s) found: - node-type "field_definition" — languages/typescript/captures.ts:0 — not valid in [typescript] `isStaticClassFieldBinding` held BOTH spellings in one set — `public_field_definition` (TypeScript) and `field_definition` (JavaScript) — so that one predicate could serve both languages. But the predicate lives in `typescript/captures.ts`, and the gate checks every literal against the grammar of the FILE it appears in. `field_definition` is not a TypeScript node type. The literal was NOT dead code: `javascript/captures.ts:42` imports the predicate and calls it against real JS nodes, so the guard worked. The gate is still right to fail it, and for exactly the reason this predicate's own docblock gives for preferring `hasKeyword` over a node-type test — "a node-type test silently stops firing on a grammar bump and every static field starts retyping its instance twin again". A literal already dead in its own file is that failure shipped pre-broken: nothing in the TypeScript file would ever have told us. Each language now names its own node type and passes it in (`TS_CLASS_FIELD_DEFINITION_TYPES` / `JS_CLASS_FIELD_DEFINITION_TYPES`), so every literal is checked against the grammar it belongs to. The `hasKeyword` logic and the static/instance reasoning stay shared and unchanged — only the node-type set moves to the caller. WHY THE LOCAL SWEEP DID NOT CATCH IT: I ran `test/integration/resolvers` and `test/integration/cfg`. The gate is `test/integration/grammar-literal-validation. test.ts`, in the parent directory. Scoping a sweep to the subdirectories a change touches is precisely how a cross-cutting gate gets skipped. grammar-literal-validation 4 passed. tsc clean. Full `test/integration` + `test/unit/scope-resolution`: 6305 passed, 14 failed — all 14 in e2e/environment suites (fts-extension-e2e 9, analyze-heap-oom-e2e, cli-e2e, analyze-wal-checkpoint-failure, plus interproc-taint and parse-impl-env-reads, which BOTH pass in isolation and fail only under 28-worker load). CI runs the same files green. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * test(dart,ts): pin all seven read-side binder shapes; correct a wrong accepted-cost claim Two review findings, one of which turned out to be a documentation defect rather than the design defect it was filed as. S8 — THE READ-SIDE FIX PINNED 2 OF THE 7 SHAPES IT REPORTED REPRODUCING. `ab2f48c17` reported the wrong-edge defect reproducing in seven binder shapes and landed rows for two. The stated mitigation was that all seven route through one `collectDartBodyShadows` enumeration whose completeness the grammar-derived coverage guard protects. That mitigation is NARROWER THAN CLAIMED: the guard filters `nodeTypeInfo` on `type.includes('pattern')`, so it covers the pattern family and NOT catch bindings, closure parameters, plain locals, or formal parameters. Narrowing `addDartBinderName`'s catch arm would have turned no row red. All seven were re-measured by unwiring `dartScopeOwnsReceivers` and rebuilding. Every one gained the wrong edge `Outer.inner#0` — none had to be dropped as non-reproducing. Five new rows: formal parameter, plain local `var`, catch binding, closure parameter, for-in `var`. Non-vacuity established structurally, not by assertion: the Dart AST was dumped first to confirm each fixture produces the node `addDartBinderName` actually inspects (`formal_parameter`, `initialized_variable_definition`, `catch_parameters`, `for_loop_parts`). The catch row uses a bare `catch (zf)` rather than `on Err catch` deliberately — an `on` clause names a type, which would make the row measure type resolution instead of the mask. S7 — THE ACCEPTED-COST COMMENT WAS WRONG, AND THAT IS THE FINDING. It claimed dropping a static field's binding trades a wrong edge for a missed one. Measured on a same-name twin, that is false: read with the drop without it this.p (instance twin) Outer correct Alien wrong Host.p (static twin) Outer WRONG Alien correct Host.q (static, no twin) none — missed Alien correct The wrong edge did not disappear. It MOVED to the static read, which now picks up the instance twin's type. Only the no-twin case is a genuine missed edge. The trade is still right — `this.p` is far more common than `Host.p` — but it was documented as safer than it is, and a reader deciding whether to revisit it was being given the wrong picture. NAMESPACING WAS EVALUATED AND DELIBERATELY NOT DONE. `Host.p.hit()` resolves through `foldReceiverChain` in shared `compound-receiver.ts`, which explicitly discards whether a chain's base was a class reference or a value (:519-527). The class-constant bit exists only on the text-cascade path (`currentIsClassConstant`) and is consumed solely by `isConstructionSelectorHop`; TS/JS take the fold, not the cascade. `Scope.typeBindings` is `ReadonlyMap` with no static field. `ownsReceivers` cannot help — it is a suppressor that can only REMOVE a binding, never route to a second one. A real fix needs `FoldState` to carry the bit plus a key convention in shared code (an AGENTS.md:42 hook if not language-neutral), it crosses the worker boundary so it needs a SCHEMA_BUMP, and `compound-receiver.ts:826` iterates every binding for `fieldFallback` so a namespaced key would leak straight back in as an ordinary field. Not a cheap or safe change — and it would have been made with ZERO existing tests pinning static-read behaviour. So: smallest safe step instead. Two rows pin the measured behaviour (`static-read-of-a-same-name-twin-picks-up-the-instance-type` asserts the positive wrong target, not an empty set; `static-read-without-a-twin-loses-its-type` is a known-gap), and the comment now says what actually happens. Anyone who revisits this starts from measurements rather than from a claim. No SCHEMA_BUMP: the `captures.ts` change is comment-only — verified, the diff has no non-comment added lines. Mutation red-rows 2/85 -> 7/90; each new row fails with a strictly larger set (`+Outer.inner#0`), so none can pass vacuously. Overreach control still live: dropping `shadows.has` turns 28 rows red including `unshadowed-read-in-a-shadowing-class-still-resolves`. Matrix 85 -> 92 tests. Sweep 3231 passed, 0 failed. tsc clean. All four gates green, no fingerprint moved, nothing rebaselined. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) * fix(python): stop a dotted callee from fabricating a constructor type S4 and S5 from the review round. They are ONE defect, not two, and the real one is wider than the review described. Both live in a 32-line block THIS PR adds (`@@ -116,0 +117,32 @@` — a pure addition), so neither is pre-existing. THE DEFECT. `constructorCallTypeName` rejected only a callee rooted at the receiver and returned every other dotted callee whole to `resolveTypeRef`, which resolves dotted names through `QualifiedNameIndex` — and that index matches the TRAILING SEGMENT against a class of that name even when the callee is a method on an unrelated object: class Alpha: def ping(self): return 1 class Factory: def Alpha(self): return "not an Alpha" # a METHOD class Host: def __init__(self, f): self.svc = f.Alpha() # svc is a str def run(self): return self.svc.ping() # measured: Host.run -> Alpha.ping, fabricated WIDER THAN FILED: the review framed the trigger as a callee rooted at an `__init__` PARAMETER. Measured, the root's binding form is irrelevant — a module-level variable (`shared_factory.Alpha()`) fabricates identically. Any rule written against what the root binds to would have fixed half the defect and left the other half looking fixed. Both fabrications now have rows. S5 IS A SYMPTOM, NOT A SECOND DEFECT. `self.conn = Outer()` then `self.conn = Registry.get()` typed the field as `"Registry.get"` (resolving to nothing, so the edge vanished) only because the dotted arm accepted `Registry.get` as a constructor in the first place. Once dotted callees yield no candidate, there is nothing weak left to displace with and `Outer` survives. So `>=` is untouched and NO second mechanism was added: between two REAL constructions last-write-wins is correct, and the existing `ReassignedField` matrix row depends on it. Tightening the tie-break would have been the wrong fix to a symptom. THE FIX: accept a bare `identifier` callee only. Refusing ambiguous evidence at CAPTURE time rather than resolving-then-rejecting is deliberate — the target-kind route is not reachable from this file (`resolveTypeRef` already filters `TYPE_KINDS`; the fabrication comes from a trailing-segment match in `scope/walkers.ts`), and the root-alias route would collide with PR #2828, which is rewriting exactly how an unaliased dotted namespace import resolves. This change is orthogonal to #2828 by construction: it changes what is CAPTURED, never how a name is looked up, and touches none of its files. WHAT THE DOTTED ARM WAS ACTUALLY BUYING: nothing. The review (and this PR's own docblock) justified it with `self.u = models.User()`. Measured, that shape emits NO edge before or after this change — an instance field's binding lands in CLASS scope, which never reaches the namespace split. The shape that really resolves is the module-level local `u = models.User()`, which comes from `query.ts` and is untouched here. The arm's entire measured contribution was fabrications, which is what made the fix cheap. #2828 COMPATIBILITY, checked not assumed: `import pkg.user` -> `self.u = pkg.user.User()` resolves to nothing both before and after, so this cannot stop it resolving. No test row pins that shape ON PURPOSE — asserting its current empty state would plant a tripwire that goes red the moment #2828 lands. If #2828 also teaches the FIELD path the namespace split, re-enabling dotted field callees becomes a live option; the docblock says so, and says why redoing it capture-side would re-open the fabrication. SCHEMA_BUMP 41 -> 42 with its pin. This is parse-time capture emission: after the fix `self.svc = f.Alpha()` emits no `@type-binding.constructor` capture at all, so a v41 warm cache replays the pre-fix capture set for byte-unchanged files and keeps serving the fabricated edge (GUARDRAILS.md:34). A within-PR re-bump, not a collision fix — 40/41/42 are all this unmerged branch's, and `origin/main` is at 39. Re-check against origin/main immediately before merging. Mutation-verified in BOTH directions, which is what shows the fix is placed at the right width rather than merely working: - revert the fix -> exactly 3 red: both S4 fabrication rows + the S5 displacement row (8 green) - reject EVERY callee -> exactly 3 red: the three positive-typing rows (8 green); the S4 rows correctly stay green The two mutations hit DISJOINT row sets — too loose and too tight each break a different half. No row asserts an empty set: the three "must not type" rows call `Alien.ping()` as a witness so a regression SWAPS a target in rather than emptying. Non-vacuity is asserted in the test itself — one guard checks every caller node is live, another asserts the `Alpha` class / `Factory.Alpha` method name collision the fabrication NEEDS is actually present, so the rows cannot rot into passing for the wrong reason. Sweep 3268 passed, 0 failed. Python unit + python.test.ts 342 passed. tsc clean. All four gates green — no bench cell moved, nothing rebaselined. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) --- .../bench/receiver-resolution/baseline.json | 6 +- gitnexus/bench/scope-capture/baselines.json | 10 +- gitnexus/src/core/ingestion/languages/dart.ts | 6 + .../core/ingestion/languages/dart/captures.ts | 538 +++- .../core/ingestion/languages/dart/index.ts | 2 +- .../core/ingestion/languages/dart/query.ts | 21 + .../ingestion/languages/dart/simple-hooks.ts | 10 + .../languages/javascript/captures.ts | 41 + .../ingestion/languages/javascript/query.ts | 28 + .../languages/javascript/simple-hooks.ts | 3 +- .../ingestion/languages/python/interpret.ts | 11 +- .../languages/python/receiver-binding.ts | 134 +- .../languages/python/simple-hooks.ts | 8 +- .../core/ingestion/languages/ruby/captures.ts | 136 ++ .../core/ingestion/languages/ruby/query.ts | 35 + .../ingestion/languages/ruby/simple-hooks.ts | 23 +- .../ingestion/languages/swift/captures.ts | 61 +- .../core/ingestion/languages/swift/query.ts | 17 + .../languages/swift/receiver-binding.ts | 8 +- .../languages/typescript/captures.ts | 160 ++ .../ingestion/languages/typescript/query.ts | 106 + .../languages/typescript/simple-hooks.ts | 48 +- .../scope-resolution/graph-bridge/ids.ts | 84 + .../src/core/ingestion/utils/call-analysis.ts | 49 +- .../core/ingestion/utils/scope-tree-walk.ts | 38 + gitnexus/src/storage/parse-cache.ts | 33 +- .../expected-captures.json | 74 +- .../cfg/pdg-chained-receiver-callees.test.ts | 97 +- .../inferred-field-receiver-matrix.test.ts | 2170 +++++++++++++++++ .../python-constructor-field-receiver.test.ts | 290 +++ .../swift-local-vs-method-label-split.test.ts | 134 + ...typescript-inferred-field-receiver.test.ts | 184 +- .../test/unit/incremental-parse-cache.test.ts | 9 +- .../graph-bridge-label-split.test.ts | 118 + 34 files changed, 4435 insertions(+), 257 deletions(-) create mode 100644 gitnexus/src/core/ingestion/utils/scope-tree-walk.ts create mode 100644 gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts create mode 100644 gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/graph-bridge-label-split.test.ts diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 31d2933ef..d4136c53c 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -133,7 +133,7 @@ "awaitParen": "N/A", "explicitTypeArgs": "N/A", "indexElement": "INVISIBLE-GAP", - "fieldReceiverCall": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", "decoratedReceiverBase": "N/A", "decoratedFieldType": "N/A" }, @@ -165,7 +165,7 @@ "plainChain": "RESOLVES", "plainDeepChain": "RESOLVES", "optionalChain": "RESOLVES", - "nonNullAssert": "VISIBLE-GAP", + "nonNullAssert": "RESOLVES", "awaitParen": "RESOLVES", "explicitTypeArgs": "RESOLVES", "indexElement": "RESOLVES", @@ -183,7 +183,7 @@ "indexElement": "INVISIBLE-GAP", "fieldReceiverCall": "RESOLVES", "decoratedReceiverBase": "N/A", - "decoratedFieldType": "INVISIBLE-GAP" + "decoratedFieldType": "RESOLVES" }, "dart": { "plainChain": "VISIBLE-GAP", diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 84a235786..4860470f1 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -94,14 +94,15 @@ "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." }, "swift": { - "fingerprint": "2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7", + "fingerprint": "adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.", "_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).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7." + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", + "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5." }, "dart": { "fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73", @@ -137,7 +138,7 @@ "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633." }, "typescript": { - "fingerprint": "cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff", + "fingerprint": "248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd -> 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78; scaling 0.975 < 1.5.", @@ -148,7 +149,8 @@ "_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object (was unscoped, then @scope.block during development). Prior e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63 -> 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4; scaling 0.981 < 1.5.", "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff." + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.", + "_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding \u2014 `public_field_definition` with a `new_expression` value, and `this. = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5." }, "javascript": { "fingerprint": "806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594", diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index 79d75eb71..f0efd455e 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -36,6 +36,7 @@ import { createCallExtractor } from '../call-extractors/generic.js'; import { dartCallConfig } from '../call-extractors/configs/dart.js'; import { emitDartScopeCaptures, + dartScopeOwnsReceivers, interpretDartImport, interpretDartTypeBinding, dartBindingScopeFor, @@ -139,6 +140,11 @@ export const dartProvider = defineLanguage({ // emit-side `ScopeResolver` lives in `dart/scope-resolver.ts`; the same // function references flow through both interfaces. emitScopeCaptures: emitDartScopeCaptures, + // Dart's `ownsReceivers` masks FIELD NAMES a member body rebinds, not `this` + // (which Dart binds lexically in every closure form, so there is nothing to + // own). See `dartShadowedFieldsCapture` in `dart/captures.ts` for why the + // read side needs the mask and what it deliberately does not cover. + scopeOwnsReceivers: dartScopeOwnsReceivers, cfgVisitor: createDartCfgVisitor(), interpretImport: interpretDartImport, interpretTypeBinding: interpretDartTypeBinding, diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index c3a09b91b..351eaee7d 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -47,6 +47,32 @@ import { encodeMarker } from '../../utils/heritage-marker.js'; import { DART_BUILT_INS } from './built-ins.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; +import { hasKeyword } from '../../field-extractors/configs/helpers.js'; + +/** + * `LanguageProvider.scopeOwnsReceivers` for Dart — the read side of + * `dartShadowedFieldsCapture`, which is where the full rationale lives. + * + * Reads the marker rather than re-deriving anything: the names were computed at + * capture time, where the AST is, and a `CaptureMatch` carries only + * name/range/text. Kept beside the emitter so the tag string has exactly one + * producer and one consumer, both in this file's line of sight. + */ +export function dartScopeOwnsReceivers(match: CaptureMatch): ReadonlySet | undefined { + const raw = match['@receiver-owner.shadowed-fields']?.text; + if (raw === undefined) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (!Array.isArray(parsed)) return undefined; + const names = parsed.filter( + (name): name is string => typeof name === 'string' && name.length > 0, + ); + return names.length > 0 ? new Set(names) : undefined; +} const FUNCTION_DECL_TAGS = [ '@declaration.function', @@ -139,6 +165,13 @@ export function emitDartScopeCaptures( // declarations by their statement node so each is emitted exactly once. const seenFnDeclNodes = new Set(); + // Shared, per-file. Pass A (the receiver mask below) and Pass B + // (`emitDartFieldAssignmentBindings`) ask the SAME two questions of the same + // nodes — "what does this class declare as a field" and "what does this + // member body bind" — so the memo makes each answer cost one walk per node + // for the whole file instead of one per consumer. + const memo: DartClassMemo = { fieldsByClassBody: new Map(), shadowsByBody: new Map() }; + // ── Pass A: query-driven scopes / declarations / imports ──────────────── for (const match of getDartScopeQuery().matches(root)) { const grouped: Record = {}; @@ -168,7 +201,15 @@ export function emitDartScopeCaptures( out.push(grouped); if (bodyNode !== null) { - out.push({ '@scope.function': spanCapture('@scope.function', declNode, bodyNode) }); + // The READ-side half of the bare-name field discipline (#2807 review). + // Rides the SAME synthesized match as `@scope.function` so the mask + // lands on this member's Function scope; outside the `@scope.` + // namespace so `anchorCaptureFor` cannot mistake it for the anchor. + const mask = dartShadowedFieldsCapture(bodyNode, memo); + out.push({ + '@scope.function': spanCapture('@scope.function', declNode, bodyNode), + ...(mask === undefined ? {} : { '@receiver-owner.shadowed-fields': mask }), + }); for (const cm of synthesizeDartReceiverBinding(declNode, bodyNode)) out.push(cm); } for (const cm of synthesizeDartSignatureBindings(declNode, bodyNode)) out.push(cm); @@ -204,6 +245,19 @@ export function emitDartScopeCaptures( '@type-binding.name': syntheticCapture('@type-binding.name', propNode, fieldName), '@type-binding.type': syntheticCapture('@type-binding.type', propNode, fieldType), }); + } else { + // No written type, so the field's type comes from the constructor its + // initializer calls (#2807). `constructor-inferred` is the weakest + // source, and the annotated branch above already returned, so an + // annotated field is untouched either way. + const callee = dartFieldConstructorCallee(nodeMap['@declaration.name']!); + if (callee !== null) { + out.push({ + '@type-binding.constructor': nodeToCapture('@type-binding.constructor', propNode), + '@type-binding.name': syntheticCapture('@type-binding.name', propNode, fieldName), + '@type-binding.type': syntheticCapture('@type-binding.type', propNode, callee.text), + }); + } } continue; } @@ -234,6 +288,7 @@ export function emitDartScopeCaptures( } if (node.type === 'class_definition') { emitHeritage(node, out); + emitDartFieldAssignmentBindings(node, out, memo); return; } if (node.type === 'extension_declaration') { @@ -497,6 +552,22 @@ function emitCascadeReference(cascade: SyntaxNode, out: CaptureMatch[]): void { // ─── Local-variable constructor / call-result type inference ──────────────── +/** + * Is `node` the callee of a construction / free call written directly at this + * position — a bare identifier whose next named sibling is a `selector` + * carrying an `argument_part` (`Outer()`)? + * + * Dart has no `new` keyword, so a constructor call and a free call are the same + * shape; the resolver decides which by looking the name up. Anything else — a + * literal, a member call, an index — is NOT this shape and is left alone rather + * than guessed at. + */ +function isDirectConstruction(node: SyntaxNode | null): node is SyntaxNode { + if (node === null || node.type !== 'identifier') return false; + const next = node.nextNamedSibling; + return next !== null && next.type === 'selector' && next.namedChild(0)?.type === 'argument_part'; +} + /** Find the callee identifier of a `var x = Callee(…)` / `await Callee(…)` * initializer (a direct free-call / constructor); returns null for member * calls or non-call values. */ @@ -505,11 +576,7 @@ function findDirectCallValue(initVarDef: SyntaxNode): SyntaxNode | null { if (firstValue === null) return null; if (firstValue.type === 'identifier') { - const next = firstValue.nextNamedSibling; - if (next !== null && next.type === 'selector' && next.namedChild(0)?.type === 'argument_part') { - return firstValue; - } - return null; + return isDirectConstruction(firstValue) ? firstValue : null; } if (firstValue.type === 'unary_expression' || firstValue.type === 'await_expression') { let aw = firstValue; @@ -519,22 +586,38 @@ function findDirectCallValue(initVarDef: SyntaxNode): SyntaxNode | null { aw = inner; } if (aw.type === 'await_expression') { + // `namedChild(0)` is the awaited callee; its next named sibling is + // `namedChild(1)`, so `isDirectConstruction` asks exactly the same + // question this branch used to spell out. const id = aw.namedChild(0); - const sel = aw.namedChild(1); - if ( - id !== null && - id.type === 'identifier' && - sel !== null && - sel.type === 'selector' && - sel.namedChild(0)?.type === 'argument_part' - ) { - return id; - } + if (isDirectConstruction(id)) return id; } } return null; } +/** + * Callee identifier of a class field initialized by a direct constructor call — + * `var b = Outer();` / `final b = Outer();` — or `null` for anything else. + * + * Dart spells a class field as `declaration(, initialized_identifier_list( + * initialized_identifier))`, NOT the `initialized_variable_definition` that + * `emitVarTypeBinding` handles — that is the LOCAL form. So an unannotated field + * had no type binding and could not act as a call receiver (#2807), even though + * its annotated twin resolved fine. + * + * Takes the field's own `@declaration.name` node, whose next named sibling IS + * the initializer — `initialized_identifier( …)`. Deliberately NOT + * a search down from the `@declaration.property` node: one `declaration` can + * hold SEVERAL declarators (`var a = X(), b = Y();`), which the query matches + * once each with the same property node, so a first-descendant search hands + * every declarator the FIRST one's initializer and types `b` as `X`. + */ +function dartFieldConstructorCallee(nameNode: SyntaxNode): SyntaxNode | null { + const value = nameNode.nextNamedSibling; + return isDirectConstruction(value) ? value : null; +} + function emitVarTypeBinding(initVarDef: SyntaxNode, out: CaptureMatch[]): void { const nameNode = initVarDef.childForFieldName('name'); if (nameNode === null) return; @@ -550,6 +633,429 @@ function emitVarTypeBinding(initVarDef: SyntaxNode, out: CaptureMatch[]): void { // ─── Heritage ─────────────────────────────────────────────────────────────── +/** + * Type an inference-typed field from a constructor call ASSIGNED to it — + * `var r; C() { r = Outer(); }` and `this.r = Outer();` (#2807). + * + * Dart is the one language here that writes a field with NO receiver prefix, so + * `r = Outer()` is syntactically identical to assigning a constructor-local. The + * discriminator is the class's own declared field set: a bare name binds only + * when the enclosing class declares it AND the enclosing member binds no name of + * its own that would shadow it (`collectDartBodyShadows` — parameters, locals, + * closure parameters, catch bindings, loop variables), which is exactly when + * Dart itself resolves `r` to the field. A `this.`-prefixed write is unambiguous + * and needs neither test. + * + * Emitted as `constructor-inferred`, the weakest source, so a field that also + * carries an annotation keeps it. The narrow `@type-binding.dart-field` marker + * rides the name node for `dartBindingScopeFor` to hoist on — the binding has to + * land on the Class scope, since the assignment sits inside a constructor's own + * Function scope where `typeOfMemberOnClass` never looks. + */ +function emitDartFieldAssignmentBindings( + classNode: SyntaxNode, + out: CaptureMatch[], + memo: DartClassMemo, +): void { + const body = findChild(classNode, 'class_body'); + if (body === null) return; + + // `namedChildren` allocates a fresh wrapper array on every access + // (node-tree-sitter), and the loop below walks the same list — read it once. + const members = body.namedChildren; + + const fields = dartClassFieldNames(body, memo); + if (fields.size === 0) return; + + for (const member of members) { + if (member === null || member.type !== 'function_body') continue; + + // A STATIC member's body can never write this class's INSTANCE fields. + // Dart's static scope holds only the class's static members, so a bare + // `z = Outer()` inside `static void make()` binds a LIBRARY-level `z` (or is + // a compile error) — never the same-named instance field. Binding it anyway + // did not merely add an edge: the write landed on the Class scope at the + // same `constructor-inferred` strength as the constructor's own, and the + // `>=` tie-break in `scope-extractor` let it DISPLACE the correct type, so + // `z.inner()` resolved to the wrong class (#2807 review). The instance + // static-vs-instance collision TypeScript and JavaScript allow cannot arise + // in Dart — one class may not declare a static and an instance member of the + // same name — so this is the only shape the defect takes here. + // + // Grammar: every class-member body is a `function_body` whose PREVIOUS named + // sibling is a `method_signature` (method, constructor, factory, getter, + // setter, operator, `async`), and `static` is an anonymous direct child of + // that signature, ahead of the inner `*_signature` node. Detection is the + // shared `hasKeyword` on child TEXT, never `child.type === 'static'`, which + // a grammar bump silently breaks — the same rule TypeScript's + // `isStaticMethodThis` follows. + // + // A body whose signature cannot be read at all (a null or unexpected + // previous sibling — parse recovery) DECLINES to bind: staticness is + // undecidable there, and a missed field type costs an edge while a wrong one + // destroys a correct binding. + const signature = member.previousNamedSibling; + if (signature === null || signature.type !== 'method_signature') continue; + if (hasKeyword(signature, 'static')) continue; + + // Still lazy per assignment, but the memo is now FILE-wide and shared with + // Pass A's read-side mask, which asks the same question of the same body. + // The laziness that used to matter here (87-100% of eagerly built sets were + // discarded, ~15% of total Dart emission) no longer buys much for a class + // that declares fields — Pass A has already forced those bodies — so this + // reads the memo rather than paying a second walk. It still short-circuits + // for a body whose class declares no fields, since `fields.size === 0` + // returned above before either pass touched it. + // + // Not visible to `bench/scope-capture`, which is a RATIO gate — this work is + // linear, so a constant factor leaves the ratio at 1.0. + const shadowsOf = (): ReadonlySet => dartBodyShadows(member, memo); + + walkNamedTree(member, (node) => { + if (node.type !== 'assignment_expression') return; + const target = node.namedChild(0); + if (target === null || target.type !== 'assignable_expression') return; + + const first = target.namedChild(0); + if (first === null) return; + let fieldNameNode: SyntaxNode | null = null; + if (first.type === 'identifier' && target.namedChildCount === 1) { + // Bare `r = …`: a field only when declared here and not shadowed. The + // `fields` test runs FIRST so the shadow set is only ever built for a + // name the class actually declares. + if (!fields.has(first.text) || shadowsOf().has(first.text)) return; + fieldNameNode = first; + } else if (first.type === 'this') { + const selector = target.namedChild(1); + if (selector === null || selector.type !== 'unconditional_assignable_selector') return; + const nameNode = selector.namedChild(0); + if (nameNode === null || nameNode.type !== 'identifier') return; + fieldNameNode = nameNode; + } else { + return; + } + if (fieldNameNode === null) return; + + // RHS must be a direct construction; anything else is left alone rather + // than guessed at. + const callee = node.namedChild(1); + if (!isDirectConstruction(callee)) return; + + out.push({ + '@type-binding.constructor': nodeToCapture('@type-binding.constructor', node), + '@type-binding.dart-field': syntheticCapture( + '@type-binding.dart-field', + fieldNameNode, + '1', + ), + '@type-binding.name': syntheticCapture( + '@type-binding.name', + fieldNameNode, + fieldNameNode.text, + ), + '@type-binding.type': syntheticCapture('@type-binding.type', callee, callee.text), + }); + }); + } +} + +/** + * Per-file memo for the two class-shaped questions the passes share, keyed by + * node span. `emitDartScopeCaptures` owns one and threads it; nothing survives + * the call, so a re-parse cannot serve a stale answer. + */ +interface DartClassMemo { + readonly fieldsByClassBody: Map>; + readonly shadowsByBody: Map>; +} + +const nodeSpanKey = (node: SyntaxNode): string => `${node.startIndex}:${node.endIndex}`; + +/** + * The names a `class_body` declares as INSTANCE-or-static fields, from + * `declaration(… initialized_identifier_list … initialized_identifier)` — the + * shape every stored Dart field takes, annotated or not. + */ +function dartClassFieldNames(classBody: SyntaxNode, memo: DartClassMemo): ReadonlySet { + const key = nodeSpanKey(classBody); + const cached = memo.fieldsByClassBody.get(key); + if (cached !== undefined) return cached; + + const fields = new Set(); + for (const member of classBody.namedChildren) { + if (member === null || member.type !== 'declaration') continue; + const list = findChild(member, 'initialized_identifier_list'); + if (list === null) continue; + for (const init of list.namedChildren) { + if (init === null || init.type !== 'initialized_identifier') continue; + const nameNode = init.namedChild(0); + if (nameNode !== null && nameNode.type === 'identifier') fields.add(nameNode.text); + } + } + memo.fieldsByClassBody.set(key, fields); + return fields; +} + +function dartBodyShadows(bodyNode: SyntaxNode, memo: DartClassMemo): ReadonlySet { + const key = nodeSpanKey(bodyNode); + const cached = memo.shadowsByBody.get(key); + if (cached !== undefined) return cached; + const shadows = collectDartBodyShadows(bodyNode); + memo.shadowsByBody.set(key, shadows); + return shadows; +} + +/** + * The READ half of the bare-name field discipline: the field names a member + * body REBINDS, published on that member's Function scope as + * `Scope.ownsReceivers` (#2701) so the receiver walk stops there instead of + * reaching the Class scope (#2807 review). + * + * `emitDartFieldAssignmentBindings` above declines to WRITE a binding for a name + * the body shadows, but the shadow set gated writes only. A bare-name READ of a + * shadowing binder the resolver cannot type — `for (final conn in xs) { + * conn.inner(); }`, where the element type of `xs` is not modelled — therefore + * walked straight past the local and hit the class field binding this same + * feature mints, resolving `conn.inner()` to the CONSTRUCTOR's type. That turns + * "no edge" into a WRONG edge, the one failure mode + * `scope-resolution/passes/compound-receiver.ts` says must never happen, and it + * was introduced by the write side rather than pre-existing: delete the + * constructor and the same read emits nothing. + * + * `ownsReceivers` is the right primitive because the walk consults + * `typeBindings` FIRST at every scope (`scope/walkers.ts`) and only then honours + * the mask. A shadow the resolver CAN type still wins — an annotated parameter + * `void probe(Beta conn)` keeps `Beta`, because + * `synthesizeDartSignatureBindings` anchors parameter bindings on this same + * body node, so they land on this same Function scope. The mask only fires + * where the alternative was a fabricated type. + * + * ── SCOPE OF THE MASK, AND ITS ACCEPTED COSTS ──────────────────────────────── + * + * `shadows ∩ fields`, and nothing wider. Three consequences are taken knowingly + * rather than hidden: + * + * 1. NOT every locally bound name is masked — only ones the enclosing class + * also declares as a field. A library-level `var logger = Logger();` + * shadowed by a loop variable of the same name still resolves against the + * library binding and can still produce the wrong edge. That is the general + * form of the same defect and arguably the more correct fix, but it changes + * resolution for code this feature never touched; it is recorded here as a + * known limitation rather than implemented. + * 2. The mask is BODY-WIDE, exactly as `collectDartBodyShadows` is on the write + * side. A member that binds `conn` anywhere — a nested closure, one `case` + * arm — masks `conn` for the whole member, so a read of the genuine field + * elsewhere in that member loses its edge. Deliberate symmetry: the write + * side already declines body-wide, and losing an edge is the error this + * whole line of work chooses over inventing one. + * 3. `fields` is EVERY field the class declares, not only the ones the + * constructor-write feature types. An ANNOTATED field shadowed by a binder + * is masked too, so this reaches resolution that predates #2807 — and it is + * meant to: which name a body's bare read refers to is a fact about Dart, not + * about how the field acquired its type. `mixin` bodies are reached for the + * same reason (the grammar gives a mixin a `class_body` as well), even though + * `emitDartFieldAssignmentBindings` mints nothing for them. `extension` + * bodies are a different node (`extension_body`) and are left alone. + * + * Returns `undefined` — not an empty marker — when nothing is masked, so the + * emitted capture set is unchanged for every body that does not shadow a field. + * Names are sorted so the capture text (and every fingerprint over it) is + * order-stable. + */ +function dartShadowedFieldsCapture(bodyNode: SyntaxNode, memo: DartClassMemo): Capture | undefined { + // Only a CLASS-MEMBER body can shadow a field: `function_body` whose parent is + // the `class_body`. A closure's `function_expression_body` and a top-level + // function are both excluded, and neither needs the mask — a closure's binders + // are already in its enclosing member's body-wide shadow set, and the walk + // passes through the enclosing member's Function scope on its way out. + if (bodyNode.type !== 'function_body') return undefined; + const classBody = bodyNode.parent; + if (classBody === null || classBody.type !== 'class_body') return undefined; + + const fields = dartClassFieldNames(classBody, memo); + if (fields.size === 0) return undefined; + const shadows = dartBodyShadows(bodyNode, memo); + if (shadows.size === 0) return undefined; + + const masked: string[] = []; + for (const name of fields) { + if (shadows.has(name)) masked.push(name); + } + if (masked.length === 0) return undefined; + masked.sort(); + return syntheticCapture('@receiver-owner.shadowed-fields', bodyNode, JSON.stringify(masked)); +} + +/** + * Every name BOUND by one class-member body — the shadow set the bare-name + * branch of `emitDartFieldAssignmentBindings` tests against. + * + * A local `var` is only ONE of Dart's binders, and a bare `r = Outer()` writes + * whichever binder wins, so a set built from local declarations alone made a + * write to any OTHER binder look like a field write. `void reset(Alpha r) { r = + * Alpha(); }` in a class with a field `r` retyped the FIELD to `Alpha` — + * fabricating an edge and displacing the type the constructor had correctly + * given it. Formal parameters are the sharpest case because they are not even + * inside the body: `function_body` is a SIBLING of the `method_signature` that + * carries them, so no walk of the body can ever see one. + * + * Dart 3 patterns are the SAME defect a second time: `var (r, n) = …;`, + * `if (o case Beta r)`, `case Beta r:`, `for (var (r, _) in xs)`, list / map / + * object / rest / cast / null-check / null-assert patterns and pattern + * assignments all bind `r` through node types no earlier list named, so each of + * them let a write retype the field. `addDartBinderName` now enumerates the + * pattern family from the grammar rather than from reported shapes. + * + * Deliberately over-approximate in the shadow direction. The set is body-wide + * (a binder in a nested closure, a `case` arm or a collection-literal element + * shadows for the whole body), a parameter shape whose name cannot be read + * contributes nothing rather than being guessed at, and a bare name inside a + * pattern shadows whether it binds or merely references a constant. All err + * toward DECLINING to bind, which is the right error: a missed field type costs + * an edge, a wrong one produces an edge to the wrong class and destroys a + * correct binding — the failure mode this whole line of work exists to avoid + * (see `scope-resolution/passes/compound-receiver.ts`). + */ +function collectDartBodyShadows(bodyNode: SyntaxNode): Set { + const shadows = new Set(); + // The enclosing function's own formal parameters live OUTSIDE the body, on + // the `method_signature` sibling that precedes it — the shape every class + // member takes (method, constructor, factory, static, getter, setter, + // operator, `async`/`async*`). + const signature = bodyNode.previousNamedSibling; + if (signature !== null && signature.type === 'method_signature') { + walkNamedTree(signature, (n) => addDartBinderName(n, shadows)); + } + walkNamedTree(bodyNode, (n) => addDartBinderName(n, shadows)); + return shadows; +} + +/** + * Record the name `node` binds, if it binds one. + * + * The `case` list is the whole point of this function and the reason it is + * separate: an INCOMPLETE list of binder forms is the exact defect this file has + * now shipped twice (formal parameters, then every Dart 3 pattern). It is + * enumerated against `vendor/tree-sitter-dart/grammar.js`, not against the + * shapes a bug report happened to carry. + */ +function addDartBinderName(node: SyntaxNode, out: Set): void { + switch (node.type) { + // `var s;`, `final r = 1;`, and the FIRST declarator of `var a = 1, b = 2;`. + // `for_loop_parts` carries the for-IN variable on the same `name` field + // (`for (var r in xs)`); the C-style form instead nests a + // `local_variable_declaration` the walk reaches on its own. + case 'initialized_variable_definition': + case 'for_loop_parts': { + const nameNode = node.childForFieldName('name'); + if (nameNode !== null) out.add(nameNode.text); + return; + } + // Formal parameters — of the enclosing member, of a closure + // (`function_expression`), and of a nested `local_function_declaration`. + case 'formal_parameter': { + const name = dartParameterName(node); + if (name !== null) out.add(name); + return; + } + // `on E catch (e, stack)` — every identifier in the list is a binding. + case 'catch_parameters': { + for (const child of node.namedChildren) { + if (child !== null && child.type === 'identifier') out.add(child.text); + } + return; + } + // Second and later declarators of `var a = 1, b = 2;` — fieldless, so the + // name is the first named child. (A class field is this node type too, but + // only ever under `initialized_identifier_list`, which no body contains.) + case 'initialized_identifier': { + const first = node.namedChild(0); + if (first !== null && first.type === 'identifier') out.add(first.text); + return; + } + // ── Dart 3 patterns ────────────────────────────────────────────────────── + // + // EVERY pattern node the grammar emits, and every one of them takes its + // DIRECT `identifier` children. The grammar rules that would carry a binder + // are `_pattern_field`, `_map_pattern_entry`, `_list_pattern_element`, + // `_parenthesized_pattern`, `_outer_pattern`, `_guarded_pattern` and the + // logical/relational tiers — ALL hidden (`_`-prefixed), so they emit no node + // of their own and inline their children onto whichever visible pattern node + // encloses them. Reading direct children is therefore what actually sees a + // binder; there is no field to ask for (`variable_pattern`, + // `pattern_variable_declaration` and `constant_pattern` declare none). + // + // The first two are where a binder truly lands, and are alone sufficient: + // `variable_pattern` — `Beta s` / `final s` / `var s`. + // `constant_pattern` — a BARE name (`(s, n)`, `[s, t]`, `{'k': s}`, + // `Point(:s)`, `...s`, `(s)`). Dart itself decides bare-name-binds-vs- + // references-a-constant from the enclosing `final`/`var`, and + // tree-sitter gives both the same node, so `case kLimit:` shadows too. + // Over-shadowing a constant reference costs an edge; the other + // direction fabricates one. + // The containers after them are DEFENCE, not routing — the walk reaches + // nested patterns on its own. They matter because the hidden `_pattern_field` + // already drops a NON-binder label identifier straight onto `record_pattern` + // / `object_pattern` (and a key onto `map_pattern`), which is proof that this + // grammar inlines identifiers onto containers. If a grammar revision ever + // inlines a real binder the same way, it is shadowed here on arrival instead + // of becoming the third instance of this bug. + // + // DELIBERATELY EXCLUDED — `pattern_variable_declaration`, `pattern_assignment` + // and `for_loop_parts` hold the pattern and the `=`/`in` RHS at the SAME + // child level (`var [s, t] = xs` → `identifier xs` is a direct child), so + // taking their direct identifiers would shadow the SOURCE expression's name, + // which binds nothing. Their pattern child is a case below. Also excluded: + // `type_identifier` (never an `identifier`, so `Beta` in `Beta s` and `Point` + // in `Point(…)` cannot be mistaken for binders), `qualified` inside a + // `constant_pattern` (`Colors.red` nests one level deeper — a qualified name + // is always a constant reference), and relational/equality operands + // (`case > kLimit`), which the hidden tier drops onto the enclosing + // STATEMENT rather than any pattern node. + case 'variable_pattern': + case 'constant_pattern': + case 'record_pattern': + case 'list_pattern': + case 'map_pattern': + case 'object_pattern': + case 'rest_pattern': + case 'cast_pattern': + case 'null_check_pattern': + case 'null_assert_pattern': { + for (const child of node.namedChildren) { + if (child !== null && child.type === 'identifier') out.add(child.text); + } + return; + } + default: + return; + } +} + +/** The name a `formal_parameter` binds, across every shape the grammar gives it. */ +function dartParameterName(param: SyntaxNode): string | null { + // `Alpha r`, `final Alpha r`, `void Function(int) r`, `{required Beta r}`, + // `[Delta r]` — all carry an explicit `name` field. + const named = param.childForFieldName('name'); + if (named !== null) return named.text; + + const only = param.namedChild(0); + if (only === null) return null; + // An untyped closure parameter (`(r) { … }`) is a bare identifier with no + // field to read it from. + if (only.type === 'identifier') return only.text; + // `this.r` / `super.r` bind a parameter NAMED `r` that is initialized from + // the field — a later bare `r = …` writes that parameter, not the field, so + // these shadow exactly like any other. + if (only.type === 'constructor_param' || only.type === 'super_formal_parameter') { + for (let i = only.namedChildCount - 1; i >= 0; i--) { + const child = only.namedChild(i); + if (child !== null && child.type === 'identifier') return child.text; + } + } + return null; +} + function emitHeritage(classNode: SyntaxNode, out: CaptureMatch[]): void { const nameNode = classNode.childForFieldName('name'); if (nameNode === null) return; diff --git a/gitnexus/src/core/ingestion/languages/dart/index.ts b/gitnexus/src/core/ingestion/languages/dart/index.ts index f82ada3d8..9059cf108 100644 --- a/gitnexus/src/core/ingestion/languages/dart/index.ts +++ b/gitnexus/src/core/ingestion/languages/dart/index.ts @@ -20,7 +20,7 @@ * - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss counters */ -export { emitDartScopeCaptures } from './captures.js'; +export { emitDartScopeCaptures, dartScopeOwnsReceivers } from './captures.js'; export { getDartCaptureCacheStats, resetDartCaptureCacheStats } from './cache-stats.js'; export { interpretDartImport, diff --git a/gitnexus/src/core/ingestion/languages/dart/query.ts b/gitnexus/src/core/ingestion/languages/dart/query.ts index d6646b92f..38496f2ec 100644 --- a/gitnexus/src/core/ingestion/languages/dart/query.ts +++ b/gitnexus/src/core/ingestion/languages/dart/query.ts @@ -163,6 +163,27 @@ const DART_SCOPE_QUERY = ` (initialized_identifier . (identifier) @declaration.name))) @declaration.property +; Inference-typed fields — \`var b = Outer();\`, \`final b = Outer();\`, +; \`late final b = Outer();\`, \`static var b = Outer();\` (#2807). The two +; patterns above require a written type, so a field whose type comes from its +; initializer produced NO property declaration at all — no Property node, and +; nothing for captures.ts to hang a type binding on, so \`b.inner()\` could not +; resolve its receiver while the annotated twin resolved fine. +; +; Dart spells the keyword as \`inferred_type\` for \`var\` and \`final_builtin\` +; for \`final\` / \`late final\`; both are class fields and both are idiomatic, +; so covering only one would leave the more common Dart style broken. +(declaration + (inferred_type) + (initialized_identifier_list + (initialized_identifier + . (identifier) @declaration.name))) @declaration.property +(declaration + (final_builtin) + (initialized_identifier_list + (initialized_identifier + . (identifier) @declaration.name))) @declaration.property + ; ── Declarations — closure bindings (#2693) ────────────────────────────────── ; \`var f = (x) => x;\` binds a callable. Without a declaration the binding has ; no SymbolDefinition, so callable-value-flow has nothing to attach its seed to diff --git a/gitnexus/src/core/ingestion/languages/dart/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/dart/simple-hooks.ts index bb7d838c2..6e89d22ea 100644 --- a/gitnexus/src/core/ingestion/languages/dart/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/dart/simple-hooks.ts @@ -23,6 +23,7 @@ import type { TypeRef, CaptureMatch, } from 'gitnexus-shared'; +import { walkToScope } from '../../utils/scope-tree-walk.js'; export function dartBindingScopeFor( decl: CaptureMatch, @@ -41,6 +42,15 @@ export function dartBindingScopeFor( return null; } + // (1b) A field typed from a constructor assigned to it (`r = Outer();` in a + // constructor body) must live on the CLASS scope — the assignment sits inside + // the constructor's own Function scope, where `typeOfMemberOnClass` never + // looks (#2807). Gated on the dedicated marker, never on + // `@type-binding.constructor` at large, which also fires for genuine locals. + if (decl['@type-binding.dart-field'] !== undefined) { + return walkToScope(innermost, tree, 'Class'); + } + // (2) Function/method/constructor names are visible in the enclosing scope. if ( decl['@declaration.function'] !== undefined || diff --git a/gitnexus/src/core/ingestion/languages/javascript/captures.ts b/gitnexus/src/core/ingestion/languages/javascript/captures.ts index b0eed8d68..a5cf3059a 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/captures.ts @@ -39,6 +39,14 @@ import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './quer import { computeTsArityMetadata } from '../typescript/arity-metadata.js'; import { synthesizeTsReceiverBinding } from '../typescript/receiver-binding.js'; import { isArrayMethodCallbackArrow } from '../typescript/array-callback.js'; +import { isStaticClassFieldBinding } from '../typescript/captures.js'; + +/** JavaScript's spelling of a class-field declaration — the TypeScript grammar + * calls the same construct `public_field_definition`. Named here, not in the + * shared predicate, so each literal is checked against the grammar of the file + * it lives in (`grammar-literal-validation`). */ +const JS_CLASS_FIELD_DEFINITION_TYPES: ReadonlySet = new Set(['field_definition']); +import { hasKeyword } from '../../field-extractors/configs/helpers.js'; import { synthesizeCjsModuleExports } from '../typescript/cjs-module-exports.js'; import { isShadowedCjsExportAssignment, @@ -638,8 +646,23 @@ function synthesizeConstructorFieldBindings(root: SyntaxNode, out: CaptureMatch[ } // Only process constructor method definitions if (node.type !== 'method_definition') continue; + // …and only a CLASS's. An object literal's members are `method_definition` + // too, so `{ constructor() { this.p = new Alien(); } }` reached here and + // typed a field on whatever class the hoist walked up to — an object + // literal's `this` is the literal, never that class's instance (#2807). + // TypeScript's sibling pattern carries the same constraint in its query + // nesting; the two must not read one source differently. + if (node.parent?.type !== 'class_body') continue; const nameNode = node.childForFieldName('name'); if (nameNode?.text !== 'constructor') continue; + // `static constructor() {}` is legal JavaScript — the reserved-name rule + // applies to instance methods only — and its `this` is the CLASS object, so + // `this.p = new Alien()` there writes a static property and must not type + // the instance field `p`. TypeScript's sibling guard (`isStaticMethodThis`) + // already drops this shape; without the same test here `.js` and `.ts` read + // one source differently. `hasKeyword` skips the `name` field, so a method + // named `static` cannot false-positive. + if (hasKeyword(node, 'static')) continue; const body = node.childForFieldName('body'); if (body === null) continue; @@ -868,6 +891,24 @@ export function emitJsScopeCaptures( } } + // A `static` class field is a member of the class object, not of instances, + // so `static p = new Wrong()` must not retype the `p = new Right()` beside + // it — the two land on one Class scope and the later match wins the `>=` + // tie-break. Shared with TypeScript (`isStaticClassFieldBinding`), which is + // where the reasoning and the grammar evidence live; the two languages read + // the same source and must not read it differently. JavaScript has no type + // annotations, so `field_definition` initializers are the only class-field + // type binding its query emits and `@type-binding.constructor` is the only + // anchor that can carry the modifier. + if ( + isStaticClassFieldBinding( + groupedNodes['@type-binding.constructor'], + JS_CLASS_FIELD_DEFINITION_TYPES, + ) + ) { + continue; + } + // #1876: drop @declaration.function for array higher-order-method // callbacks (`const x = arr.map(a => …)`). The HOC-wrapped-arrow // pattern matches them, but the binding holds a value, not a callable. diff --git a/gitnexus/src/core/ingestion/languages/javascript/query.ts b/gitnexus/src/core/ingestion/languages/javascript/query.ts index 7445929ba..ec2d6a3f2 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/query.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/query.ts @@ -430,6 +430,34 @@ export const JAVASCRIPT_SCOPE_QUERY = ` value: (new_expression constructor: (member_expression) @type-binding.type)) @type-binding.constructor +;; Class field initializer: \`class C { p = new Outer(); }\` (#2807). +;; JavaScript has no field annotations at all, so a class field's type can only +;; ever come from its initializer — without this pattern \`this.p.inner()\` had +;; nothing to type the receiver with and the receiver fold declined the whole +;; chain. \`synthesizeConstructorFieldBindings\` in captures.ts already covers the +;; sibling shape (\`this.p = new Outer()\`), but only inside a \`constructor\` +;; body, so a field initialized at its declaration matched nothing. +;; +;; Anchored on \`field_definition\` so the binding lands in the class body scope, +;; where \`typeOfMemberOnClass\` reads it — the same anchoring TypeScript uses for +;; \`public_field_definition\`. Note the JS grammar names the field \`property:\`, +;; not \`name:\`. +(field_definition + property: (property_identifier) @type-binding.name + value: (new_expression + constructor: (identifier) @type-binding.type)) @type-binding.constructor + +(field_definition + property: (property_identifier) @type-binding.name + value: (new_expression + constructor: (member_expression) @type-binding.type)) @type-binding.constructor + +;; Private-name field: \`#p = new Outer()\`. +(field_definition + property: (private_property_identifier) @type-binding.name + value: (new_expression + constructor: (identifier) @type-binding.type)) @type-binding.constructor + ;; Call-result alias: const u = getUser() (variable_declarator name: (identifier) @type-binding.name diff --git a/gitnexus/src/core/ingestion/languages/javascript/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/javascript/simple-hooks.ts index c0947ba5a..565b04a28 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/simple-hooks.ts @@ -18,7 +18,8 @@ */ import type { CaptureMatch, Scope, ScopeId, ScopeTree } from 'gitnexus-shared'; -import { tsBindingScopeFor, walkToScope } from '../typescript/simple-hooks.js'; +import { walkToScope } from '../../utils/scope-tree-walk.js'; +import { tsBindingScopeFor } from '../typescript/simple-hooks.js'; export { tsImportOwningScope as jsImportOwningScope, diff --git a/gitnexus/src/core/ingestion/languages/python/interpret.ts b/gitnexus/src/core/ingestion/languages/python/interpret.ts index d435809be..1144a7671 100644 --- a/gitnexus/src/core/ingestion/languages/python/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/python/interpret.ts @@ -119,10 +119,17 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi // `cls` is a self-like receiver; share the source label so downstream // `Registry.lookup` Step 2 treats them identically. else if (captures['@type-binding.cls'] !== undefined) source = 'self'; - else if (captures['@type-binding.instance-field'] !== undefined) { + // Before the instance-field arm, not after it: `self.x = Outer()` (#2807) + // carries BOTH markers, and its type is inferred from the CONSTRUCTOR CALL — + // the weakest of the three tiers — so a class that also annotates the field + // keeps the annotation. Testing constructor first is what lets the + // instance-field arm below stay flat; the two orders agree on every input, + // since they differ only where both markers are present and both then say + // 'constructor-inferred'. + else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred'; + else if (captures['@type-binding.instance-field'] !== undefined) source = captures['@type-binding.parameter'] !== undefined ? 'parameter-annotation' : 'annotation'; - } else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred'; else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation'; else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred'; else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation'; diff --git a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts index 620b19302..85ee381e2 100644 --- a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts @@ -114,17 +114,78 @@ export function synthesizeReceiverTypeBinding(fnNode: SyntaxNode): CaptureMatch }; } +/** + * The class name of a direct constructor call — `Outer()` — or `undefined` for + * anything else. Python has no `new`, so a call is the only syntactic + * construction form, and a call to a plain name is the shape every other + * language spells `= new X()`. + * + * BARE NAMES ONLY. A dotted callee (`models.User()`, `f.Alpha()`) is refused. + * The first cut of #2807 returned the callee's full dotted text, on the theory + * that a dotted name resolves through `QualifiedNameIndex` the way the + * module-level `u = models.User()` capture in `query.ts` does. Measured on a + * fixture carrying both shapes, that arm produced no correct edge and did + * produce wrong ones: + * + * - `self.svc = f.Alpha()`, where `Alpha` is a METHOD on an unrelated object + * and `Alpha` is also a class elsewhere: the dotted lookup matches the + * TRAILING segment against a same-named class, so `self.svc.ping()` emitted + * a FABRICATED edge to `Alpha.ping`. The root does not have to be a + * parameter — a module-level `shared_factory.Alpha()` fabricates the same + * way — so no test on the root's binding form contains this. + * - `self.u = models.User()`, the shape the arm was written FOR, emitted no + * edge with the arm or without it. An instance field's binding lands in + * CLASS scope, which never reaches the namespace split that makes the + * module-level local form resolve. + * + * Accepted cost, stated plainly: `self.u = models.User()` still does not type + * its field. It did not before this narrowing either, so nothing that worked is + * given up — and declining to bind is the doctrine `compound-receiver.ts` + * states, that a confidently WRONG owner is strictly worse than a missing edge. + * Re-enabling dotted callees is a RESOLUTION-side change (teach the field path + * the namespace split that #2826/#2828 is reworking); redoing it here could + * only re-open the trailing-segment fabrication. + * + * This is also why the tier tie-break below can stay last-write-wins: the + * displacement it used to suffer — `self.conn = Outer()` then + * `self.conn = Registry.get()`, where the second, dotted, non-type candidate + * overwrote the real constructor binding at the shared weakest tier and left + * the field untyped — cannot arise once a dotted callee yields no candidate at + * all. The weakest tier now ranks only REAL constructions against each other, + * where the last write in `__init__` genuinely is the live one. + */ +function constructorCallTypeName( + right: SyntaxNode | null, + receiverName: string, +): string | undefined { + if (right === null || right.type !== 'call') return undefined; + const callee = right.childForFieldName('function'); + if (callee === null) return undefined; + // An `attribute` callee is the dotted path refused above; `self.p = + // self.build()` is one instance of it, and a method call is not a + // construction. + if (callee.type !== 'identifier') return undefined; + const text = callee.text.trim(); + if (text.length === 0) return undefined; + // `self.p = self()` invokes the instance's own `__call__`. It is a call to a + // bare name, but that name is the receiver, not a type. + if (text === receiverName) return undefined; + return text; +} + /** * Synthesize class-scope field bindings for the common Python constructor - * injection pattern: + * injection patterns: * * def __init__(self, service: Service): - * self.service = service + * self.service = service # from the PARAMETER's annotation + * self.cache: Cache = build() # from the FIELD's own annotation + * self.outer = Outer() # from the CONSTRUCTOR called (#2807) * - * An explicit field annotation (`self.service: Service = ...`) is also - * accepted and takes precedence over a parameter annotation. Deliberately do - * not infer from arbitrary unannotated RHS expressions: the receiver resolver - * needs a declared type, not a name-only guess. + * The three tiers rank in that order — an explicit field annotation beats a + * parameter annotation, which beats a construction. Anything else is still + * refused: the receiver resolver needs positive evidence, not a name-only + * guess from an arbitrary RHS. */ export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): CaptureMatch[] { if (fnNode.childForFieldName('name')?.text !== '__init__') return []; @@ -148,7 +209,18 @@ export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): Capt if (name !== null && annotation !== null) parameterTypes.set(name, annotation.text); } - type Candidate = { readonly match: CaptureMatch; readonly explicit: boolean }; + // Three evidence tiers for one field, strongest first. A later assignment of + // the SAME tier still wins (last write in `__init__` is the live one), but a + // weaker one never displaces a stronger: `self.x: Outer = make()` keeps its + // annotation even if a later branch does `self.x = Other()`. + // + // Same-tier last-write-wins is only sound because `constructorCallTypeName` + // admits nothing but real constructions into the weakest tier — see the note + // there on the `self.conn = Registry.get()` displacement it used to allow. + const TIER_EXPLICIT = 2; + const TIER_PARAMETER = 1; + const TIER_CONSTRUCTOR = 0; + type Candidate = { readonly match: CaptureMatch; readonly tier: number }; const candidates = new Map(); const stack: SyntaxNode[] = [body]; @@ -178,13 +250,30 @@ export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): Capt const explicitType = node.childForFieldName('type'); const parameterType = right?.type === 'identifier' ? parameterTypes.get(right.text) : undefined; - const typeName = explicitType?.text ?? parameterType; + // `self.x = Outer()` — the type comes from the CONSTRUCTOR being + // called (#2807). This is not the "arbitrary unannotated RHS" the + // header warns against: a call to a name that resolves to a class is + // the same positive evidence every other language reads from + // `= new X()`, and without it an unannotated instance field could + // never act as a call receiver at all. Weakest of the three tiers, so + // an explicit annotation or a parameter annotation still wins. + const constructedType = + explicitType === null && parameterType === undefined + ? constructorCallTypeName(right, receiverName) + : undefined; + const typeName = explicitType?.text ?? parameterType ?? constructedType; if (typeName !== undefined) { const explicit = explicitType !== null; + const inferredFromConstructor = constructedType !== undefined; + const tier = explicit + ? TIER_EXPLICIT + : inferredFromConstructor + ? TIER_CONSTRUCTOR + : TIER_PARAMETER; const existing = candidates.get(field.text); - if (existing === undefined || explicit || !existing.explicit) { + if (existing === undefined || tier >= existing.tier) { candidates.set(field.text, { - explicit, + tier, match: { '@type-binding.name': syntheticCapture('@type-binding.name', field, field.text), '@type-binding.type': syntheticCapture( @@ -192,15 +281,26 @@ export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): Capt explicitType ?? right ?? field, typeName, ), + // The marker that tells `interpretPythonTypeBinding` which + // tier this is; an explicit annotation carries neither and + // reads as `annotation`. ...(explicit ? {} - : { - '@type-binding.parameter': syntheticCapture( - '@type-binding.parameter', - right ?? field, - '1', - ), - }), + : inferredFromConstructor + ? { + '@type-binding.constructor': syntheticCapture( + '@type-binding.constructor', + right ?? field, + '1', + ), + } + : { + '@type-binding.parameter': syntheticCapture( + '@type-binding.parameter', + right ?? field, + '1', + ), + }), '@type-binding.instance-field': syntheticCapture( '@type-binding.instance-field', node, diff --git a/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts index 5784f7ad7..6613c6738 100644 --- a/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts @@ -17,6 +17,7 @@ import type { } from 'gitnexus-shared'; import type { SyntaxNode } from 'tree-sitter'; import { findAncestorBeforeBoundary, FUNCTION_NODE_TYPES } from '../../utils/ast-helpers.js'; +import { walkToScope } from '../../utils/scope-tree-walk.js'; const PYTHON_METHOD_CONTAINER_TYPES: ReadonlySet = new Set(['class_definition']); @@ -46,12 +47,7 @@ export function pythonBindingScopeFor( tree: ScopeTree, ): ScopeId | null { if (decl['@type-binding.instance-field'] !== undefined) { - let current: Scope | undefined = innermost; - while (current !== undefined) { - if (current.kind === 'Class') return current.id; - if (current.parent === null) break; - current = tree.getScope(current.parent); - } + return walkToScope(innermost, tree, 'Class'); } return null; } diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index 285e52afa..f66095c02 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -93,6 +93,123 @@ function buildEnclosingQualifiedName(callNode: SyntaxNode): string | undefined { return segments.length > 0 ? segments.join('.') : undefined; } +/** + * Does this `@ivar` write land on an INSTANCE of the enclosing LEXICAL class? + * + * In Ruby an instance variable belongs to whatever `self` is at the point of + * the write, so this is really a question about `self`, and `self` is an + * instance of the enclosing lexical class only inside an ordinary `def` whose + * own definition site is that class's body. Every other arrangement writes an + * ivar some instance of this class will never see: + * + * class C + * @shared = Outer.new # class body: self == C + * def self.build # singleton_method: self == C + * @pool = Outer.new + * end + * class << self # singleton_class: self == C + * def warm; @cache = Outer.new; end + * end + * Other.class_eval do # block receiver: self == Other + * def warm; @far = Outer.new; end + * end + * other.instance_eval { @alien = Outer.new } # self == other + * def run; @pool.inner; end # reads nil — @pool was never set on an instance + * end + * + * So the walk stops on the FIRST ancestor that decides who `self` is, and + * answers true only for an ordinary `method` reached without crossing any + * boundary that could have moved `self` elsewhere. `method` alone is NOT + * sufficient — a `def` nested in a `class << self` body, or in a `class_eval` + * block, is reached through a `method` node first — hence the flag rather than + * an early return (#2807). + * + * ── WHY BLOCKS ARE A HARD STOP, WITHOUT LOOKING AT THE CALL THEY BELONG TO ── + * + * A block is the one construct whose `self` (and whose "default definee", the + * class a `def` inside it attaches to) is chosen by its RECEIVER, not by its + * syntax. `Foo.class_eval do … end`, `Class.new do … end`, `Struct.new(:x) do … + * end`, `Data.define(:x) do … end`, `Module.new`, `refine`, `define_method`, + * `instance_eval`, `instance_exec`, `module_eval` and `class_exec` all rebind + * it; `[1].each do … end` does not. + * + * Telling those apart would need an enumeration of every method that rebinds a + * block's `self`, and that set is OPEN: any user-defined method can do it to a + * block it merely receives — + * + * def helper(&blk) = Foo.class_eval(&blk) + * helper { def warm; @far = Outer.new; end } # attaches to Foo, not here + * + * — so no allow-list of "safe" call names is sound, and a deny-list of known + * rebinders is exactly the incomplete enumeration that produced this bug. The + * only complete answer available from the block's own syntax is that ownership + * is unprovable, so every block boundary is a stop. That over-discards a plain + * `each`/`tap` block, whose `self` really is the instance; per the safety + * doctrine (see `scope-resolution/passes/compound-receiver.ts`) a missed edge is + * the acceptable cost and a fabricated edge on the wrong class is not. + * + * A `class` / `module` keyword nested INSIDE a block still terminates the walk + * with a true answer, and correctly so: that keyword sets the definee lexically + * no matter what surrounds it, so `Class.new do class Inner; def m; @a = …` + * writes a real `Inner` instance field. + * + * ── FORMS THAT CANNOT REACH HERE AT ALL ──────────────────────────────────── + * + * `obj.instance_variable_set(:@a, Outer.new)` is a `call`, not an `assignment`, + * and `Foo.class_eval "def warm; @a = Outer.new; end"` hides its body in a + * `string` node. Neither parses into the `(assignment left: (instance_variable) + * …)` pattern in query.ts, so neither ever produces the marker this gate reads. + * + * Deliberately conservative. A write this returns false for simply binds no + * type at all, which costs at most a missed edge; returning true too eagerly + * invents a call from a receiver that is always nil. + */ +function isRubyInstanceIvarWrite(ivarNode: SyntaxNode | undefined): boolean { + if (ivarNode === undefined) return false; + let insideMethodBody = false; + for (let ancestor = ivarNode.parent; ancestor !== null; ancestor = ancestor.parent) { + switch (ancestor.type) { + // `def self.x` / `def obj.x`, and `class << self` / `class << obj`. + case 'singleton_method': + case 'singleton_class': + return false; + // Every block body: `do … end` and `{ … }` are the only two the grammar + // produces. `lambda` (`->`) always wraps its body in one of them, so it + // can never be the first boundary today — it is listed because the + // boundary IS the lambda, and a grammar change must not silently + // un-guard it. + case 'do_block': + case 'block': + case 'lambda': + return false; + // `BEGIN { … }` / `END { … }` are program-level bodies whose execution is + // relocated out of the enclosing method (before the program, and at exit); + // Ruby warns when they appear in a method body at all. Rather than assert + // whose `self` runs them, the doctrine applies: unprovable, so discard. + // Reachable — tree-sitter parses `END { @a = Outer.new }` inside a `def` + // as an `end_block` under that method's body. + case 'begin_block': + case 'end_block': + return false; + case 'method': + insideMethodBody = true; + break; + // The owning body. Reaching it without crossing any of the boundaries + // above means the write is an instance write exactly when a `def` body + // enclosed it. + case 'class': + case 'module': + case 'program': + return insideMethodBody; + // Everything else is control flow that cannot move `self`: `if`/`unless` + // (`then`), `case`/`when`, `while`, `for`/`do`, `begin`/`rescue`/`ensure`. + default: + break; + } + } + return false; +} + export function emitRubyScopeCaptures( sourceText: string, _filePath: string, @@ -137,6 +254,25 @@ export function emitRubyScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + // A tree-sitter pattern cannot say "and no singleton ancestor", so the + // ownership test is a walk and the whole binding is dropped here when `self` + // is the class object rather than an instance. + // + // Dropping the MARKER alone is not enough, and the class-body shape is why: + // with the marker gone `rubyBindingScopeFor` declines to hoist and the + // binding falls back to its innermost scope — which for `@shared = Outer.new` + // written straight in the class body already IS the Class scope. It would + // arrive at the wrong place by default. Discarding the match is the only + // uniform answer, and it costs nothing that existed before: these ivar + // patterns are new in #2807, so a class-object ivar simply goes back to + // binding nothing, exactly as it did before the pattern was added. + if ( + grouped['@type-binding.ivar-field'] !== undefined && + !isRubyInstanceIvarWrite(nodeMap['@type-binding.ivar-field']) + ) { + continue; + } + // Decompose require/require_relative/load into import captures if (grouped['@import.statement'] !== undefined) { const anchor = grouped['@import.statement']!; diff --git a/gitnexus/src/core/ingestion/languages/ruby/query.ts b/gitnexus/src/core/ingestion/languages/ruby/query.ts index 4e9494c41..f341fb36d 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/query.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/query.ts @@ -178,6 +178,41 @@ const RUBY_SCOPE_QUERY = ` method: (identifier) @_new_method2 (#eq? @_new_method2 "new"))) @type-binding.constructor +;; Instance-variable constructor: \`@service = UserService.new\` (#2807). +;; The patterns above bind locals and constants; an instance variable — the +;; only way a Ruby object gets a field at all — bound nothing, so \`@service.run\` +;; had no receiver type and the fold declined the whole chain. +;; +;; \`@type-binding.name\` is captured on the \`instance_variable\` node, so the +;; bound name keeps its \`@\` sigil and matches the receiver text at the call +;; site verbatim. The narrow \`@type-binding.ivar-field\` marker rides the same +;; node for \`rubyBindingScopeFor\` to hoist on; anchorCaptureFor takes the +;; broadest range, so the assignment stays the anchor and the source stays +;; \`constructor-inferred\`. +;; +;; These patterns match unconditionally HERE; captures.ts then discards the +;; whole binding via \`isRubyInstanceIvarWrite\` unless \`self\` at the write is +;; provably an instance of the enclosing lexical class — which rules out +;; \`def self.x\`, \`class << self\`, the class body itself, and any write reached +;; through a block, whose \`self\` its receiver chooses (\`class_eval\`, +;; \`Class.new\`, \`instance_eval\`, \`define_method\`, …). A tree-sitter pattern +;; cannot state "and no singleton or block ancestor", so the ownership test has +;; to be a walk. + +(assignment + left: (instance_variable) @type-binding.name @type-binding.ivar-field + right: (call + receiver: (constant) @type-binding.type + method: (identifier) @_new_ivar + (#eq? @_new_ivar "new"))) @type-binding.constructor + +(assignment + left: (instance_variable) @type-binding.name @type-binding.ivar-field + right: (call + receiver: (scope_resolution) @type-binding.type + method: (identifier) @_new_ivar_q + (#eq? @_new_ivar_q "new"))) @type-binding.constructor + ;; Constant constructor: SERVICE = UserService.new (left is constant, not identifier) (assignment diff --git a/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts index b09bd2616..59d758632 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts @@ -8,17 +8,38 @@ import type { NodeLabel, } from 'gitnexus-shared'; import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { walkToScope } from '../../utils/scope-tree-walk.js'; export function rubyBindingScopeFor( decl: CaptureMatch, innermost: Scope, - _tree: ScopeTree, + tree: ScopeTree, ): ScopeId | null { // Keep self typeBindings in the method's Function scope so // populateClassOwnedMembers can match Method defs to their receiver types. if (decl['@type-binding.self'] !== undefined) { return innermost.id; } + // `@ivar = Foo.new` in `initialize` (or any method) declares a FIELD of the + // enclosing class, so its type binding belongs on the Class scope — the only + // place `typeOfMemberOnClass` reads it. Left on the method's own Function + // scope it would be invisible to every other method (#2807). + // + // Gated on the marker that pattern emits, never on `@type-binding.constructor` + // at large: that capture also fires for `x = Foo.new` locals, and hoisting + // those to the class would leak a method local into every sibling method. + // + // Reaching this hook already means the write is an INSTANCE write. `Capture` + // carries only name/range/text — no AST node — so this hook cannot ask whose + // `self` owns the ivar; `isRubyInstanceIvarWrite` (captures.ts) answers that + // upstream and discards the binding entirely whenever `self` is anything but + // an instance of the enclosing lexical class — the class object itself + // (`def self.x`, `class << self`, the class body), or whatever object a block + // receiver rebinds it to (`class_eval`, `Class.new`, `instance_eval`, …). + // None of those reach an instance, so none may be published as its field. + if (decl['@type-binding.ivar-field'] !== undefined) { + return walkToScope(innermost, tree, 'Class'); + } return null; } diff --git a/gitnexus/src/core/ingestion/languages/swift/captures.ts b/gitnexus/src/core/ingestion/languages/swift/captures.ts index e3d398695..736c6e962 100644 --- a/gitnexus/src/core/ingestion/languages/swift/captures.ts +++ b/gitnexus/src/core/ingestion/languages/swift/captures.ts @@ -43,7 +43,10 @@ import { import { splitSwiftImport } from './import-decomposer.js'; import { swiftQualifiedBaseTail } from './base-type.js'; import { computeSwiftArityMetadata } from './arity-metadata.js'; -import { synthesizeSwiftReceiverBinding } from './receiver-binding.js'; +import { + findEnclosingTypeDeclaration, + synthesizeSwiftReceiverBinding, +} from './receiver-binding.js'; import { synthesizeSwiftSignatureBindings } from './signature-bindings.js'; import { getSwiftParser, getSwiftScopeQuery } from './query.js'; import { preprocessSwiftConditionalDirectives } from './conditional-directive-preprocess.js'; @@ -53,6 +56,43 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; +/** + * Name of the type that lexically owns `node` — the nearest enclosing + * `class_declaration` (which in tree-sitter-swift is also how `struct` and + * `extension` parse) or `protocol_declaration`. + * + * Qualifies a method def as `.` (#2807 follow-up). Swift's + * structure phase already keys the graph node that way (`A.run#1`), but the + * resolver-side def carried only `run`, and the bridge's every label-scoped key + * is built from the def's name — so two classes in one file each declaring + * `func run` fell through to the label-agnostic simple key, which is + * first-write-wins. The result: EVERY call in both bodies was attributed to + * whichever `run` registered first, which collected duplicate edges while its + * twin collected none. Renaming one method, or moving it to another file, made + * both resolve — which is what identified the collision as name-keyed and + * per-file rather than positional. + * + * An `extension Foo` wraps the extended type in a `user_type`, which may be + * qualified (`extension Outer.Inner`) or carry generic arguments + * (`extension Set`), so the trailing `type_identifier` is taken — the + * spelling has to match the owner the structure phase used to build the node id. + * + * Both halves delegate rather than re-derive: `findEnclosingTypeDeclaration` + * (receiver-binding.ts) decides what the enclosing type IS, so a method's owner + * qualifier and its `self` binding cannot disagree, and `swiftBaseTypeIdentifier` + * reads the name STRUCTURALLY. An earlier version split the node text on `<` and + * `.`, which only approximates the tree: generic arguments live in a sibling + * `type_arguments` node that the walk skips outright. + */ +function swiftEnclosingTypeName(node: SyntaxNode): string | null { + const typeNode = findEnclosingTypeDeclaration(node); + if (typeNode === null) return null; + const nameNode = typeNode.childForFieldName('name'); + if (nameNode === null) return null; + const tail = swiftBaseTypeIdentifier(nameNode)?.text.trim() ?? ''; + return tail.length > 0 ? tail : null; +} + /** Declaration anchors that carry function-like arity metadata. */ const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const; @@ -247,6 +287,25 @@ export function emitSwiftScopeCaptures( } } + // ── Qualify a method/constructor def with its owning type (#2807). ── + // Emitted before the `@scope.function` branch below, which pushes and + // `continue`s; a Swift `function_declaration` matches both patterns. + if (grouped['@declaration.qualified_name'] === undefined) { + const ownerTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined); + const declaredName = grouped['@declaration.name']?.text; + const declNode = ownerTag === undefined ? null : nodeMap[ownerTag]; + if (ownerTag !== undefined && declaredName !== undefined && declNode != null) { + const owner = swiftEnclosingTypeName(declNode); + if (owner !== null) { + grouped['@declaration.qualified_name'] = syntheticCapture( + '@declaration.qualified_name', + declNode, + `${owner}.${declaredName}`, + ); + } + } + } + // ── @scope.function: arity + receiver + signature bindings. ────── if (grouped['@scope.function'] !== undefined) { const fnNodeForArity = nodeIfType( diff --git a/gitnexus/src/core/ingestion/languages/swift/query.ts b/gitnexus/src/core/ingestion/languages/swift/query.ts index 78c1efa41..1d50be2df 100644 --- a/gitnexus/src/core/ingestion/languages/swift/query.ts +++ b/gitnexus/src/core/ingestion/languages/swift/query.ts @@ -107,6 +107,23 @@ const SWIFT_SCOPE_QUERY = ` (type_annotation (user_type (type_identifier) @type-binding.type))) @type-binding.annotation +;; Optional property annotations: \`var owner: Owner?\` (#2807). The pattern +;; above requires the \`user_type\` to be a DIRECT child of the annotation; +;; an optional inserts an \`optional_type\` level between them, so an optional +;; field was never typed at all and \`self.owner!.method()\` could not resolve +;; its receiver. Declaring a field optional and assigning it later is the +;; idiomatic Swift way to express a field that has no value at init time, so +;; this is the common shape, not an edge case. +;; +;; The INNER \`type_identifier\` is captured, so the binding is \`Owner\` with no +;; reliance on \`stripOptional\` reducing a \`Owner?\` spelling. +(property_declaration + name: (pattern + bound_identifier: (simple_identifier) @type-binding.name) + (type_annotation + (optional_type + (user_type (type_identifier) @type-binding.type)))) @type-binding.annotation + ;; ── Type bindings — stored / local-var constructor inference: ;; \`let p = Product(...)\` (constructor) and \`let u = getUser()\` ;; (free-call result; chain-follow resolves getUser → its return type). diff --git a/gitnexus/src/core/ingestion/languages/swift/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/swift/receiver-binding.ts index 92ac3232c..8b0d8c3c5 100644 --- a/gitnexus/src/core/ingestion/languages/swift/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/swift/receiver-binding.ts @@ -39,8 +39,12 @@ const FUNCTION_NODE_TYPES = new Set([ /** Walk up to the enclosing type declaration (class/struct/extension/ * protocol). Nested local functions still see `self` from the enclosing - * type, so don't stop at function-like nodes. */ -function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null { + * type, so don't stop at function-like nodes. + * + * Exported so `swiftEnclosingTypeName` (captures.ts) asks the same question + * the same way — the two must agree on what "the enclosing type" is or a + * method's owner qualifier and its `self` binding name different types. */ +export function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null { let cur: SyntaxNode | null = node.parent; while (cur !== null) { if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur; diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 34146686a..88989d369 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -43,6 +43,7 @@ import { isUnexportedMemberAssignmentValue, isUndeclarableThisMemberValue, } from './cjs-export-assignment.js'; +import { hasKeyword } from '../../field-extractors/configs/helpers.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; @@ -199,6 +200,134 @@ function shouldEmitReadMember(memberNode: SyntaxNode): boolean { } } +/** + * Is this `(this)` node the `this` of a STATIC method? + * + * `this` in a static method is the class object, so `this.x = new Y()` there + * assigns a STATIC property and must never type the instance field of the same + * name (#2807). Every other context that rebinds `this` is excluded + * structurally, by the query's `class_body → method_definition → + * statement_block → expression_statement` nesting; `static` is the one + * constraint tree-sitter cannot carry, because it is an ANONYMOUS token with no + * field name and patterns cannot negate one. + * + * The caller only reaches here for a capture the query already pinned inside a + * class method, so the `method_definition` lookup is a short walk. Detection is + * the shared `hasKeyword`, which is what the TypeScript METHOD EXTRACTOR already + * uses to decide the same question — matching on child TEXT, and skipping the + * `name` field so a method literally called `static()` is not misread. + * + * Text, not node type: `static` reaches the tree as an anonymous token in some + * grammar versions and as a keyword node in others (see `isStaticMember` in + * `receiver-binding.ts`), so a `child.type === 'static'` test silently stops + * firing on a grammar bump and every static `this.x = new Y()` starts typing the + * instance field of that name. `hasKeyword` scans the whole `method_definition` + * rather than stopping at the name, which is safe here because a + * `method_definition`'s own children are its modifiers, name, parameters and + * body — a mention of `static` inside the BODY is a descendant of the body node, + * never a direct child. + */ +function isStaticMethodThis(thisNode: SyntaxNode): boolean { + const method = findSelfOrAncestorOfType(thisNode, 'method_definition'); + if (method === null) return false; + return hasKeyword(method, 'static'); +} + +/** The class-field declaration node a field `@type-binding.*` match anchors on + * in TypeScript/TSX. JavaScript spells the same construct `field_definition` + * and passes its own set in — the predicate below is shared because both + * grammars carry `static` identically, but each language must NAME its own + * node type. Listing both here made `field_definition` a dead literal in the + * typescript grammar, which `grammar-literal-validation` fails on: the gate + * checks every literal against the grammar of the FILE it appears in, and a + * node type that is dead there is exactly how a guard silently stops firing. */ +export const TS_CLASS_FIELD_DEFINITION_TYPES: ReadonlySet = new Set([ + 'public_field_definition', +]); + +/** + * Is this type-binding anchored on a **`static`** class field? + * + * A static member belongs to the CLASS OBJECT; an instance field belongs to + * instances. JavaScript and TypeScript keep the two in separate namespaces, so + * one class may legally declare both under one name: + * + * class Host { + * p = new Right(); + * static p = new Wrong(); // legal — a different member + * hit() { return this.p.hit(); } // `this.p` is Right + * } + * + * Both field patterns anchor their binding on the same CLASS scope with the + * same `constructor-inferred` source, and `scope-extractor` breaks a + * same-strength tie with `>=` — last match wins. So the static field silently + * RETYPED the instance field of that name and `this.p.hit()` resolved to + * `Wrong.hit`: not a missing edge but a wrong one, the failure mode + * `scope-resolution/passes/compound-receiver.ts` exists to avoid. The scope + * tree has one `typeBindings` map per scope with no static/instance split, so a + * static field cannot be recorded separately — it is dropped instead. + * + * WHAT THAT COSTS, MEASURED rather than assumed (#2807 review, S7). An earlier + * version of this comment called the cost "a missed edge beats a wrong one". + * Only half of that is true, and the false half is the one that matters: + * + * shape with the drop without it + * -------------------------- ---------------- ---------------- + * `this.p` (instance twin) Right ✓ Wrong ✗ + * `Host.p` (static twin) Right ✗ WRONG Wrong ✓ + * `Host.q` (static, no twin) — none, missed Wrong ✓ + * + * For a class declaring BOTH twins the wrong edge does not disappear, it MOVES: + * `Host.p` now reads the INSTANCE twin's binding, because that is what is left + * in the map under that name. Only the no-twin case — the common static shape — + * is a true missed edge. + * + * The trade is still the right one, since `this.p` is overwhelmingly the more + * common access and a `Host.p` static chain is the cheaper place to be wrong. + * It is recorded here as a wrong edge rather than described as a missing one so + * that the next person weighing it is weighing the real thing. Closing it + * properly needs a static/instance split that the shared receiver fold cannot + * express today — `foldReceiverChain` in + * `scope-resolution/passes/compound-receiver.ts` explicitly discards whether a + * chain's base was a class reference or a value — so it is a separate change + * with a `SCHEMA_BUMP`, not a tweak here. Both shapes are pinned by rows in + * `test/integration/resolvers/inferred-field-receiver-matrix.test.ts` + * (`static-read-of-a-same-name-twin-picks-up-the-instance-type`, + * `static-read-without-a-twin-loses-its-type`), so the cost moves visibly. + * + * Sibling of {@link isStaticMethodThis}, which drops the ASSIGNMENT form + * (`this.x = new Y()` inside a static method). Together they cover both ways a + * static member can reach the field-typing path. This is an emit-side filter + * for the same reason that one is: `static` is an ANONYMOUS token on the + * declaration node, and a tree-sitter pattern cannot negate one. + * + * Detection is the shared `hasKeyword` — matching on child TEXT, never + * `child.type === 'static'`, because the token reaches the tree as an anonymous + * token in some grammar versions and a keyword node in others (see + * `isStaticMember` in `receiver-binding.ts`); a node-type test silently stops + * firing on a grammar bump and every static field starts retyping its instance + * twin again. Verified against both grammars in use here: `static` is an + * anonymous direct child of the caller's field-definition node — + * `public_field_definition` in TypeScript, `field_definition` in JavaScript — + * ahead of the name. Each language passes its OWN node-type set rather than the + * predicate holding both: a literal is only valid in the grammar of the file it + * appears in, and `grammar-literal-validation` fails a dead one. + * + * One deliberate over-fire: `hasKeyword` skips the node's `name` FIELD, and the + * JavaScript grammar names a field's name `property:`, not `name:` — so the + * legal-but-rare JavaScript field literally called `static` + * (`class C { static = new Right(); }`) reads as static and goes untyped. That + * is a declined binding, the safe direction of the same trade. + */ +export function isStaticClassFieldBinding( + anchorNode: SyntaxNode | undefined, + fieldDefinitionTypes: ReadonlySet, +): boolean { + if (anchorNode === undefined) return false; + if (!fieldDefinitionTypes.has(anchorNode.type)) return false; + return hasKeyword(anchorNode, 'static'); +} + /** Walks the parent chain from `node` (inclusive), returning the first node * whose type matches, or null. Faster than `findNodeAtRange` when the caller * already holds the anchor node — avoids re-scanning the tree from the root. */ @@ -345,6 +474,37 @@ export function emitTsScopeCaptures( } } + // `this. = new …` inside a STATIC method types a static property, + // not the instance field of the same name (#2807). Every other `this`- + // rebinding context is already excluded by the pattern's nesting — see the + // note on it in `query.ts`; `static` is an anonymous token, so no pattern + // can negate it and the last case is dropped here. + const thisFieldNode = groupedNodes['@type-binding.this-field']; + if (thisFieldNode !== undefined && isStaticMethodThis(thisFieldNode)) { + continue; + } + + // …and a `static` FIELD is a member of the class object, not of instances, + // so it must not type the instance field of the same name either — see + // `isStaticClassFieldBinding`. Both class-field anchors are tested: the + // initializer form (`static p = new Wrong()`, `@type-binding.constructor`) + // and the annotated form (`static p: Wrong`, `@type-binding.annotation`), + // which collide on the Class scope the same way. The predicate self-gates on + // the anchor's node type, so the local `variable_declarator` patterns that + // share these tags are untouched. + if ( + isStaticClassFieldBinding( + groupedNodes['@type-binding.constructor'], + TS_CLASS_FIELD_DEFINITION_TYPES, + ) || + isStaticClassFieldBinding( + groupedNodes['@type-binding.annotation'], + TS_CLASS_FIELD_DEFINITION_TYPES, + ) + ) { + continue; + } + // #1876: drop @declaration.function for array higher-order-method // callbacks (`const x = arr.map(a => …)`). The HOC-wrapped-arrow // pattern matches them, but the binding holds a value, not a callable. diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index 57ad0b5f7..c67f8f777 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -882,6 +882,40 @@ export const TYPESCRIPT_SCOPE_QUERY = ` type: (type_annotation (type_identifier) @type-binding.type)) @type-binding.annotation +;; Type bindings — class field constructor-inferred: \`private p = new Outer()\`. +;; The annotation patterns above cover a field that DECLARES its type; a field +;; whose type must be inferred from its initializer matched nothing, so it had +;; no typeBinding, so \`this.p\` could not be typed and the receiver fold declined +;; the whole chain — losing even the first, ordinary named link (#2807). +;; +;; Anchored on \`public_field_definition\` exactly like the annotation patterns, +;; so the binding lands in the same (class body) scope with no bindingScopeFor +;; override. \`annotation\` outranks \`constructor-inferred\` in +;; typeBindingStrength, so \`private p: Outer = new Outer()\` still resolves +;; through its annotation regardless of which pattern matches first. +;; +;; Kotlin and Swift express both a local and a stored property with ONE grammar +;; node (property_declaration) and so needed no separate field pattern; the +;; TypeScript grammar splits them (variable_declarator vs +;; public_field_definition), which is why only the local form was ever covered. +(public_field_definition + name: (property_identifier) @type-binding.name + value: (new_expression + constructor: (identifier) @type-binding.type)) @type-binding.constructor + +;; Qualified: \`private p = new models.Outer()\` — mirrors the local form above; +;; the member_expression's text is resolved via QualifiedNameIndex. +(public_field_definition + name: (property_identifier) @type-binding.name + value: (new_expression + constructor: (member_expression) @type-binding.type)) @type-binding.constructor + +;; Private-name field: \`#p = new Outer()\`. +(public_field_definition + name: (private_property_identifier) @type-binding.name + value: (new_expression + constructor: (identifier) @type-binding.type)) @type-binding.constructor + ;; Type bindings — method return type: \`save(): User { … }\` / \`function f(): User { … }\`. ;; Function/method return-type is the type_annotation that is a direct ;; child of the function node (not the parameter's annotation). Anchor on @@ -1010,6 +1044,78 @@ export const TYPESCRIPT_SCOPE_QUERY = ` right: (new_expression constructor: (identifier) @type-binding.type)) @type-binding.constructor +;; Type bindings — field assigned through \`this\`: \`this.p = new Outer()\` on a +;; field that declares no type (#2807). The rebind pattern above only matches a +;; bare identifier LHS, so an unannotated field assigned in the constructor had +;; no typeBinding at all. (An ANNOTATED field does not need this — its +;; annotation already types it, which is why \`private p: Outer;\` + the same +;; assignment always resolved.) +;; +;; The nesting is the whole safety property. \`this.x = new Y()\` is a legal +;; statement ANYWHERE, and the binding it produces is hoisted onto the nearest +;; enclosing Class — so a context-free version of this pattern typed a class's +;; field from an assignment whose \`this\` was some OTHER object, overwriting the +;; field's real type (pass4CollectTypeBindings resolves a same-source tie in +;; favour of the LATER match). Requiring the chain +;; \`class_body → method_definition → statement_block → expression_statement\` +;; makes the marker fire only where \`this\` provably IS an instance of the class +;; the binding lands on. It rejects, in order of how easily each was hit: +;; a non-arrow callback inside a method (\`function () { this.x = new Y(); }\` — +;; \`this\` is the call's receiver), an object-literal method (also a +;; \`method_definition\`, but under \`object\`, not \`class_body\`), and module top +;; level (no enclosing Class at all, where the hoist used to fall back to the +;; innermost scope and could overwrite a module local of the same name). +;; +;; This mirrors \`synthesizeConstructorFieldBindings\` in +;; \`languages/javascript/captures.ts\`, which walks only \`method_definition\` +;; bodies and only their DIRECT \`expression_statement\` children — the two +;; languages must not read the same source differently. TypeScript accepts any +;; method rather than only \`constructor\` (a later \`setUp()\` assignment types +;; the field just as well), which is the one intended divergence. +;; +;; Two shapes are deliberately NOT matched, both erring toward no binding: +;; an assignment nested in a block (\`if (…) { this.p = new Outer(); }\`) and one +;; inside an arrow (\`() => { this.p = new Outer(); }\`, where \`this\` IS the +;; instance). Both are only ever a MISSING binding, never a wrong one, and JS +;; declines them too. +;; +;; \`@type-binding.this-field\` is a MARKER, not the anchor: it sits on the narrow +;; \`(this)\` node, so anchorCaptureFor's broadest-range rule keeps the whole +;; assignment_expression (@type-binding.constructor) as the anchor and the +;; source stays \`constructor-inferred\`. tsBindingScopeFor reads the marker to +;; hoist the binding onto the enclosing Class scope — without that hoist the +;; binding would land on the method's own Function scope, where +;; typeOfMemberOnClass never looks. The marker must stay specific to THIS +;; pattern: hoisting every constructor-inferred binding would move method-local +;; \`const o = new Outer()\` out of its own scope. +;; +;; One constraint the grammar cannot carry: \`static\` is an ANONYMOUS token on +;; \`method_definition\` with no field name, and tree-sitter patterns cannot +;; negate one. \`this\` in a static method is the class object, so that last +;; wrong receiver is dropped by \`emitTsScopeCaptures\` instead. +(class_body + (method_definition + body: (statement_block + (expression_statement + (assignment_expression + left: (member_expression + object: (this) @type-binding.this-field + property: (property_identifier) @type-binding.name) + right: (new_expression + constructor: (identifier) @type-binding.type)) @type-binding.constructor)))) + +;; Qualified form: \`this.p = new models.Outer()\`. +(class_body + (method_definition + body: (statement_block + (expression_statement + (assignment_expression + left: (member_expression + object: (this) @type-binding.this-field + property: (property_identifier) @type-binding.name) + right: (new_expression + constructor: (member_expression) @type-binding.type)) @type-binding.constructor)))) + (assignment_expression left: (identifier) @type-binding.name right: (call_expression diff --git a/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts index f8f7302fa..a88ea5069 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts @@ -13,6 +13,7 @@ import type { ScopeTree, TypeRef, } from 'gitnexus-shared'; +import { walkToScope } from '../../utils/scope-tree-walk.js'; // ─── bindingScopeFor ────────────────────────────────────────────────────── @@ -55,6 +56,28 @@ export function tsBindingScopeFor( return walkToScope(innermost, tree, 'Class'); } + // `this.p = new Outer()` binds the FIELD, not a method-local, so the binding + // belongs on the class the way an annotated field's does — that is the only + // place `typeOfMemberOnClass` reads. Left on the innermost scope it would sit + // on the method's own Function scope and never be found (#2807). Same shape + // as the parameter-property branch above. + // + // Gated on the marker the `this. = new …` pattern emits, never on + // `@type-binding.constructor` at large: that capture also fires for + // `const o = new Outer()` inside a method, and hoisting THOSE to the class + // would take method locals out of their own scope and mistype them. + // + // This walk is UNCONDITIONAL by design, and stays correct only because the + // marker's producers are bounded: the query nests the pattern under + // `class_body → method_definition → statement_block`, and `emitTsScopeCaptures` + // drops the static-method case. So `this` here provably IS an instance of the + // class this lands on, and a Class ancestor always exists — no `walkToScope` + // null-fallback onto some unrelated innermost scope. Anything that widens the + // marker's producers has to re-establish both, or restore the guard here. + if (decl['@type-binding.this-field'] !== undefined) { + return walkToScope(innermost, tree, 'Class'); + } + // `var` declarations: hoist to nearest enclosing Function or Module. const variable = decl['@declaration.variable']; if (variable !== undefined && isVarDeclaration(variable.text)) { @@ -70,31 +93,6 @@ export function tsBindingScopeFor( return null; } -/** - * Walk up the scope chain to find the first scope whose `kind` matches - * any of `kinds`. Returns the matching scope's id or `null` when no - * ancestor matches (e.g., a return type binding emitted outside any - * Module scope — shouldn't happen in well-formed input). - * - * Exported so language-specific hook wrappers (e.g. `jsBindingScopeFor`) - * can reuse it without duplicating the traversal logic. - */ -export function walkToScope( - from: Scope, - tree: ScopeTree, - ...kinds: readonly Scope['kind'][] -): ScopeId | null { - let cur: Scope | undefined = from; - const kindSet = new Set(kinds); - while (cur !== undefined) { - if (kindSet.has(cur.kind)) return cur.id; - const parentId: ScopeId | null = cur.parent ?? null; - if (parentId === null) break; - cur = tree.getScope(parentId); - } - return null; -} - /** `var x = 1;` vs `let x = 1;` / `const x = 1;`. The capture's text * starts at the outer declaration's `startIndex` in source, which is * the keyword's first character — no leading whitespace possible. */ diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 6f942d88b..89c3e2839 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -194,6 +194,29 @@ function simpleNameOf(qualifiedName: string): string { */ const LOCAL_IDENTITY_SUFFIX = /@\d+:\d+$/; +/** + * The OTHER callable label the same construct may be registered under. + * + * A def and its graph node describe one construct, but they do not always agree + * on its LABEL: some structure phases emit a type's methods as `Function` nodes + * while the scope extractor derives `Method` from the `@declaration.method` + * anchor. Every key `resolveDefGraphId` builds is label-scoped, so such a pair + * misses ALL of them and lands on the label-agnostic, first-write-wins + * `simpleKey` at the bottom (#2807 follow-up, measured in Swift). + * + * ONE definition, consulted by all three key families — position, local-name + * guard, qualified — because they are not independent: leaving it out of the + * position key makes the fail-closed guard beside it UNREACHABLE for exactly the + * split the qualified retry serves, so the retry inherits a case the guard was + * written to stop (a function-local aliased onto a same-named class method). + * What each family may do with it differs and is documented at each site. + */ +function siblingCallableLabel(label: NodeLabel): NodeLabel | undefined { + if (label === 'Method') return 'Function'; + if (label === 'Function') return 'Method'; + return undefined; +} + export function resolveDefGraphId( filePath: string, def: { @@ -214,6 +237,10 @@ export function resolveDefGraphId( const qn = def.qualifiedName; if (qn === undefined || qn.length === 0) return undefined; if (def.type !== undefined) { + // ONE binding for all three key families — see `siblingCallableLabel`, which + // documents why they cannot be given independent answers. What each family + // is allowed to DO with it still differs, and is documented at each site. + const siblingLabel = siblingCallableLabel(def.type); // Position key FIRST (#2699). A def and its graph node are the same // construct, so they share a source line — the only evidence that // separates a function-local declaration from a same-named file-level one @@ -230,6 +257,29 @@ export function resolveDefGraphId( const simple = simpleNameOf(qn); const posHit = nodeLookup.get(positionKey(filePath, def.type, line - 1, simple)); if (posHit !== undefined && posHit !== AMBIGUOUS_POSITION) return posHit; + // Retry under the sibling callable label when the def's OWN label + // registered NOTHING here — see `siblingCallableLabel`. Both keys in this + // block are label-scoped, so under a split the position join misses and + // the guard below cannot fire, and the def falls through to the qualified + // retry that ends on the class method of the same name: a function-local + // `func helper` inside `Host.run` was aliased onto `Host.helper`, taking + // its calls with it, even at a different arity. + // + // Deliberately NOT dot-gated the way the qualified retry is: this key is + // not a name. `(file, line, simple name)` identifies one declaration by + // itself — that is why `positionKey` needs no qualifier at all — so + // crossing the two callable labels here cannot alias a top-level `save` + // onto a class's `save` the way a bare NAME would. + // + // Gated on `posHit === undefined` so an `AMBIGUOUS_POSITION` tombstone + // keeps meaning ambiguous: two callables already claim this line under the + // def's own label, and relabelling must not resolve by picking a third. + if (posHit === undefined && siblingLabel !== undefined) { + const siblingPosHit = nodeLookup.get(positionKey(filePath, siblingLabel, line - 1, simple)); + if (siblingPosHit !== undefined && siblingPosHit !== AMBIGUOUS_POSITION) { + return siblingPosHit; + } + } // FAIL CLOSED when a function-local of this name exists in the file (#2699 // follow-up). Falling through to the name keys would end at the label-agnostic, // first-write-wins `simpleKey` below and alias this def onto whichever same-named @@ -245,6 +295,19 @@ export function resolveDefGraphId( if (nodeLookup.get(localNameKey(filePath, def.type, simple)) !== undefined) { return undefined; } + // Same guard under the sibling label: a local the structure phase + // registered as `Function` must still stop a `Method`-labelled def of that + // name from reaching `simpleKey`, or the split re-opens the fabricated + // edge this guard exists to close. Unlike the position retry above this + // arm is NOT conditioned on the own-label lookup missing — it only ever + // returns `undefined`, and a missing edge is the correct failure + // direction; declining to check would be the risky choice, not this. + if ( + siblingLabel !== undefined && + nodeLookup.get(localNameKey(filePath, siblingLabel, simple)) !== undefined + ) { + return undefined; + } } // Name forms to try for every keyed lookup below, most specific first. // @@ -268,11 +331,32 @@ export function resolveDefGraphId( const nsPrefix = def.namespacePrefix; const nameForms = nsPrefix !== undefined && nsPrefix.length > 0 ? [`${nsPrefix}.${qn}`, qn] : [qn]; + // The label split described on `siblingCallableLabel` also kills every key + // above: they are all label-scoped, so a split pair misses all of them and + // lands on the label-agnostic simple key at the bottom of this function — + // which is first-write-wins, so two same-named methods in ONE file both + // resolved to whichever was registered first. That silently misattributed + // every call in the second method's body to the first (#2807 follow-up; + // measured in Swift, where `class A { func run }` + `class B { func run }` + // gave A.run both bodies' edges and B.run none). + // + // Crossing the two callable labels is sound HERE only for a name that + // carries its owner: `A.run` names exactly one construct whatever the + // label, while a bare `run` is precisely the aliasing the label was added + // to prevent (a top-level `save` vs a class's `save`). Hence the dot gate — + // it keeps the original guarantee intact for unqualified names. The + // position key above needs no such gate because it is not a name. const lookupTagged = (tag: string): string | undefined => { for (const form of nameForms) { const hit = nodeLookup.get(qualifiedKey(filePath, defType, `${form}${tag}`)); if (hit !== undefined) return hit; } + if (siblingLabel === undefined) return undefined; + for (const form of nameForms) { + if (!form.includes('.')) continue; + const hit = nodeLookup.get(qualifiedKey(filePath, siblingLabel, `${form}${tag}`)); + if (hit !== undefined) return hit; + } return undefined; }; diff --git a/gitnexus/src/core/ingestion/utils/call-analysis.ts b/gitnexus/src/core/ingestion/utils/call-analysis.ts index 86be8b8cd..30224d29c 100644 --- a/gitnexus/src/core/ingestion/utils/call-analysis.ts +++ b/gitnexus/src/core/ingestion/utils/call-analysis.ts @@ -555,12 +555,49 @@ export function extractCallChain( * Deliberately EXCLUDES a cast (`x as T`, `(T)x`): a cast changes the type an * expression denotes, so reading through one would type the receiver as the * operand rather than as the cast target. + * + * Keyed by node type; the value is the operator text that has to TERMINATE the + * node for the peel to apply, or `null` for a node type that is transparent + * unconditionally. + * + * The gated entry exists because Swift force-unwrap (`self.a!`) is the exact + * semantic of TypeScript's `non_null_expression` — it yields the wrapped type — + * but Swift parses it as the general `postfix_expression`, which ALSO carries + * user-defined postfix operators. Those can return anything, so peeling that + * node type unconditionally would type the receiver as the operand and could + * produce a confidently wrong owner. Reading the operator keeps the peel to the + * case that is provably type-preserving. */ -const TRANSPARENT_RECEIVER_WRAPPERS = new Set([ - 'non_null_expression', // TypeScript `svc!` - 'parenthesized_expression', // `(svc)` +const TRANSPARENT_RECEIVER_WRAPPERS = new Map([ + ['non_null_expression', null], // TypeScript `svc!` + ['parenthesized_expression', null], // `(svc)` + // NOT Swift-only: Kotlin's `!!` non-null assertion parses as the same node type + // and is equally type-preserving, so it is peeled too. Measured — the + // receiver-resolution bench moved `kotlin.nonNullAssert` VISIBLE-GAP -> + // RESOLVES when this landed, which is how the Kotlin effect was discovered + // rather than assumed. Any other grammar emitting `postfix_expression` is + // affected as well; the `!` gate, not the language, is what bounds this. + ['postfix_expression', '!'], // Swift `self.a!`, Kotlin `a!!` ]); +/** Is `node` a wrapper that denotes exactly what its operand denotes? */ +function isTransparentReceiverWrapper(node: SyntaxNode): boolean { + // `node.type` is a native getter, and this predicate runs on every receiver + // node. Crossing it twice for two separate table lookups measured 376 ns/check + // against 188 ns for the single read below. + const operator = TRANSPARENT_RECEIVER_WRAPPERS.get(node.type); + // `undefined` is "not in the table"; `null` is "in the table, ungated". The + // two are distinguishable, so one `get` answers both questions — no separate + // `has` probe, and one crossing of the native `node.type` getter. + if (operator === undefined) return false; + if (operator === null) return true; + // The operator is an anonymous token, so it is not in `namedChildren`; the + // node's own text is the reliable place to read it. Deliberately NOT + // `node.lastChild` — measured 3x SLOWER (the child wrapper allocation + // dominates), and the token surfaces with type `bang`, not `!`. + return node.text.trimEnd().endsWith(operator); +} + /** * Iteration bound for the wrapper peel. Its OWN constant, not `MAX_CHAIN_DEPTH`. * @@ -575,11 +612,7 @@ const MAX_TRANSPARENT_WRAPPER_DEPTH = 3; /** Peel transparent wrappers off a base receiver node. */ function unwrapTransparentReceiver(node: SyntaxNode): SyntaxNode { let current = node; - for ( - let i = 0; - i < MAX_TRANSPARENT_WRAPPER_DEPTH && TRANSPARENT_RECEIVER_WRAPPERS.has(current.type); - i++ - ) { + for (let i = 0; i < MAX_TRANSPARENT_WRAPPER_DEPTH && isTransparentReceiverWrapper(current); i++) { const inner = current.namedChildren?.find((c) => c !== null); if (inner === undefined || inner === null) break; current = inner; diff --git a/gitnexus/src/core/ingestion/utils/scope-tree-walk.ts b/gitnexus/src/core/ingestion/utils/scope-tree-walk.ts new file mode 100644 index 000000000..d7bb86f1e --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/scope-tree-walk.ts @@ -0,0 +1,38 @@ +/** + * Scope-tree walking primitives shared by the providers' `bindingScopeFor` + * hooks. + * + * Language-neutral: parameterised over `ScopeKind` — the shared scope + * vocabulary — and names no language, so it belongs in the core pipeline + * rather than in any one provider (AGENTS.md § shared pipeline code). + */ + +import type { Scope, ScopeId, ScopeTree } from 'gitnexus-shared'; + +/** + * Walk up the scope chain to find the first scope whose `kind` matches + * any of `kinds`. Returns the matching scope's id or `null` when no + * ancestor matches (e.g., a return type binding emitted outside any + * Module scope — shouldn't happen in well-formed input). + * + * Every provider that hoists a binding out of the scope it was captured in + * needs exactly this walk: a field typed from a constructor call in a method + * body belongs on the enclosing Class, a `var` on the enclosing Function or + * Module, a method return type on the Module. Which `kind` to stop at is the + * only per-language part, and that is the parameter. + */ +export function walkToScope( + from: Scope, + tree: ScopeTree, + ...kinds: readonly Scope['kind'][] +): ScopeId | null { + let cur: Scope | undefined = from; + const kindSet = new Set(kinds); + while (cur !== undefined) { + if (kindSet.has(cur.kind)) return cur.id; + const parentId: ScopeId | null = cur.parent ?? null; + if (parentId === null) break; + cur = tree.getScope(parentId); + } + return null; +} diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 3cc5b9767..8b4880554 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -174,7 +174,38 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // "pick a bigger number": it is that the check must happen immediately before // merge, because the window between review and merge is exactly when `main` // allocates. Re-check against origin/main before merging this. -const SCHEMA_BUMP = 39; +// v40: inference-typed class fields emit type-binding captures in SIX languages +// (#2807) — TypeScript/JavaScript `public_field_definition|field_definition` with a +// `new_expression` value and `this. = new X()`; Python `self.x = Outer()`; +// Ruby `@ivar = Foo.new`; Swift optional property annotations; Dart inferred-type +// and final field declarations plus constructor-body field writes. Every one of +// these is PARSE-TIME capture emission, so a warm cache replays the pre-fix +// capture set verbatim for byte-unchanged files and the new receiver edges never +// appear — silently, with no error, exactly the v27/v30 failure mode. `analyze` +// skips tree-sitter dispatch for unchanged chunks (GUARDRAILS.md), so a plain +// re-analyze does NOT surface them without this bump. +// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING — main was also at 39 +// when this was allocated, and this file records eight prior collisions. +// v41: the v40 Dart field-write binding gained its READ-side mask (#2807 review) +// — a Dart class-member body that rebinds one of its class's field names now +// emits `@receiver-owner.shadowed-fields` on its synthesized `@scope.function` +// match, which becomes `Scope.ownsReceivers`. Parse-time capture emission again, +// so a v40 warm cache replays scope matches with no marker and the receiver walk +// still reaches the class field — i.e. it keeps serving the WRONG edge this +// bump's fix removes, silently. Same bump-or-nothing situation as v40. +// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. +// v42: the v40/v41 Python constructor-field arm stopped accepting a DOTTED +// callee (#2807 review). `self.svc = f.Alpha()` no longer emits a +// `@type-binding.constructor` capture at all, which is what removes the +// fabricated edge to the same-named class `Alpha` and what stops +// `self.conn = Registry.get()` displacing an earlier real `self.conn = Outer()`. +// A within-PR re-bump, not a collision fix: v41 was allocated by this same +// unmerged branch, so v41-stamped caches exist only on it — but they exist on +// every reviewer's and CI runner's checkout of it, and parse-time emission means +// they replay the pre-fix capture set for byte-unchanged files and keep serving +// the fabricated edge. main is at 39, so 40/41/42 are all this branch's. +// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. +const SCHEMA_BUMP = 42; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json index 316af34d4..dd1540ec7 100644 --- a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json @@ -5,7 +5,7 @@ }, "swift-abstract-dispatch/Sources/Repository.swift": { "captureGroups": 26, - "digest": "36d177980181b97f016d834a9a57b7e559e61558a9f4e1c45809604886b18aa7" + "digest": "d8a8c59b7236d6b25a063b8584637d5c89a86cf022776272f80aeb4f1e4826b9" }, "swift-await-try/App.swift": { "captureGroups": 13, @@ -13,7 +13,7 @@ }, "swift-await-try/Models.swift": { "captureGroups": 21, - "digest": "eb9e2b04b9dd55d161c58b5004adde0f881a138b790985fd523e110b87035ffd" + "digest": "df2a06182275e023c6f40e978f60a0ff933fccc711d7c15f4a2434e8e282a653" }, "swift-call-result-binding/App.swift": { "captureGroups": 7, @@ -21,11 +21,11 @@ }, "swift-call-result-binding/Models.swift": { "captureGroups": 15, - "digest": "7f068c35624ff48635c1f51ec79e871511200a044eb4920bedd6673c4f690911" + "digest": "d312b4003cbe7f3eec8e1b6f924b0e1afb7023ca086ec26ef0ad69078cc67925" }, "swift-child-extends-parent/Sources/App.swift": { "captureGroups": 10, - "digest": "19b16f002f2e10b661ada767769723ed92c6101fbf8dd895a9077c563c06946d" + "digest": "9532b3989cd104dd468deb8d671e727cdf944ebb858e002072875519a014e968" }, "swift-child-extends-parent/Sources/Child.swift": { "captureGroups": 4, @@ -33,11 +33,11 @@ }, "swift-child-extends-parent/Sources/Parent.swift": { "captureGroups": 7, - "digest": "ee1086e1a0cbfc57f381401b81b2e150f422a64cd3b937f083a1fbbd85e5a27e" + "digest": "09d26410736d5ca5ec8dfe598212e1eefc64075bc2199fda170bb08a478392a4" }, "swift-class-func-receiver/Service.swift": { "captureGroups": 27, - "digest": "6e9c6bd2ebb65624a5079a1ce18b5b8c6a73af5e3216580bb558f0e22a0ec689" + "digest": "5d7e5b278bdc94cc2014ebf2f8c975afb10aa80bc2e1bd62ec5559a102fc8e41" }, "swift-constructor-fallback/App.swift": { "captureGroups": 7, @@ -45,15 +45,15 @@ }, "swift-constructor-fallback/Service.swift": { "captureGroups": 7, - "digest": "685538adb8aede40f7969572a4c8ba3d491413de22798e109800a121d8d88223" + "digest": "5744bba4c9b821b2264dea36de3e864723f8ca1be9face21ec9de55117a9e7a5" }, "swift-constructor-type-inference/Models/Repo.swift": { "captureGroups": 15, - "digest": "9f932b70d43c83b403a456b5854586f29238cfb573dff7e1183aea20d7e7f18d" + "digest": "e8ea96d49fc2113e29b88961686e39472b92481e929b3f887d53b65201e2e7bb" }, "swift-constructor-type-inference/Models/User.swift": { "captureGroups": 15, - "digest": "1fcd2298bb1b7d0d6ff42248cedd66d31a2e3e0de8645162ee3eb31924ac9987" + "digest": "fe1039891719c640bfa7d362040460addcf744eed1350521cbcc7c9399395087" }, "swift-constructor-type-inference/Services/App.swift": { "captureGroups": 12, @@ -61,7 +61,7 @@ }, "swift-enum-members/Direction.swift": { "captureGroups": 18, - "digest": "6378c8860113e9d0331f4da4d05e64e6933a995493131d2f2165f64f82978b0b" + "digest": "bd0bf779b08305fcd7dfadfcad45319123393aadc17044784314a7b34cb9e6ae" }, "swift-export-visibility/App.swift": { "captureGroups": 10, @@ -69,7 +69,7 @@ }, "swift-export-visibility/Visible.swift": { "captureGroups": 15, - "digest": "9531822091fc521783d6815f03ed503f8d1010289b401f6f2b2057b422de3e76" + "digest": "c8515702b94de2f9e3ad009daec61bad6f9328fb39796e57ec03348438446c4c" }, "swift-extension-dedup/App.swift": { "captureGroups": 7, @@ -77,11 +77,11 @@ }, "swift-extension-dedup/Product.swift": { "captureGroups": 15, - "digest": "81ef7e833c46fdedfd8f632fe0918b768e88d4490e886a763760f26549346009" + "digest": "c0f08c41829db294b2c89876a329cfffd7f3fb0a4b2bca315ae867a1d263a068" }, "swift-extension-dedup/ProductExtensions.swift": { "captureGroups": 8, - "digest": "1e072eb427bb17ea1a225bcc85e0defe74297e3e0ae05c7dc9c04a7e870b0508" + "digest": "228f2f32494a51fb371c754d9ae11a94e7181a14d875a339c631e26f82df542e" }, "swift-field-types/App.swift": { "captureGroups": 7, @@ -89,7 +89,7 @@ }, "swift-field-types/Models.swift": { "captureGroups": 20, - "digest": "e55d0207f950d63ace5efb93551971b10e1fc6cc264e17098f006aa606d1e19c" + "digest": "cad215dd5ce4d0c93056ec5600663b8d17eebbefa06d09675fb64392196531ab" }, "swift-for-loop-inference/App.swift": { "captureGroups": 5, @@ -97,7 +97,7 @@ }, "swift-for-loop-inference/Models.swift": { "captureGroups": 11, - "digest": "5423257303f45305269592ec1d4e64e1fcdc0aa1387f5adab5fa53a9906fc8e2" + "digest": "6799fe82225f3f2f256df49d7238e4f241dbe2ea8109d7095bdac1d16de132ff" }, "swift-if-let-guard-let/App.swift": { "captureGroups": 11, @@ -105,7 +105,7 @@ }, "swift-if-let-guard-let/Models.swift": { "captureGroups": 19, - "digest": "a21d2c56f0d5665da624993acbf14b2ede5aa4e2b9f161b8f9b21fc261707609" + "digest": "51d6986620c07de553b2c8d848d908899a96c7174a7ae56bf68c7de930c8d5c5" }, "swift-implicit-imports/App.swift": { "captureGroups": 7, @@ -113,11 +113,11 @@ }, "swift-implicit-imports/Models.swift": { "captureGroups": 7, - "digest": "9d0a738f1fdd31c1e204b20f1ccc76428f9f300eed82191da63f70799ff18f09" + "digest": "995e604bfd03b42eaeabff130fd1671e524832c64ee52f01cd193a88283d3764" }, "swift-init-cross-file/User.swift": { "captureGroups": 18, - "digest": "4e7df3b46d39126ab8d6a21a6a4103453f38e1b8a2fe159447abaa85381b6295" + "digest": "13d60237fb85ddeba695f3e5f0a202c327309bf159a04e696531c419c42e8cf1" }, "swift-init-cross-file/main.swift": { "captureGroups": 8, @@ -129,11 +129,11 @@ }, "swift-member-write-access/Models.swift": { "captureGroups": 26, - "digest": "25be33df18e7eb8fcb9005c8a963205a3d93f37d0950ca187e9caf569fc5dfa4" + "digest": "376359897f8706015f702b2f3346ad4ca4e78e9f11ae6425746ae57116c2cc51" }, "swift-method-enrichment/Sources/Animal.swift": { "captureGroups": 24, - "digest": "a49732a1d0fdf5d33b1f0c321a0413163d5af3a353b83510dae5d801109788c5" + "digest": "48bd53d1e69d4a48569985296f5aa44d4b41ff474566445f4e3d33390165c84d" }, "swift-method-enrichment/Sources/App.swift": { "captureGroups": 10, @@ -145,7 +145,7 @@ }, "swift-multi-if-let/Models.swift": { "captureGroups": 24, - "digest": "0c072ac3f476a9e563190de555eeaff09a62128d9e7760912655cea9bdefbe6e" + "digest": "e6add0cb3679cb2800094e41d4afc66c5d714c19ffefc38edfb5686ed9d46d51" }, "swift-multidir-target/Package.swift": { "captureGroups": 5, @@ -153,7 +153,7 @@ }, "swift-multidir-target/Sources/Alpha/Core/User.swift": { "captureGroups": 7, - "digest": "b5fa3ef978d5bb5322a433761891ffaa86988f805bc9d419cfa1f24e9fcffa39" + "digest": "a67cb60787680595b43af3fdd371ad84237e17fa7de928610a8c14e8ac062043" }, "swift-multidir-target/Sources/Alpha/Entry/App.swift": { "captureGroups": 7, @@ -161,11 +161,11 @@ }, "swift-multidir-target/Sources/Beta/Core/User.swift": { "captureGroups": 7, - "digest": "10c5e3b4724499ce2e43f1838a516e0387f74045450560aec033e3357d99fd62" + "digest": "434aaaf05cc49f019807499f5f0f08a2b383dc289f3e3308a2ba37e6fdca2330" }, "swift-multifolder-nopackage/Models/User.swift": { "captureGroups": 7, - "digest": "4a05d8ee43df38acb8c91ee66bd0b9c5f4ede20f1330a2977981cf431c16fb1e" + "digest": "356dcd75eb93636607d406e26ce0e62dfa1fe9c78dcd1f950adca304a14d3ad1" }, "swift-multifolder-nopackage/Services/App.swift": { "captureGroups": 7, @@ -173,11 +173,11 @@ }, "swift-nested-extension/Extension.swift": { "captureGroups": 7, - "digest": "3e6dfc7895f9c257be1524e43f004497faca6f765311474fc4bcc85f1d222940" + "digest": "c69b554d6a1372a449198db7581a2713a1850bded67874101d3cf2faa784f646" }, "swift-nested-extension/Types.swift": { "captureGroups": 13, - "digest": "16d599d9805cba59b1bb22598b09e1d25e3dd80da52a4a2c04c23581a8b65d6e" + "digest": "2738a4d77473a2641166b95bc1107f99afca55e9b9d4186eb102e053d378d8ab" }, "swift-overload-dispatch/App.swift": { "captureGroups": 7, @@ -185,23 +185,23 @@ }, "swift-overload-dispatch/Repository.swift": { "captureGroups": 11, - "digest": "b5ed80965325806c715d1c3a65163b014e0bbba5ca7e2df9d7c81b084fada15e" + "digest": "07c6509becf6ae01ae336c53ddeeff38b486bea4a7facad0edf129eb67401408" }, "swift-overload-dispatch/SqlRepository.swift": { "captureGroups": 28, - "digest": "40caf38a1bbe958294ffaacb921f1ef08131445ed959f549fe9be86ea0cdb289" + "digest": "59d11755ba6796355751608bbf74bd03b99edd6c601c51dcd3e46ff33a4af659" }, "swift-parent-resolution/Sources/Models/BaseModel.swift": { "captureGroups": 7, - "digest": "f8a7f98e0df2132df1bd48efef94135214a4072cf47ac5e42e64c99da50bcaa6" + "digest": "0d9056d5dbd480f04ad915c628c81a71e9c1b4ec8981d8027d3a4edcd0f06427" }, "swift-parent-resolution/Sources/Models/Serializable.swift": { "captureGroups": 6, - "digest": "684be5a2e9d7c03c4a9ce209fe5719a776ad4fe0ed12a7a79ba64eed6f34a40e" + "digest": "502ce9136303235cb8e1cdd459afeb4bb76c658d186c63731a3dbb3c42fadac9" }, "swift-parent-resolution/Sources/Models/User.swift": { "captureGroups": 10, - "digest": "bd01b5adcd523ceee95772cc74ded0dbc1d70449fc9e9d17e975299dcbac783f" + "digest": "9bc07345adb74d5e2b4711354175825ef00918dbd61785e472d091f14567f9d5" }, "swift-protocol-property/Repository.swift": { "captureGroups": 7, @@ -213,7 +213,7 @@ }, "swift-qualified-base/Sources/Outer.swift": { "captureGroups": 9, - "digest": "8674f64c110ed91d7e63add63a90612c56d7b827e1b0ade54841677e82c56d80" + "digest": "1ff59349dc4d45a5e1f6fdebf19caf65427d3904eddbc50efeb0879e31574a17" }, "swift-return-type-inference/App.swift": { "captureGroups": 21, @@ -221,7 +221,7 @@ }, "swift-return-type-inference/Models.swift": { "captureGroups": 29, - "digest": "fd0c6c7970e237bed4a305a84ee33d00dbc7c06d97c2e076911ae6ca741cad98" + "digest": "bc539da3c97ac77543b7a1cce457c6ec0f8072c1e5861bfc027cf8fe8f4b541c" }, "swift-return-type/App.swift": { "captureGroups": 7, @@ -229,18 +229,18 @@ }, "swift-return-type/Models.swift": { "captureGroups": 21, - "digest": "d92ce7a1ec19c86a1848fa1d72d151c8059714c9129b70e8d260135a6a4a4993" + "digest": "dc230a5f6085165fb717632f62b65fd3c99825b5fcc7a3a597a8d9143627729e" }, "swift-self-this-resolution/Sources/Models/Repo.swift": { "captureGroups": 7, - "digest": "2f104d81deb40413f23cec9ed5e1e11585cd7075c101158fc95ed3c44cb7e778" + "digest": "61fe84aa9826726ee02bce54c62507cc4f27e56e9be2d57c10f55dd8043e13d9" }, "swift-self-this-resolution/Sources/Models/User.swift": { "captureGroups": 11, - "digest": "c0c8e583896b1d9c889b43682d53b0d6b8194f54a27ae16a4d89a5ad79354fdc" + "digest": "1841837c805e19746138dbb80ccdfccbce483d68d1e7ea08d92daec255cfbdc7" }, "synthetic:dao-20": { "captureGroups": 361, - "digest": "d38aec3ca6fad59b943b3515691adc169dc4b40b70711139e9f0301be844e6fa" + "digest": "aa1747e2f297f5a5a71bd0361273f5505adf93db3d4a2ae3111916bc109c1c7b" } } diff --git a/gitnexus/test/integration/cfg/pdg-chained-receiver-callees.test.ts b/gitnexus/test/integration/cfg/pdg-chained-receiver-callees.test.ts index c3f83dd4d..f10a10c1a 100644 --- a/gitnexus/test/integration/cfg/pdg-chained-receiver-callees.test.ts +++ b/gitnexus/test/integration/cfg/pdg-chained-receiver-callees.test.ts @@ -12,12 +12,11 @@ * impact-pdg-fullchain-e2e; what those cannot catch is a chain link silently * missing from the column they both read. * - * ── WHERE THE SUPPORT ACTUALLY STOPS ────────────────────────────────────────── + * ── WHAT REACHES THE CELL ───────────────────────────────────────────────────── * - * Chained resolution is NOT general. Measured against this fixture (one repo per - * shape and all shapes in one repo agree, so the rows do not contaminate each - * other), the discriminator is whether the receiver's type is DECLARED, not - * whether it is a local or a field: + * Measured against this fixture (one repo per shape and all shapes in one repo + * agree, so the rows do not contaminate each other). Every receiver form now + * carries its whole chain, whether the receiver's type is declared or inferred: * * receiver form calleeIds cell * --------------------------------------------------- ------------------------ @@ -26,29 +25,16 @@ * field `private p: Outer;` + ctor `this.p = new ...` Outer.inner + Inner.compute * receiver is a call result `makeOuter().inner()...` makeOuter + both links * three links `o.inner().mid().compute()` all three links - * field `private p = new Outer()` (INFERRED) EMPTY <- known gap - * field `private p;` + ctor `this.p = new Outer()` EMPTY <- known gap + * field `private p = new Outer()` (INFERRED) Outer.inner + Inner.compute + * field `private p;` + ctor `this.p = new Outer()` Outer.inner + Inner.compute * - * An inference-typed receiver does not merely lose the CHAINED link — it empties - * the whole cell, so the descent cannot cross into `Outer.inner` either, even - * though that call has a perfectly ordinary named receiver. Those two rows are - * pinned by `KNOWN GAP: an inference-typed receiver empties the WHOLE calleeIds - * cell` below, which asserts the current empty value EXACTLY. - * - * ── THIS PIN IS SELF-DIFFING: IT WILL GO RED ON PURPOSE ─────────────────────── - * - * The gap is tracked as issue #2807 ("Inference-typed field receivers resolve to - * no CALLS edges at all"); PR #2810 is open against it at the time of writing. - * The `KNOWN GAP` test asserts that the gap EXISTS — the empty cell, exactly — - * so it is not a regression guard, it is a record. Whoever closes #2807 will see - * it fail with the newly resolved ids in the diff; that is the intended signal, - * and the fix is to update this file (the table above, the rows' `resolution`, - * and the pin's expected value), not to relax the assertion. A single-shape - * fixture, or an `it.fails` row, would instead have kept quietly implying that - * chained receivers work in general. - * - * The gap was FOUND during #2802 work but is PRE-EXISTING and independent of it: - * nothing on that branch touches receiver typing. Track the gap itself at #2807. + * The last two rows were EMPTY before #2807 — not a truncated chain, an empty + * cell, so the descent could not cross into `Outer.inner` either even though + * that call has a perfectly ordinary named receiver. The cause was upstream of + * the PDG entirely: an untyped field had no type binding, so the receiver fold + * declined at its first step and no link was ever resolved to put here. The + * resolver-level view of the same fact, with the full shape table, lives in + * `test/integration/resolvers/typescript-inferred-field-receiver.test.ts`. * * Self-contained fixture rather than an addition to `fixtures/pdg-repo` — that * fixture is shared by eight suites including a snapshot test, so growing it to @@ -162,15 +148,13 @@ const INNER_MID = `Method:${FIXTURE_PATH}:Inner.mid#0`; const MID_COMPUTE = `Method:${FIXTURE_PATH}:Mid.compute#1`; const MAKE_OUTER = `Function:${FIXTURE_PATH}:makeOuter`; -/** - * `reaches-pdg` — every link's id lands in the cell today. - * `known-gap-empty-cell` — the resolver cannot type the receiver, so the cell is - * emitted EMPTY and the descent cannot cross ANY link of the chain. - */ -type ChainResolution = 'reaches-pdg' | 'known-gap-empty-cell'; +/** Every link's id lands in the cell. The only value today — the + * inference-typed rows joined it in #2807 — but kept as a named type so a + * future gap row has somewhere to say so instead of being a bare boolean. */ +type ChainResolution = 'reaches-pdg'; interface ReceiverShape { - /** Row name; also the key of the known-gap pin below. */ + /** Row name; also the assertion key in the diff when a row moves. */ readonly name: string; /** Unique fragment of the chained statement, used to find its block. */ readonly marker: string; @@ -210,20 +194,22 @@ const RECEIVER_SHAPES: readonly ReceiverShape[] = [ links: [OUTER_INNER, INNER_MID, MID_COMPUTE], resolution: 'reaches-pdg', }, - // ── Known gaps ──────────────────────────────────────────────────────────── - // Identical to the two rows above except that the field has no type - // annotation, so its type would have to be inferred from the initializer. + // ── Inference-typed fields (#2807) ──────────────────────────────────────── + // Identical to the two annotated rows above except that the field declares no + // type, so its type comes from the initializer. Both emitted an EMPTY cell + // until #2807 — the descent could not cross even `Outer.inner`, a plainly + // named receiver call. { name: 'inferred-field', marker: 'this.inferred.inner().compute(', links: [OUTER_INNER, INNER_COMPUTE], - resolution: 'known-gap-empty-cell', + resolution: 'reaches-pdg', }, { name: 'ctor-assigned-inferred', marker: 'this.ctorUntyped.inner().compute(', links: [OUTER_INNER, INNER_COMPUTE], - resolution: 'known-gap-empty-cell', + resolution: 'reaches-pdg', }, ]; @@ -256,7 +242,7 @@ function assertChainReachesPdg(shape: ReceiverShape): void { expect(ids).toEqual(expect.arrayContaining([...shape.links])); } -describe('PDG calleeIds — chained receiver calls by receiver form (known gap: #2807)', () => { +describe('PDG calleeIds — chained receiver calls by receiver form (#2802 follow-up)', () => { beforeAll(async () => { const dir = repos.dir(); fs.mkdirSync(path.join(dir, path.dirname(FIXTURE_PATH))); @@ -281,25 +267,28 @@ describe('PDG calleeIds — chained receiver calls by receiver form (known gap: expect(counts).toEqual(Object.fromEntries(RECEIVER_SHAPES.map((s) => [s.name, 1]))); }); - // The `known-gap-empty-cell` rows are deliberately absent here — an `it.fails` - // row over them would be strictly weaker than the exact pin below, since - // `it.fails` is satisfied by ANY throw, including `idsFor`'s own non-vacuity - // guard. Fixture drift that renamed a marker would keep it green while the - // premise had rotted. for (const shape of RECEIVER_SHAPES.filter((s) => s.resolution === 'reaches-pdg')) { it(`${shape.name}: every chain link's exact id reaches calleeIds`, () => { assertChainReachesPdg(shape); }); } - // Pins the CURRENT broken value, not merely that the chain fails: both known - // gaps emit an EMPTY cell — the first link (`Outer.inner`, a plainly named - // receiver) is gone too. This asserts the gap EXISTS (issue #2807), so closing - // #2807 turns it red BY DESIGN; update it together with the header table and - // the rows' `resolution` rather than loosening it. - it('KNOWN GAP (#2807): an inference-typed receiver empties the WHOLE calleeIds cell', () => { - const gaps = RECEIVER_SHAPES.filter((s) => s.resolution === 'known-gap-empty-cell'); - const observed = Object.fromEntries(gaps.map((s) => [s.name, idsFor(s.marker)])); - expect(observed).toEqual({ 'inferred-field': [], 'ctor-assigned-inferred': [] }); + // The inference-typed rows are asserted as a SET, in one assertion, on top of + // their per-row checks above: #2807's signature was that both of them emptied + // together, so a regression that reopened the gap for only one shape has to + // show up as a diff here rather than as a single quiet row failure. + it('both inference-typed receivers carry the whole chain, not just the first link', () => { + const inferred = ['inferred-field', 'ctor-assigned-inferred'] as const; + const observed = Object.fromEntries( + inferred.map((name) => { + const shape = RECEIVER_SHAPES.find((s) => s.name === name); + if (shape === undefined) throw new Error(`fixture drift: no row named ${name}`); + return [name, [...idsFor(shape.marker)].sort()]; + }), + ); + expect(observed).toEqual({ + 'inferred-field': [INNER_COMPUTE, OUTER_INNER].sort(), + 'ctor-assigned-inferred': [INNER_COMPUTE, OUTER_INNER].sort(), + }); }); }); diff --git a/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts b/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts new file mode 100644 index 000000000..9ad4a6b62 --- /dev/null +++ b/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts @@ -0,0 +1,2170 @@ +/** + * Cross-language matrix for #2807: can a class field whose type is INFERRED — + * from its initializer, or from a constructor call assigned to it — act as a + * call receiver? + * + * ── HOW TO READ A ROW ───────────────────────────────────────────────────────── + * + * Every row in every language runs the SAME statement shape, + * `.inner().compute(x)`, and only the receiver FORM varies. The + * question this file answers is not "did both links resolve" — it is **does an + * inference-typed field behave like that language's own control row**. + * + * That distinction is the whole design. Several languages lose the SECOND link + * (`Inner.compute`) even for a plain local, because they have no return-type + * annotation to carry the chain — JavaScript has none at all, and the Python, + * Dart and PHP fixtures here declare none. That is a separate + * return-type-inference gap and NOT what #2807 was about. Comparing an inferred + * field against the language's control row isolates the field-typing question + * from it; comparing against "both links present" would have falsely accused + * four languages and falsely cleared none. + * + * ── MEASURED STATE ──────────────────────────────────────────────────────────── + * + * language control inferred field (init) assigned field (this/self) + * ---------- ------------- ---------------------- -------------------------- + * TypeScript both links both links (#2807) both links (#2807) + * JavaScript Outer.inner Outer.inner (#2807) Outer.inner (already ok) + * Python Outer.inner n/a — no field decls Outer.inner (#2807) + * Ruby both links n/a — no field decls both links (#2807) + * Kotlin both links both links (was ok) n/a — needs a type + * PHP Outer.inner n/a — see below Outer.inner (was ok) + * Dart Outer.inner Outer.inner (#2807) Outer.inner (#2807) + * Swift both links both links (#2807) optional field (#2807) + * + * Shapes marked n/a do not exist in that language: Python and Ruby have no + * field DECLARATIONS at all (a field is created by assignment, so only the + * assigned column is meaningful), PHP property initializers accept only + * constant expressions so `private $p = new Outer();` is not writable, and + * Kotlin/Swift cannot declare a stored property with neither a type nor an + * initializer, so their "assigned" shape is always annotated and already + * resolves through the annotation. + * + * Kotlin and PHP were already correct before #2807 and are pinned here so a + * change to the shared fold cannot regress them unnoticed — the two languages + * that got receiver typing for free are exactly the ones nobody would think to + * re-check. + * + * ── THE OTHER HALF: A FIELD MUST NOT BE TYPED BY AN ALIEN `this` ───────────── + * + * Typing a field from `this.p = new Outer()` is only half the question; the + * other half is WHICH `this`. The first cut of the TypeScript pattern was + * context-free, so it typed a class's field from any `this.p = new …` in the + * file — inside a non-arrow callback, an object-literal method, a static + * method, or at module top level, none of which are that class's instance. The + * TypeScript section carries one guard row per shape; see the comment on them. + * They are written so a regression SWAPS a target rather than emptying the set, + * because a row that asserts an empty result passes just as well when the + * fixture stopped working. + * + * A `static` member reaches the same Class scope by a SECOND route that has no + * `this` in it at all — its own declaration. JavaScript and TypeScript keep + * static and instance members in separate namespaces, so `p = new Outer(); + * static p = new Alien();` is legal and `this.p` is `Outer`; both bindings + * landed on one scope at one strength and the `>=` tie-break gave the field to + * whichever matched last. Dart forbids that same-name pair outright, so its + * version of the defect is a static METHOD's receiver-less write + * (`libraryZ = Other()`, which in Dart's static scope names a library variable) + * displacing the constructor's binding. All three languages carry a row, and + * Dart carries the counterweight too: a static field DECLARATION is read by + * bare name from instance methods in ordinary Dart, so its binding must survive. + * + * ── HOW THE HARD CASES WERE FIXED ───────────────────────────────────────────── + * + * Dart is the one language here that writes a field with NO receiver prefix, so + * `r = Outer()` in a constructor is syntactically identical to assigning a + * local. It binds only when the class declares that field AND the enclosing + * member binds no name that shadows it — exactly when Dart itself resolves the + * bare name to the field. A `this.`-prefixed write needs neither test. The + * shadowing cases are asserted, not described: a body-local, a formal parameter, + * a closure parameter, a catch binding and a for-in variable each get a row. + * "Local declarations" alone was the first attempt and was wrong in both + * directions — a parameter write retyped the field to the wrong class AND + * displaced the constructor's correct binding, so the shadow rows below assert + * the SURVIVING correct target rather than an absence. + * + * That shadow set gated WRITES only, which left the mirror-image defect on the + * READ side: a bare-name read of a shadowing binder the resolver cannot type + * (`for (final conn in xs) { conn.inner(); }`) walked past the local and picked + * up the very class binding the write side had just minted, so a missing edge + * became a WRONG one. The fix publishes `shadows ∩ fields` on the member's + * Function scope as `Scope.ownsReceivers` (#2701) — the same language-neutral + * masking primitive TypeScript uses for a non-arrow `this`. Because the receiver + * walk consults `typeBindings` before the mask at every scope, a shadow the + * resolver CAN type still wins, which the annotated-parameter and typed-local + * rows pin. All SEVEN binder shapes the read-side defect was measured in get a + * row of their own — for-in (`final` and `var`), untyped formal parameter, plain + * local, catch binding, closure parameter and record pattern — rather than the + * two it shipped with plus an argument that the rest route through the same + * enumeration: the grammar-derived coverage test at the bottom of this file + * gates the PATTERN family only, so it never covered the other four (#2807 + * review, S8). Two costs are taken knowingly and are visible in the rows: the + * mask is body-wide, so a read of the genuine field elsewhere in a shadowing + * member loses its edge; and only names the class declares as FIELDS are masked, + * so the same defect against a library-level variable is still open. Both are + * recorded on `dartShadowedFieldsCapture` in `languages/dart/captures.ts`. + * + * Swift reached parity only once a SEPARATE defect was fixed alongside: its + * methods are emitted as `Function` nodes while the scope extractor derives + * `Method` from the declaration anchor, so every label-scoped bridge key missed + * and two same-named methods in one file collapsed onto whichever registered + * first — the second method's calls were attributed to the first. That masked + * this row entirely; Swift's `let p = Outer()` binding had been correct all + * along. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js'; +import type { PipelineResult } from './helpers.js'; +import { cleanupTempDirSync } from '../../helpers/test-db.js'; +import { getDartParser } from '../../../src/core/ingestion/languages/dart/query.js'; +import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js'; + +/** One receiver form under test, with the exact CALLS targets it emits today. */ +interface Row { + readonly name: string; + /** Exact node id of the method holding the chained statement. */ + readonly callerId: string; + /** Every distinct CALLS target id, sorted. */ + readonly targets: readonly string[]; + readonly status: 'resolves' | 'known-gap'; +} + +interface LanguageCase { + readonly language: string; + readonly file: string; + readonly source: string; + readonly rows: readonly Row[]; +} + +// ── TypeScript ─────────────────────────────────────────────────────────────── +const TS_FILE = 'src/app.ts'; +const TS_SOURCE = `export class Inner { compute(v: number): number { return v * 2; } } +export class Outer { inner(): Inner { return new Inner(); } } +export class ControlLocal { run(x: number): number { const o = new Outer(); return o.inner().compute(x); } } +export class ControlTypedField { private p: Outer = new Outer(); run(x: number): number { return this.p.inner().compute(x); } } +export class InferredField { private p = new Outer(); run(x: number): number { return this.p.inner().compute(x); } } +export class AssignedField { private q; constructor() { this.q = new Outer(); } run(x: number): number { return this.q.inner().compute(x); } } +export class Alien { inner(): Inner { return new Inner(); } } +export class CallbackThis { + private p = new Outer(); + attach(el: any): void { el.addEventListener('click', function () { this.p = new Alien(); }); } + run(x: number): number { return this.p.inner().compute(x); } +} +export class ObjectLiteralThis { + private p = new Outer(); + build(): unknown { return { m() { this.p = new Alien(); } }; } + run(x: number): number { return this.p.inner().compute(x); } +} +export class StaticThis { + private p = new Outer(); + static make(): void { this.p = new Alien(); } + run(x: number): number { return this.p.inner().compute(x); } +} +export class StaticFieldSameName { + private p = new Outer(); + static p = new Alien(); + run(x: number): number { return this.p.inner().compute(x); } +} +export class StaticAnnotatedFieldSameName { + private r: Outer = new Outer(); + static r: Alien = new Alien(); + run(x: number): number { return this.r.inner().compute(x); } +} +export class StaticReadTwin { + private p = new Outer(); + static p = new Alien(); + run(x: number): number { return this.p.inner().compute(x); } +} +export class StaticReadOnly { + static q = new Alien(); +} +export class StaticReader { + readTwin(x: number): number { return StaticReadTwin.p.inner().compute(x); } + readOnly(x: number): number { return StaticReadOnly.q.inner().compute(x); } +} +export const moduleLocal = new Outer(); +export function runModuleLocal(x: number): number { return moduleLocal.inner().compute(x); } +this.moduleLocal = new Alien(); +`; + +// ── JavaScript ─────────────────────────────────────────────────────────────── +const JS_FILE = 'src/app.js'; +const JS_SOURCE = `export class Inner { compute(v) { return v * 2; } } +export class Outer { inner() { return new Inner(); } } +export class ControlLocal { run(x) { const o = new Outer(); return o.inner().compute(x); } } +export class InferredField { p = new Outer(); run(x) { return this.p.inner().compute(x); } } +export class AssignedField { constructor() { this.q = new Outer(); } run(x) { return this.q.inner().compute(x); } } +export class Alien { inner() { return new Inner(); } } +export class ObjectLiteralCtorThis { + p = new Outer(); + build() { return { constructor() { this.p = new Alien(); } }; } + run(x) { return this.p.inner().compute(x); } +} +export class StaticFieldSameName { + p = new Outer(); + static p = new Alien(); + run(x) { return this.p.inner().compute(x); } +} +export class StaticCtorThis { + p = new Outer(); + static constructor() { this.p = new Alien(); } + run(x) { return this.p.inner().compute(x); } +} +`; + +// ── Python ─────────────────────────────────────────────────────────────────── +const PY_FILE = 'src/app.py'; +const PY_SOURCE = `class Inner: + def compute(self, v): + return v * 2 + + +class Outer: + def inner(self): + return Inner() + + +class ControlLocal: + def run(self, x): + o = Outer() + return o.inner().compute(x) + + +class AnnotatedField: + def __init__(self): + self.p: Outer = Outer() + + def run(self, x): + return self.p.inner().compute(x) + + +class AssignedField: + def __init__(self): + self.q = Outer() + + def run(self, x): + return self.q.inner().compute(x) + + +class ReassignedField: + def __init__(self): + self.r = Outer() + self.r = self.rebuild() + + def rebuild(self): + return Outer() + + def run(self, x): + return self.r.inner().compute(x) +`; + +// ── Ruby ───────────────────────────────────────────────────────────────────── +const RB_FILE = 'src/app.rb'; +const RB_SOURCE = `class Inner + def compute(v) + v * 2 + end +end + +class Outer + def inner + Inner.new + end +end + +class ControlLocal + def run(x) + o = Outer.new + o.inner.compute(x) + end +end + +class AssignedField + def initialize + @q = Outer.new + end + + def run(x) + @q.inner.compute(x) + end +end + +class SingletonMethodSelfField + def self.build + @pool = Outer.new + end + + def initialize + @q = Outer.new + end + + def run_self_ivar(x) + @pool.inner.compute(x) + end + + def run(x) + @q.inner.compute(x) + end +end + +class SingletonClassSelfField + class << self + def build + @cache = Outer.new + end + end + + def initialize + @q = Outer.new + end + + def run_self_ivar(x) + @cache.inner.compute(x) + end + + def run(x) + @q.inner.compute(x) + end +end + +class ClassBodySelfField + @shared = Outer.new + + def initialize + @q = Outer.new + end + + def run_self_ivar(x) + @shared.inner.compute(x) + end + + def run(x) + @q.inner.compute(x) + end +end + +class Alien + def inner + Inner.new + end +end + +class ClassEvalBlockField + Alien.class_eval do + def warm + @shared = Alien.new + end + end + + def initialize + @q = Outer.new + end + + def run_self_ivar(x) + @shared.inner.compute(x) + end + + def run(x) + @q.inner.compute(x) + end +end + +class ClassNewBlockField + Anon = Class.new do + def warm + @shared = Alien.new + end + end + + def initialize + @q = Outer.new + end + + def run_self_ivar(x) + @shared.inner.compute(x) + end + + def run(x) + @q.inner.compute(x) + end +end + +class StructNewBlockField + Pair = Struct.new(:x) do + def warm + @shared = Alien.new + end + end + + def initialize + @q = Outer.new + end + + def run_self_ivar(x) + @shared.inner.compute(x) + end + + def run(x) + @q.inner.compute(x) + end +end + +class InstanceEvalBlockField + def seed(other) + other.instance_eval { @shared = Alien.new } + end + + def initialize + @q = Outer.new + end + + def run_self_ivar(x) + @shared.inner.compute(x) + end + + def run(x) + @q.inner.compute(x) + end +end + +class InstanceExecBlockField + def seed(other) + other.instance_exec { @shared = Alien.new } + end + + def initialize + @q = Outer.new + end + + def run_self_ivar(x) + @shared.inner.compute(x) + end + + def run(x) + @q.inner.compute(x) + end +end + +class DefineMethodBlockField + define_method(:warm) { @shared = Alien.new } + + def initialize + @q = Outer.new + end + + def run_self_ivar(x) + @shared.inner.compute(x) + end + + def run(x) + @q.inner.compute(x) + end +end + +class PlainBlockField + def seed + [1].each { @shared = Alien.new } + end + + def initialize + @q = Outer.new + end + + def run_self_ivar(x) + @shared.inner.compute(x) + end + + def run(x) + @q.inner.compute(x) + end +end +`; + +// ── Kotlin ─────────────────────────────────────────────────────────────────── +const KT_FILE = 'src/app.kt'; +const KT_SOURCE = `class Inner { + fun compute(v: Int): Int { return v * 2 } +} + +class Outer { + fun inner(): Inner { return Inner() } +} + +class ControlLocal { + fun run(x: Int): Int { + val o = Outer() + return o.inner().compute(x) + } +} + +class InferredField { + val p = Outer() + fun run(x: Int): Int { + return this.p.inner().compute(x) + } +} +`; + +// ── PHP ────────────────────────────────────────────────────────────────────── +const PHP_FILE = 'src/app.php'; +const PHP_SOURCE = `inner()->compute($x); } +} +class ControlTypedField { + private Outer $p; + public function __construct() { $this->p = new Outer(); } + public function run($x) { return $this->p->inner()->compute($x); } +} +class AssignedField { + private $q; + public function __construct() { $this->q = new Outer(); } + public function run($x) { return $this->q->inner()->compute($x); } +} +`; + +// ── Dart ───────────────────────────────────────────────────────────────────── +const DART_FILE = 'src/app.dart'; +const DART_SOURCE = `Other libraryZ = Other(); + +class Inner { + int compute(int v) { + return v * 2; + } +} + +class Outer { + Inner inner() { + return Inner(); + } +} + +class ControlLocal { + int run(int x) { + var o = Outer(); + return o.inner().compute(x); + } +} + +class ControlTypedField { + Outer p = Outer(); + int run(int x) { + return p.inner().compute(x); + } +} + +class InferredField { + var q = Outer(); + int run(int x) { + return q.inner().compute(x); + } +} + +class AssignedField { + var r; + AssignedField() { + r = Outer(); + } + int run(int x) { + return r.inner().compute(x); + } +} + +class ShadowedAssignedField { + var s; + ShadowedAssignedField() { + var s; + s = Outer(); + } + int run(int x) { + return s.inner().compute(x); + } +} + +class Other { + Other inner() { + return Other(); + } +} + +class ParamShadowedField { + var t; + ParamShadowedField() { + t = Outer(); + } + void reset(Other t) { + t = Other(); + } + int run(int x) { + return t.inner().compute(x); + } +} + +class ClosureShadowedField { + var u; + ClosureShadowedField() { + u = Outer(); + } + void reset(xs) { + xs.forEach((u) { + u = Other(); + }); + } + int run(int x) { + return u.inner().compute(x); + } +} + +class CatchShadowedField { + var w; + CatchShadowedField() { + w = Outer(); + } + void reset() { + try {} on Err catch (w) { + w = Other(); + } + } + int run(int x) { + return w.inner().compute(x); + } +} + +class LoopShadowedField { + var y; + LoopShadowedField() { + y = Outer(); + } + void reset(xs) { + for (var y in xs) { + y = Other(); + } + } + int run(int x) { + return y.inner().compute(x); + } +} + +class MultiDeclaratorField { + var m = Other(), n = Outer(); + int run(int x) { + return n.inner().compute(x); + } +} + +class RecordPatternShadowedField { + var pa; + RecordPatternShadowedField() { + pa = Outer(); + } + void reset(xs) { + var (pa, _) = xs; + pa = Other(); + } + int run(int x) { + return pa.inner().compute(x); + } +} + +class ListPatternShadowedField { + var pb; + ListPatternShadowedField() { + pb = Outer(); + } + void reset(xs) { + var [pb, _] = xs; + pb = Other(); + } + int run(int x) { + return pb.inner().compute(x); + } +} + +class RestPatternShadowedField { + var pc; + RestPatternShadowedField() { + pc = Outer(); + } + void reset(xs) { + var [_, ...pc] = xs; + pc = Other(); + } + int run(int x) { + return pc.inner().compute(x); + } +} + +class MapPatternShadowedField { + var pd; + MapPatternShadowedField() { + pd = Outer(); + } + void reset(xs) { + var {'k': pd} = xs; + pd = Other(); + } + int run(int x) { + return pd.inner().compute(x); + } +} + +class ObjectPatternShadowedField { + var pe; + ObjectPatternShadowedField() { + pe = Outer(); + } + void reset(o) { + var Outer(inner: pe) = o; + pe = Other(); + } + int run(int x) { + return pe.inner().compute(x); + } +} + +class ObjectShorthandPatternShadowedField { + var pf; + ObjectShorthandPatternShadowedField() { + pf = Outer(); + } + void reset(o) { + var Outer(:pf) = o; + pf = Other(); + } + int run(int x) { + return pf.inner().compute(x); + } +} + +class IfCasePatternShadowedField { + var pg; + IfCasePatternShadowedField() { + pg = Outer(); + } + void reset(o) { + if (o case Other pg) { + pg = Other(); + } + } + int run(int x) { + return pg.inner().compute(x); + } +} + +class CastPatternShadowedField { + var ph; + CastPatternShadowedField() { + ph = Outer(); + } + void reset(o) { + if (o case var ph as Other) { + ph = Other(); + } + } + int run(int x) { + return ph.inner().compute(x); + } +} + +class NullCheckPatternShadowedField { + var pj; + NullCheckPatternShadowedField() { + pj = Outer(); + } + void reset(o) { + if (o case var pj?) { + pj = Other(); + } + } + int run(int x) { + return pj.inner().compute(x); + } +} + +class NullAssertPatternShadowedField { + var pk; + NullAssertPatternShadowedField() { + pk = Outer(); + } + void reset(o) { + if (o case var pk!) { + pk = Other(); + } + } + int run(int x) { + return pk.inner().compute(x); + } +} + +class OrPatternShadowedField { + var pl; + OrPatternShadowedField() { + pl = Outer(); + } + void reset(o) { + if (o case Other pl || Other pl) { + pl = Other(); + } + } + int run(int x) { + return pl.inner().compute(x); + } +} + +class SwitchCasePatternShadowedField { + var pm; + SwitchCasePatternShadowedField() { + pm = Outer(); + } + void reset(o) { + switch (o) { + case Other pm: + pm = Other(); + } + } + int run(int x) { + return pm.inner().compute(x); + } +} + +class SwitchExpressionPatternShadowedField { + var pn; + SwitchExpressionPatternShadowedField() { + pn = Outer(); + } + void reset(o) { + var v = switch (o) { Other pn => pn = Other(), _ => o }; + } + int run(int x) { + return pn.inner().compute(x); + } +} + +class ForInPatternShadowedField { + var pp; + ForInPatternShadowedField() { + pp = Outer(); + } + void reset(xs) { + for (var (pp, _) in xs) { + pp = Other(); + } + } + int run(int x) { + return pp.inner().compute(x); + } +} + +class PatternAssignmentShadowedField { + var pq; + PatternAssignmentShadowedField() { + pq = Outer(); + } + void reset(xs) { + (pq, _) = xs; + pq = Other(); + } + int run(int x) { + return pq.inner().compute(x); + } +} + +class CollectionIfElementPatternShadowedField { + var pr; + CollectionIfElementPatternShadowedField() { + pr = Outer(); + } + void reset(o) { + var l = [if (o case Other pr) pr = Other()]; + } + int run(int x) { + return pr.inner().compute(x); + } +} + +class CollectionForElementPatternShadowedField { + var ps; + CollectionForElementPatternShadowedField() { + ps = Outer(); + } + void reset(xs) { + var l = [for (var (ps, _) in xs) ps = Other()]; + } + int run(int x) { + return ps.inner().compute(x); + } +} + +class StaticMethodBareWrite { + var libraryZ; + StaticMethodBareWrite() { + libraryZ = Outer(); + } + static void make() { + libraryZ = Other(); + } + int run(int x) { + return libraryZ.inner().compute(x); + } +} + +class StaticFieldDecl { + static var sd = Other(); + int run(int x) { + return sd.inner().compute(x); + } +} + +class Alien { + Inner inner() { + return Inner(); + } +} + +class LoopVarReadShadowedField { + var za; + LoopVarReadShadowedField() { + za = Outer(); + } + int probe(xs) { + var wit = Alien(); + for (final za in xs) { + za.inner(); + } + return wit.inner().compute(1); + } + int use(int x) { + return za.inner().compute(x); + } + int annotated(Other za) { + return za.inner().compute(1); + } +} + +class PatternReadShadowedField { + var zb; + PatternReadShadowedField() { + zb = Outer(); + } + int probe(xs) { + var wit = Alien(); + var (zb, _) = xs; + zb.inner(); + return wit.inner().compute(1); + } +} + +class TypedLocalReadShadowedField { + var zc; + TypedLocalReadShadowedField() { + zc = Outer(); + } + int probe(int x) { + var zc = Other(); + return zc.inner().compute(x); + } +} + +class ParamReadShadowedField { + var zd; + ParamReadShadowedField() { + zd = Outer(); + } + int probe(zd) { + var wit = Alien(); + zd.inner(); + return wit.inner().compute(1); + } +} + +class LocalReadShadowedField { + var ze; + LocalReadShadowedField() { + ze = Outer(); + } + int probe(int x) { + var wit = Alien(); + var ze; + ze.inner(); + return wit.inner().compute(x); + } +} + +class CatchReadShadowedField { + var zf; + CatchReadShadowedField() { + zf = Outer(); + } + int probe() { + var wit = Alien(); + try {} catch (zf) { + zf.inner(); + } + return wit.inner().compute(1); + } +} + +class ClosureParamReadShadowedField { + var zg; + ClosureParamReadShadowedField() { + zg = Outer(); + } + int probe(xs) { + var wit = Alien(); + xs.forEach((zg) { + zg.inner(); + }); + return wit.inner().compute(1); + } +} + +class LoopVarVarReadShadowedField { + var zh; + LoopVarVarReadShadowedField() { + zh = Outer(); + } + int probe(xs) { + var wit = Alien(); + for (var zh in xs) { + zh.inner(); + } + return wit.inner().compute(1); + } +} +`; + +// ── Swift ──────────────────────────────────────────────────────────────────── +const SWIFT_FILE = 'src/app.swift'; +const SWIFT_SOURCE = `class Inner { + func compute(_ v: Int) -> Int { return v * 2 } +} + +class Outer { + func inner() -> Inner { return Inner() } +} + +class ControlLocal { + func run(_ x: Int) -> Int { + let o = Outer() + return o.inner().compute(x) + } +} + +class InferredField { + let p = Outer() + func run(_ x: Int) -> Int { + return self.p.inner().compute(x) + } +} + +class OptionalAssignedField { + var q: Outer? + init() { + self.q = Outer() + } + func run(_ x: Int) -> Int { + return self.q!.inner().compute(x) + } +} +`; + +const CASES: readonly LanguageCase[] = [ + { + language: 'typescript', + file: TS_FILE, + source: TS_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${TS_FILE}:ControlLocal.run#1`, + targets: [ + `Class:${TS_FILE}:Outer`, + `Method:${TS_FILE}:Inner.compute#1`, + `Method:${TS_FILE}:Outer.inner#0`, + ], + status: 'resolves', + }, + { + name: 'control-typed-field', + callerId: `Method:${TS_FILE}:ControlTypedField.run#1`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'inferred-field', + callerId: `Method:${TS_FILE}:InferredField.run#1`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'assigned-field', + callerId: `Method:${TS_FILE}:AssignedField.run#1`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // ── The wrong-`this` guard rows ──────────────────────────────────────── + // + // Each of the next four writes `this.p = new Alien()` from a context + // where `this` is NOT an instance of the class the binding would land on, + // and each one is a shape the context-free version of the `this. = + // new …` pattern accepted. The receiver they must NOT retype is + // `private p = new Outer()`, already typed by its initializer, so the + // pattern's `>=` tie-break (later match wins at equal source strength) + // OVERWROTE the field's real type. + // + // `Alien` deliberately declares `inner()` too. That is what keeps these + // rows honest: a wrong binding does not empty the target set, it swaps + // `Outer.inner#0` for `Alien.inner#0`. Asserting the exact set therefore + // fails on the defect instead of passing vacuously the way an + // expected-empty row would — and the last row is a module local, not a + // field, so it also pins that the marker cannot escape a class at all. + { + name: 'callback-this-does-not-retype-the-field', + callerId: `Method:${TS_FILE}:CallbackThis.run#1`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'object-literal-this-does-not-retype-the-field', + callerId: `Method:${TS_FILE}:ObjectLiteralThis.run#1`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'static-this-does-not-retype-the-instance-field', + callerId: `Method:${TS_FILE}:StaticThis.run#1`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'module-level-this-does-not-retype-a-module-local', + callerId: `Function:${TS_FILE}:runModuleLocal`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // ── …and a static FIELD is not the instance field of that name ──────── + // + // The four rows above all guard the ASSIGNMENT form (`this.x = new …`). + // A static field DECLARATION reaches the same Class scope without any + // `this` at all, and JS/TS keep static and instance members in separate + // namespaces, so `p = new Outer(); static p = new Alien();` is legal and + // `this.p` is `Outer`. Both bindings carried the same source strength, so + // the `>=` tie-break handed the field to whichever pattern matched LAST — + // the static one — and `this.p.inner()` resolved to `Alien.inner`. + // + // Written the same swap-not-empty way as the guard rows: `Alien` declares + // `inner()`, so the defect produces a DIFFERENT non-empty target set and + // this positive assertion cannot pass vacuously. + // + // The annotated twin is a separate row because it is a separate pattern + // (`@type-binding.annotation`, not `@type-binding.constructor`) and it + // collides at the `annotation` strength rather than `constructor-inferred` + // — a fix that only guarded the initializer form would leave it red. + { + name: 'static-field-does-not-retype-the-instance-field', + callerId: `Method:${TS_FILE}:StaticFieldSameName.run#1`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'static-annotated-field-does-not-retype-the-instance-field', + callerId: `Method:${TS_FILE}:StaticAnnotatedFieldSameName.run#1`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // ── WHAT DROPPING THE STATIC BINDING COSTS, MEASURED ────────────────── + // + // The three rows above are bought by DROPPING a `static` field's type + // binding outright (`isStaticClassFieldBinding` in + // `languages/typescript/captures.ts`), because `Scope.typeBindings` is one + // map per Class scope with no static/instance split, so the static twin + // cannot be recorded without colliding with the instance one. The two rows + // here are the other side of that trade. They exist because the cost was + // described in a comment and pinned by nothing — a cost no row measures is + // a cost nobody notices changing (#2807 review, S7). + // + // Both were measured by disabling the drop and rebuilding. What the trade + // actually bought and sold: + // + // shape with the drop without it + // ------------------------- ----------------- ------------------ + // `this.p` (instance twin) Outer ✓ correct Alien ✗ wrong + // `Host.p` (static twin) Outer ✗ WRONG Alien ✓ correct + // `Host.q` (static, no twin) — none, missed Alien ✓ correct + // + // So the trade is NOT the "missed edge beats a wrong one" the comment on + // `isStaticClassFieldBinding` claims, and this row is why that comment now + // says otherwise. For a class with BOTH twins the wrong edge did not go + // away, it MOVED — a static read now picks up the INSTANCE twin's type. + // The trade is still right, because `this.p` is overwhelmingly the more + // common access and a rarely-written `Host.p` is the cheaper place to be + // wrong; but it is a wrong edge, and it is recorded as one rather than + // described as a missing one. + // + // Asserted as a POSITIVE target: this row goes red when the static read + // starts resolving `Alien` — which is what closing S7 properly looks like. + // It is a pin on today's measured behaviour, NOT an endorsement of it. + { + name: 'static-read-of-a-same-name-twin-picks-up-the-instance-type', + callerId: `Method:${TS_FILE}:StaticReader.readTwin#1`, + targets: [`Method:${TS_FILE}:Inner.compute#1`, `Method:${TS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // The static-only shape — no instance twin, so nothing at all is left in + // the map and the chain simply loses its type. This is the genuine MISSED + // edge, and the common static shape; the row above is the rarer one. A + // `known-gap` row rather than a positive assertion because there is no + // surviving target to name — the whole point is that the edge is gone. It + // is protected from vacuity by the `every row has a live caller node` + // guard, the same way this file's other known-gap rows are. + { + name: 'static-read-without-a-twin-loses-its-type', + callerId: `Method:${TS_FILE}:StaticReader.readOnly#1`, + targets: [], + status: 'known-gap', + }, + ], + }, + { + language: 'javascript', + file: JS_FILE, + source: JS_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${JS_FILE}:ControlLocal.run#1`, + targets: [`Class:${JS_FILE}:Outer`, `Method:${JS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'inferred-field', + callerId: `Method:${JS_FILE}:InferredField.run#1`, + targets: [`Method:${JS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'assigned-field', + callerId: `Method:${JS_FILE}:AssignedField.run#1`, + targets: [`Method:${JS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // JavaScript's half of the wrong-`this` guard above. Its binding comes + // from `synthesizeConstructorFieldBindings`, which was already bounded to + // a `constructor` body's direct statements — but matched a + // `method_definition` anywhere, and an object literal's members are + // `method_definition` too. So a literal named-`constructor` method typed + // the enclosing class's field, the one shape where `.js` still read this + // source differently from `.ts` once TypeScript's pattern was nested. + // Same swap-not-empty construction: `Alien` declares `inner()`. + { + name: 'object-literal-constructor-this-does-not-retype-the-field', + callerId: `Method:${JS_FILE}:ObjectLiteralCtorThis.run#1`, + targets: [`Method:${JS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // JavaScript's half of the static-FIELD guard. Class fields are the one + // place `.js` and `.ts` write the same declaration under different grammar + // node names (`field_definition` vs `public_field_definition`), so the + // predicate that reads `static` off them is shared rather than duplicated + // — this row is what proves the JavaScript spelling is actually covered. + { + name: 'static-field-does-not-retype-the-instance-field', + callerId: `Method:${JS_FILE}:StaticFieldSameName.run#1`, + targets: [`Method:${JS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // `static constructor() {}` is legal JavaScript — the reserved-name rule + // applies to instance methods only — and its `this` is the CLASS. The + // JavaScript constructor-field walk matched on the NAME alone, so this + // shape typed the instance field exactly the way TypeScript's static + // method did before `isStaticMethodThis`. Same swap-not-empty shape. + { + name: 'static-constructor-this-does-not-retype-the-field', + callerId: `Method:${JS_FILE}:StaticCtorThis.run#1`, + targets: [`Method:${JS_FILE}:Outer.inner#0`], + status: 'resolves', + }, + ], + }, + { + language: 'python', + file: PY_FILE, + source: PY_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${PY_FILE}:ControlLocal.run#1`, + targets: [`Method:${PY_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'control-annotated-field', + callerId: `Method:${PY_FILE}:AnnotatedField.run#1`, + targets: [`Method:${PY_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'assigned-field', + callerId: `Method:${PY_FILE}:AssignedField.run#1`, + targets: [`Method:${PY_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // A method call is not a construction. `self.r = Outer()` followed by + // `self.r = self.rebuild()` must keep the FIRST binding: both would sit + // in the weakest tier, so accepting `self.rebuild()` as a constructor let + // the later one displace the real type and the field went untyped again — + // measured as zero CALLS edges before `constructorCallTypeName` learned to + // reject a callee rooted at the receiver. This row fails without that + // rejection, which is the only reason it exists. + { + name: 'reassigned-from-method-call', + callerId: `Method:${PY_FILE}:ReassignedField.run#1`, + targets: [`Method:${PY_FILE}:Outer.inner#0`], + status: 'resolves', + }, + ], + }, + { + language: 'ruby', + file: RB_FILE, + source: RB_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${RB_FILE}:ControlLocal.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'assigned-field', + callerId: `Method:${RB_FILE}:AssignedField.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // ── An `@ivar` is only an INSTANCE field when `self` is an instance ──── + // + // The three `*-self-ivar` rows below all write `@x = Outer.new` where + // Ruby's `self` is the CLASS object, not an instance: inside + // `def self.build`, inside a `class << self` body, and directly in the + // class body. None of those ivars exists on an instance, so an instance + // method reading them reads `nil` and must resolve NOTHING. Hoisting the + // binding to the Class scope regardless of whose `self` owns it fabricated + // an `Outer.inner` edge from a receiver that is never assigned. + // + // Each is PAIRED with an `*-instance-ivar` row on the SAME class that + // writes `@q` from `initialize` and must still resolve both links. The + // pairing is what keeps the empty rows honest: an empty expectation passes + // vacuously if the fixture never reached the ivar-field machinery at all, + // so the partner row asserts a NON-empty result through the very same + // class. Break the hoist entirely and the partner goes red; keep the + // unconditional hoist and the empty row goes red. + { + name: 'singleton-method-self-ivar', + callerId: `Method:${RB_FILE}:SingletonMethodSelfField.run_self_ivar#1`, + targets: [], + status: 'known-gap', + }, + { + name: 'singleton-method-instance-ivar', + callerId: `Method:${RB_FILE}:SingletonMethodSelfField.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'singleton-class-self-ivar', + callerId: `Method:${RB_FILE}:SingletonClassSelfField.run_self_ivar#1`, + targets: [], + status: 'known-gap', + }, + { + name: 'singleton-class-instance-ivar', + callerId: `Method:${RB_FILE}:SingletonClassSelfField.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'class-body-self-ivar', + callerId: `Method:${RB_FILE}:ClassBodySelfField.run_self_ivar#1`, + targets: [], + status: 'known-gap', + }, + { + name: 'class-body-instance-ivar', + callerId: `Method:${RB_FILE}:ClassBodySelfField.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // ── A BLOCK's `self` is chosen by its RECEIVER, not by where it is written ─ + // + // The three shapes above are the ones a `self`-keyword search finds. They + // are not the whole family: a `def` written inside a BLOCK attaches to + // whatever that block's receiver made the default definee, so + // `Other.class_eval do def warm; @shared = Alien.new; end end` writes a + // field of `Other` — while the walk that only knew about singletons fell + // straight through the block, reached the enclosing LEXICAL class, and + // published `Alien` as that class's field type. Measured, before the fix: + // every `run_self_ivar` below emitted `Alien.inner#0` + `Inner.compute#1` + // from a receiver that is `nil` on every instance of its own class. + // + // The rows use `Alien` rather than `Outer` on purpose. A wrong bind then + // shows up as the ALIEN type's edge, which is unmistakably an ownership + // failure; had they reused `Outer` a regression would emit the same + // targets the correct rows expect and read as an ordinary miss. + // + // The fix keys on the block NODES (`do … end`, `{ … }`), never on the + // call that owns them, so these row names are representative rather than + // exhaustive: `module_eval`, `class_exec`, `Module.new`, `Data.define` + // and `refine` all parse to one of the same two block nodes and are + // covered by the identical path. An enumeration of rebinding call NAMES + // could not be complete anyway — `def helper(&b) = Foo.class_eval(&b)` + // rebinds a block it merely receives, and nothing at the block's own + // syntax reveals that. + { + name: 'class-eval-block-self-ivar', + callerId: `Method:${RB_FILE}:ClassEvalBlockField.run_self_ivar#1`, + targets: [], + status: 'known-gap', + }, + { + name: 'class-eval-block-instance-ivar', + callerId: `Method:${RB_FILE}:ClassEvalBlockField.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'class-new-block-self-ivar', + callerId: `Method:${RB_FILE}:ClassNewBlockField.run_self_ivar#1`, + targets: [], + status: 'known-gap', + }, + { + name: 'class-new-block-instance-ivar', + callerId: `Method:${RB_FILE}:ClassNewBlockField.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'struct-new-block-self-ivar', + callerId: `Method:${RB_FILE}:StructNewBlockField.run_self_ivar#1`, + targets: [], + status: 'known-gap', + }, + { + name: 'struct-new-block-instance-ivar', + callerId: `Method:${RB_FILE}:StructNewBlockField.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // `instance_eval` / `instance_exec` differ from the three above in shape, + // not just in name: the write is DIRECT in a brace block inside an + // ordinary instance method, with no `def` between it and the block. The + // old walk reached `method(seed)`, set its flag, and bound the field — + // the same wrong answer by a different route, so it needs its own row. + { + name: 'instance-eval-block-self-ivar', + callerId: `Method:${RB_FILE}:InstanceEvalBlockField.run_self_ivar#1`, + targets: [], + status: 'known-gap', + }, + { + name: 'instance-eval-block-instance-ivar', + callerId: `Method:${RB_FILE}:InstanceEvalBlockField.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'instance-exec-block-self-ivar', + callerId: `Method:${RB_FILE}:InstanceExecBlockField.run_self_ivar#1`, + targets: [], + status: 'known-gap', + }, + { + name: 'instance-exec-block-instance-ivar', + callerId: `Method:${RB_FILE}:InstanceExecBlockField.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // `define_method` is the one member of the family the ORIGINAL walk + // already answered correctly, and only by accident: its block holds the + // write directly in a class body, so the walk hit `class` with the + // `method` flag still false. Pinned so the block rule cannot be removed + // in favour of "it already worked" — under a name-based deny-list this + // row would be the only survivor, and it is the least informative one. + { + name: 'define-method-block-self-ivar', + callerId: `Method:${RB_FILE}:DefineMethodBlockField.run_self_ivar#1`, + targets: [], + status: 'known-gap', + }, + { + name: 'define-method-block-instance-ivar', + callerId: `Method:${RB_FILE}:DefineMethodBlockField.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // A DELIBERATE over-discard, asserted so the cost is visible rather than + // discovered. `[1].each { @shared = Alien.new }` inside an instance method + // really does write that instance's field — Ruby's `each` does not rebind + // `self` — and this row records that the fix drops it anyway. It has to: + // the block's syntax is identical to the `instance_eval` block two rows + // up, and only the receiver's IMPLEMENTATION distinguishes them. Per the + // safety doctrine (`scope-resolution/passes/compound-receiver.ts`) a + // missed edge is the acceptable cost of never inventing a wrong one. If a + // later change makes ownership provable, this row is the one to flip. + { + name: 'plain-block-self-ivar', + callerId: `Method:${RB_FILE}:PlainBlockField.run_self_ivar#1`, + targets: [], + status: 'known-gap', + }, + { + name: 'plain-block-instance-ivar', + callerId: `Method:${RB_FILE}:PlainBlockField.run#1`, + targets: [`Method:${RB_FILE}:Inner.compute#1`, `Method:${RB_FILE}:Outer.inner#0`], + status: 'resolves', + }, + ], + }, + { + language: 'kotlin', + file: KT_FILE, + source: KT_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${KT_FILE}:ControlLocal.run#1`, + targets: [`Method:${KT_FILE}:Inner.compute#1`, `Method:${KT_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'inferred-field', + callerId: `Method:${KT_FILE}:InferredField.run#1`, + targets: [`Method:${KT_FILE}:Inner.compute#1`, `Method:${KT_FILE}:Outer.inner#0`], + status: 'resolves', + }, + ], + }, + { + language: 'php', + file: PHP_FILE, + source: PHP_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${PHP_FILE}:ControlLocal.run#1`, + targets: [`Class:${PHP_FILE}:Outer`, `Method:${PHP_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'control-typed-field', + callerId: `Method:${PHP_FILE}:ControlTypedField.run#1`, + targets: [`Method:${PHP_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'assigned-field', + callerId: `Method:${PHP_FILE}:AssignedField.run#1`, + targets: [`Method:${PHP_FILE}:Outer.inner#0`], + status: 'resolves', + }, + ], + }, + { + language: 'dart', + file: DART_FILE, + source: DART_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Method:${DART_FILE}:ControlLocal.run#1`, + targets: [`Class:${DART_FILE}:Outer`, `Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'control-typed-field', + callerId: `Method:${DART_FILE}:ControlTypedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'inferred-field', + callerId: `Method:${DART_FILE}:InferredField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'assigned-field', + callerId: `Method:${DART_FILE}:AssignedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // The shadowing guard, asserted rather than asserted-in-a-comment. Dart + // writes a field with no receiver prefix, so `s = Outer()` is + // syntactically identical to assigning a constructor-local. Here the + // constructor declares its OWN `var s`, so the write targets that local + // and the FIELD must stay untyped — `run` reads the field and must + // therefore resolve nothing. Without the `locals.has(...)` guard in + // `emitDartFieldAssignmentBindings` this row goes green with a WRONG edge, + // which is the failure mode the guard exists to prevent. + { + name: 'shadowed-assigned-field', + callerId: `Method:${DART_FILE}:ShadowedAssignedField.run#1`, + targets: [], + status: 'known-gap', + }, + // A local `var` is only ONE of Dart's binders. These four rows each write + // the field's name under a DIFFERENT binder — a formal parameter, a + // closure parameter, a catch binding, a for-in variable — in a method + // that is not the constructor, while the constructor has already typed + // that field `Outer` correctly. + // + // They assert a POSITIVE target on purpose. An empty-result row passes + // vacuously whenever the fixture stops reaching the guard at all, so the + // thing being pinned is that the CORRECT `Outer` binding SURVIVES the + // shadowed write. Before `collectDartBodyShadows` looked past local + // declarations, every one of these bare writes was read as a write to the + // field: it retyped the field to `Other` — measured as the WRONG edge + // `Other.inner#0`, not merely a missing one — and destroyed the + // constructor's binding at the same time. `Other` therefore declares its + // own `inner()`, so the fabricated edge is a visible wrong target rather + // than silence that an unrelated regression could also produce. + { + name: 'param-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:ParamShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'closure-param-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:ClosureShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'catch-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:CatchShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'loop-var-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:LoopShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // ONE `declaration` can hold SEVERAL declarators — `var m = Other(), n = + // Outer();` — and the field query matches it once per declarator with the + // SAME `@declaration.property` node. `dartFieldConstructorCallee` therefore + // cannot search DOWN from that node for an initializer: it would hand every + // declarator the FIRST one's, typing `n` as `Other`. It reads the + // initializer as the next named sibling of the declarator's OWN name node + // instead. + // + // Ordered so the declarator under test is the SECOND and the first has a + // DIFFERENT type: under the first-descendant search `n` took `Other` and + // this row went green on the WRONG edge `Other.inner#0` — a visible wrong + // target, not silence an unrelated regression could also produce. + { + name: 'multi-declarator-inferred-field', + callerId: `Method:${DART_FILE}:MultiDeclaratorField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // ── Dart 3 PATTERN binders ──────────────────────────────────────────── + // + // The four rows above cover the binders that existed before Dart 3. A + // pattern binds too, and every pattern form parses into node types that + // the pre-Dart-3 list did not name — so each write below was read as a + // write to the FIELD and retyped it to `Other`, exactly the way the + // parameter case did. Same construction as the rows above and for the same + // reason: `Other` declares its own `inner()`, so the pre-fix value of each + // row is the WRONG target `Other.inner#0`, not an empty set. Nothing here + // can pass vacuously — an unrelated regression that stopped the fixture + // reaching this machinery empties the set and the row still fails. + // + // One row per grammar shape, not per bug report. `_pattern_field`, + // `_map_pattern_entry`, `_list_pattern_element`, `_parenthesized_pattern`, + // `_outer_pattern` and `_guarded_pattern` are all HIDDEN rules, so the + // twelve visible pattern node types below are the entire surface, and the + // fixture is checked to produce all twelve. + // + // Declaring contexts — `pattern_variable_declaration` over each of the + // five `_outer_pattern` alternatives, plus the nested `rest_pattern`. The + // bare names in these (`pa`, `pb`, …) are `constant_pattern` nodes: Dart + // reads a bare pattern name as a binder only because the enclosing `var` + // distributes over it, and the grammar records no such distinction. + { + name: 'record-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:RecordPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'list-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:ListPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'rest-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:RestPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'map-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:MapPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // `var Outer(inner: pe) = o;` — the getter name `inner` is a NON-binder + // identifier that the hidden `_pattern_field` drops directly onto + // `object_pattern`, next to the binder. That inlining is why the container + // pattern types are handled and not only the two leaf types. + { + name: 'object-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:ObjectPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'object-shorthand-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:ObjectShorthandPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // Matching contexts — `variable_pattern` (`Other pg` / `var ph`) reached + // through if-case, the three `_unary_pattern` wrappers, an or-pattern, a + // switch statement case and a switch EXPRESSION arm. The or-pattern binds + // the same name at the same type in both branches because Dart requires + // that; the redundancy is the point, not an oversight. + { + name: 'if-case-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:IfCasePatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'cast-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:CastPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'null-check-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:NullCheckPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'null-assert-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:NullAssertPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'or-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:OrPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'switch-case-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:SwitchCasePatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'switch-expression-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:SwitchExpressionPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // `for (var (pp, _) in xs)` is the pattern arm of `_for_loop_parts`, which + // carries NO `name` field — so the `for_loop_parts` case that handles + // `for (var y in xs)` reads null here and saw no binder at all. + { + name: 'for-in-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:ForInPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // `pattern_assignment` — `(pq, _) = xs;` — is the one shape here that + // DECLARES nothing; it writes names that already exist. Shadowing it is + // deliberate over-approximation: if those names are locals some other + // binder already shadows them, and if they are fields the write is real + // but produces no binding either way, so declining costs at most an edge. + { + name: 'pattern-assignment-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:PatternAssignmentShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // Collection-literal `if_element` / `for_element` — the same patterns in + // the one context that is an ELEMENT rather than a statement, so a walk + // keyed on statement nodes would miss them. + { + name: 'collection-if-element-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:CollectionIfElementPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'collection-for-element-pattern-shadowed-assigned-field', + callerId: `Method:${DART_FILE}:CollectionForElementPatternShadowedField.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // ── A STATIC member's bare write is not an instance-field write ─────── + // + // Dart's static scope holds only the class's STATIC members, so the bare + // `libraryZ = Other()` inside `static void make()` binds the LIBRARY-level + // `libraryZ` this fixture declares at the top — never the same-named + // instance field. (The library variable is what makes the fixture legal + // Dart: without it a static method naming an instance field is a compile + // error. Dart also forbids a class from declaring a static and an instance + // member of one name, which is why the TypeScript/JavaScript same-name + // field collision has no Dart twin and this is the only shape the defect + // takes here.) + // + // Treating it as a field write did not just add an edge: it landed on the + // Class scope at the same `constructor-inferred` strength as the + // constructor's own binding and DISPLACED it, so `run` resolved + // `Other.inner#0`. Asserting the surviving `Outer` — the same construction + // the shadow rows above use, and for the same reason. + { + name: 'static-method-bare-write-does-not-retype-the-field', + callerId: `Method:${DART_FILE}:StaticMethodBareWrite.run#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // The counterweight, and the reason the guard above is scoped to member + // BODIES rather than to `static` anywhere. A static field DECLARATION is + // read by a bare name from an instance method in ordinary Dart + // (`sd.inner()` below is how you read a static), so its type binding must + // SURVIVE. Over-correcting the row above into "drop every static field + // binding" turns this row red instead — which is exactly the signal + // wanted, since Dart, unlike TypeScript, loses nothing by keeping it. + { + name: 'static-field-declaration-still-types-its-receiver', + callerId: `Method:${DART_FILE}:StaticFieldDecl.run#1`, + targets: [`Method:${DART_FILE}:Other.inner#0`], + status: 'resolves', + }, + // ── The READ side of the same shadow set ────────────────────────────── + // + // Every Dart row above tests the WRITE: does a shadowed bare-name + // assignment retype the field. The shadow set gated writes ONLY, and a + // bare-name READ of a shadowing binder the resolver cannot type walked + // straight past the local to the class binding this feature mints. In + // `probe` below, `za` is an ELEMENT of `xs`; measured pre-fix it resolved + // to `Outer.inner#0` — the constructor's type — turning "no edge" into a + // WRONG edge, the failure mode `compound-receiver.ts:519-537` forbids. + // The field binding is what made it wrong, not a pre-existing gap: delete + // the constructor and the same read emits nothing at all. + // + // `Alien` exists so these two rows keep a SURVIVING POSITIVE target. `za` + // untyped is correctly typeless, so the row's own call has nothing to + // assert; `var wit = Alien(); wit.inner()` in the same method is the + // witness, and the pre-fix value is the strictly LARGER set + // {Alien, Alien.inner, Outer.inner}. A regression that merely breaks the + // fixture empties the set and the row still fails — it cannot pass + // vacuously in either direction. + { + name: 'loop-var-read-does-not-see-the-field', + callerId: `Method:${DART_FILE}:LoopVarReadShadowedField.probe#1`, + targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + status: 'resolves', + }, + // Dart 3 patterns are the binder family this branch widened twice, so the + // read side gets one too — `var (zb, _) = xs;` binds through node types + // the pre-Dart-3 list never named. + { + name: 'pattern-read-does-not-see-the-field', + callerId: `Method:${DART_FILE}:PatternReadShadowedField.probe#1`, + targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + status: 'resolves', + }, + // ONE ROW PER BINDER SHAPE, because the two rows above pin two of the + // SEVEN shapes the defect was measured reproducing in (#2807 review, S8). + // The argument offered for the other five was that they all route through + // `collectDartBodyShadows`, whose completeness is guarded by the + // grammar-derived pattern-coverage test at the bottom of this file. That + // argument is narrower than it looks: the coverage test filters + // `nodeTypeInfo` on `type.includes('pattern')`, so it gates the PATTERN + // family and nothing else. A catch binding, a closure parameter, an + // untyped formal parameter and a plain local are invisible to it — + // narrowing `addDartBinderName`'s `catch_parameters` arm turned no row + // red. Each shape is therefore pinned by a row rather than by an argument. + // + // Same construction as the two rows above, for the same reason: `Alien` is + // a SURVIVING POSITIVE witness, so the pre-fix value is the strictly larger + // {Class:Alien, Alien.inner, Outer.inner} and a regression that merely + // breaks the fixture empties the set and still fails the row. All five were + // measured pre-fix by unwiring `scopeOwnsReceivers`: every one gained the + // wrong edge `Outer.inner#0` — the constructor's type reached through a + // binder the resolver cannot type — so none of them passes vacuously. + { + name: 'param-read-does-not-see-the-field', + callerId: `Method:${DART_FILE}:ParamReadShadowedField.probe#1`, + targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + status: 'resolves', + }, + // A local `var ze;` with NO initializer: the resolver has nothing to type + // it from, which is exactly the condition that let the read walk out to the + // class binding. The typed-local counterweight below is the other half. + { + name: 'local-read-does-not-see-the-field', + callerId: `Method:${DART_FILE}:LocalReadShadowedField.probe#1`, + targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + status: 'resolves', + }, + // Deliberately a BARE `catch (zf)` rather than the `on Err catch (w)` the + // write-side rows use. An `on` clause names a type, and a row whose binder + // could acquire one would stop measuring the mask and start measuring + // whether `Err` resolves. `probe#0` — this is the one new row whose method + // takes no parameters. + { + name: 'catch-read-does-not-see-the-field', + callerId: `Method:${DART_FILE}:CatchReadShadowedField.probe#0`, + targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + status: 'resolves', + }, + // The closure parameter is the shape that pins WHERE the mask is emitted. + // `dartShadowedFieldsCapture` returns early unless the node is a + // `function_body` whose parent is a `class_body`, so a closure's own + // `function_expression_body` never carries a mask — the read is covered + // only because the closure's binders are in the enclosing member's + // body-wide shadow set AND the walk passes out through that member's + // Function scope. Measured: the call is attributed to the enclosing + // `probe#1`, not to a separate node for the closure. + { + name: 'closure-param-read-does-not-see-the-field', + callerId: `Method:${DART_FILE}:ClosureParamReadShadowedField.probe#1`, + targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + status: 'resolves', + }, + // The second for-in form. `for (var zh in xs)` and the `final` form of + // `loop-var-read-does-not-see-the-field` are different parses — the `var` + // form puts an `inferred_type` where the `final` form puts a + // `final_builtin` — so the reported pair is two shapes, not one written + // twice, and `addDartBinderName` reaches both only via `for_loop_parts`' + // `name` field. + { + name: 'loop-var-var-read-does-not-see-the-field', + callerId: `Method:${DART_FILE}:LoopVarVarReadShadowedField.probe#1`, + targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + status: 'resolves', + }, + // THE COUNTERWEIGHT. `use` reads the same field by bare name and binds + // NOTHING, so it must still resolve to the constructor's `Outer`. It sits + // in the SAME class as the masked `probe` above deliberately: it is the + // row that distinguishes "mask the names THIS BODY rebinds" from "mask + // every field name the class declares". Measured: dropping the + // `shadows.has(name)` test in `dartShadowedFieldsCapture` — the exact + // over-widening this guards — turns 28 Dart rows red, and this row is one + // of them, while the fix as written leaves it green. + { + name: 'unshadowed-read-in-a-shadowing-class-still-resolves', + callerId: `Method:${DART_FILE}:LoopVarReadShadowedField.use#1`, + targets: [`Method:${DART_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // THE OVERREACH CONTROL, and the reason the mask is `ownsReceivers` rather + // than a deletion. `collectDartBodyShadows` includes formal parameters, so + // an ANNOTATED `Other za` is masked exactly like the loop variable — but + // `findReceiverTypeBinding` consults `typeBindings` FIRST at every scope + // and `synthesizeDartSignatureBindings` anchors parameter bindings on this + // same body node, so the annotation wins on the same scope the mask sits + // on. Asserted rather than assumed: if the mask were placed anywhere the + // parameter binding is not, this row would go from `Other` to empty. + { + name: 'annotated-param-read-keeps-its-own-type', + callerId: `Method:${DART_FILE}:LoopVarReadShadowedField.annotated#1`, + targets: [`Method:${DART_FILE}:Other.inner#0`], + status: 'resolves', + }, + // The same precedence one level in: a typed LOCAL (`var zc = Other();`) + // shadowing the field keeps `Other`, because its binding lands at or below + // the masked Function scope. Together with the row above this pins both + // halves of "a shadow the resolver CAN type still wins". + { + name: 'typed-local-read-keeps-its-own-type', + callerId: `Method:${DART_FILE}:TypedLocalReadShadowedField.probe#1`, + targets: [`Class:${DART_FILE}:Other`, `Method:${DART_FILE}:Other.inner#0`], + status: 'resolves', + }, + ], + }, + { + language: 'swift', + file: SWIFT_FILE, + source: SWIFT_SOURCE, + rows: [ + { + name: 'control-local', + callerId: `Function:${SWIFT_FILE}:ControlLocal.run#1`, + targets: [`Class:${SWIFT_FILE}:Outer`, `Function:${SWIFT_FILE}:Outer.inner#0`], + status: 'resolves', + }, + { + name: 'inferred-field', + callerId: `Function:${SWIFT_FILE}:InferredField.run#1`, + targets: [`Function:${SWIFT_FILE}:Outer.inner#0`], + status: 'resolves', + }, + // Swift cannot declare a stored property with neither a type nor an + // initializer, so its "assigned later" shape is an OPTIONAL field written + // in `init` and read through a force-unwrap. Both halves were broken: + // `var q: Outer?` put an `optional_type` between the annotation and the + // `user_type` the query matched, so the field was never typed at all; and + // `self.q!` is a `postfix_expression`, which the receiver walk did not + // peel. Fixed together — this row needs both. + { + name: 'optional-assigned-field', + callerId: `Function:${SWIFT_FILE}:OptionalAssignedField.run#1`, + targets: [`Function:${SWIFT_FILE}:Outer.inner#0`], + status: 'resolves', + }, + ], + }, +]; + +describe('inference-typed field receivers across languages (#2807)', () => { + const results = new Map(); + + beforeAll(async () => { + for (const testCase of CASES) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-matrix-${testCase.language}-`)); + try { + writeFixtureRepo(dir, { [testCase.file]: testCase.source }); + // CALLS resolution is complete before the graph phases run and nothing + // here reads what they produce, so skipping them narrows each run to + // the phase under test. + results.set( + testCase.language, + await runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), + ); + } finally { + // Not a bare `rmSync`: a pipeline run can still hold a handle open when + // this fires, which surfaces as EBUSY/EPERM on Windows — `force` does + // not suppress that — and this suite runs in the sharded Windows CI. + cleanupTempDirSync(dir); + } + } + }, 600000); + + /** + * Distinct CALLS target ids emitted by one exact caller, sorted. + * + * Deduplicated on purpose: Swift emits the same edge more than once for one + * call site, and edge MULTIPLICITY is a different question from whether the + * receiver typed at all. Deduplicating keeps this file measuring the one + * thing it claims to measure; a multiplicity regression belongs in a test + * that says so. + */ + function callTargets(language: string, callerId: string): string[] { + const result = results.get(language); + if (result === undefined) throw new Error(`no pipeline result for ${language}`); + return [ + ...new Set( + getRelationships(result, 'CALLS') + .filter((edge) => edge.rel.sourceId === callerId) + .map((edge) => edge.rel.targetId), + ), + ].sort(); + } + + function callerExists(language: string, callerId: string): boolean { + const result = results.get(language); + if (result === undefined) throw new Error(`no pipeline result for ${language}`); + return result.graph.getNode(callerId) !== undefined; + } + + for (const testCase of CASES) { + describe(testCase.language, () => { + // Every row's caller node must exist before any target assertion means + // anything: an id-scheme change or fixture drift would otherwise turn + // every row into a silently vacuous empty-vs-empty comparison — which is + // exactly how a "known gap" row rots into a passing lie. + it('every row has a live caller node', () => { + const found = Object.fromEntries( + testCase.rows.map((row) => [row.name, callerExists(testCase.language, row.callerId)]), + ); + expect(found).toEqual(Object.fromEntries(testCase.rows.map((row) => [row.name, true]))); + }); + + for (const row of testCase.rows.filter((r) => r.status === 'resolves')) { + it(`${row.name}: resolves`, () => { + expect(callTargets(testCase.language, row.callerId)).toEqual([...row.targets].sort()); + }); + } + + const gaps = testCase.rows.filter((r) => r.status === 'known-gap'); + if (gaps.length > 0) { + it(`KNOWN GAP: inference-typed field receivers emit no CALLS edges`, () => { + const observed = Object.fromEntries( + gaps.map((row) => [row.name, callTargets(testCase.language, row.callerId)]), + ); + expect(observed).toEqual(Object.fromEntries(gaps.map((row) => [row.name, []]))); + }); + } + }); + } + + // A whole-matrix guard: the per-language blocks above would all still pass if + // a language were quietly deleted from CASES, or if a row lost its control. + // Every language must keep at least one control row that resolves — that is + // what makes its gap rows mean "broken" rather than "fixture never worked". + it('every language keeps a resolving control row', () => { + const controls = Object.fromEntries( + CASES.map((testCase) => [ + testCase.language, + testCase.rows.some((row) => row.name.startsWith('control') && row.status === 'resolves'), + ]), + ); + expect(controls).toEqual(Object.fromEntries(CASES.map((c) => [c.language, true]))); + }); + + // The Dart pattern rows above were written to cover a FAMILY, not the handful + // of shapes a bug report carried — because an incomplete enumeration of binder + // forms is precisely what this file has now had to fix twice (formal + // parameters, then patterns). A comment claiming full coverage rots silently, + // so the claim is derived from the grammar instead of asserted: tree-sitter + // ships the node types it can produce in `nodeTypeInfo`, and every NAMED one + // whose name contains `pattern` must appear somewhere in the Dart fixture. + // + // A grammar bump that adds a thirteenth pattern node type therefore turns this + // red, which is the whole point — it forces someone back to + // `addDartBinderName` before the new form can silently retype a field. It goes + // red on removal too, which catches a fixture edit that quietly drops a shape. + it('the Dart fixture exercises every pattern node type the grammar declares', () => { + const parser = getDartParser(); + const language = parser.getLanguage() as { + readonly nodeTypeInfo: readonly { readonly type: string; readonly named: boolean }[]; + }; + const declared = language.nodeTypeInfo + .filter((entry) => entry.named && entry.type.includes('pattern')) + .map((entry) => entry.type) + .sort(); + + const exercised = new Set(); + const walk = (node: SyntaxNode): void => { + if (node.type.includes('pattern')) exercised.add(node.type); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null) walk(child); + } + }; + walk(parser.parse(DART_SOURCE).rootNode); + + // Compared as sorted lists, not "size >= n": a set comparison names the + // missing type in the failure output, which is the fact the next reader needs. + expect([...exercised].sort()).toEqual(declared); + }); +}); diff --git a/gitnexus/test/integration/resolvers/python-constructor-field-receiver.test.ts b/gitnexus/test/integration/resolvers/python-constructor-field-receiver.test.ts index 8d6f4613c..4cb0c8ef2 100644 --- a/gitnexus/test/integration/resolvers/python-constructor-field-receiver.test.ts +++ b/gitnexus/test/integration/resolvers/python-constructor-field-receiver.test.ts @@ -1,6 +1,10 @@ import { beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; +import { writeFixtureRepo } from './helpers.js'; +import { cleanupTempDirSync } from '../../helpers/test-db.js'; describe('Python calls through constructor-assigned receiver fields', () => { let result: PipelineResult; @@ -39,3 +43,289 @@ describe('Python calls through constructor-assigned receiver fields', () => { expect(misresolved).toEqual([]); }); }); + +/** + * `constructorCallTypeName` accepts a BARE-NAME callee only (#2807 review). + * + * ── WHY A DOTTED CALLEE IS REFUSED ─────────────────────────────────────────── + * + * The first cut of #2807 handed a dotted callee's full text to the resolver, on + * the theory that `models.User` resolves through `QualifiedNameIndex` the way + * the module-level `u = models.User()` capture in `query.ts` does. Measured + * here, that arm never produced a correct edge and did produce wrong ones. Both + * halves of that claim have rows below, because the fix is only defensible if + * BOTH hold — refusing a shape that worked would be a regression, not a fix. + * + * The wrong edge: a dotted rawName is matched by its TRAILING segment against a + * same-named class, so `self.svc = f.Alpha()` — where `Alpha` is a METHOD on an + * unrelated object and `Alpha` is also a class — typed the field as `Alpha` and + * `self.svc.ping()` emitted a FABRICATED `Alpha.ping`. `Factory.Alpha` returns + * a `str`; there is no sense in which that field is an `Alpha`. + * + * The absent right edge: `self.u = models.User()` emitted nothing with the arm + * or without it, because an instance field's binding lands in CLASS scope, + * which never reaches the namespace split that makes the module-level LOCAL + * form resolve. That local form is pinned below and is untouched by this + * change — it comes from `query.ts`, not from `receiver-binding.ts` — which is + * what keeps "dotted construction works somewhere" true while this module + * refuses it. + * + * ── HOW TO READ A ROW ──────────────────────────────────────────────────────── + * + * No row asserts an empty set. Where the correct outcome IS "this field does + * not type", the method also calls `Alien.ping()`, so the assertion is + * `{Alien.ping}` and a regression SWAPS a target in rather than emptying the + * set — an empty-set row would pass just as well if the fixture stopped + * parsing. `Alien` exists only to be that witness. + * + * ── THE DISPLACEMENT THIS ALSO CLOSED ──────────────────────────────────────── + * + * `self.conn = Outer()` followed by `self.conn = Registry.get()` used to emit NO + * edge at all: both candidates sat in the weakest tier, so the later dotted one + * — which resolves to nothing — displaced the real constructor binding under + * the tier tie-break's last-write-wins. It needed no separate mechanism. Once a + * dotted callee yields no candidate, `Registry.get()` is not a candidate to + * displace with, and `Outer` survives. The tie-break is deliberately left as + * last-write-wins: between two REAL constructions the last write in `__init__` + * genuinely is the live one, so tightening it would have been the wrong fix to + * a symptom of this one. + */ +const PY_FILE = 'src/app.py'; +const PY_SOURCE = `class Inner: + def compute(self, v): + return v * 2 + + +class Outer: + def inner(self): + return Inner() + + +class Alpha: + def ping(self): + return 1 + + +class Alien: + def ping(self): + return 2 + + +class Factory: + # A METHOD whose name collides with the class \`Alpha\`. The collision is the + # entire trigger — without it a dotted callee's trailing segment matches + # nothing and the defect is invisible. + def Alpha(self): + return "not an Alpha" + + def build(self): + return Outer() + + +class Registry: + def get(self): + return Outer() + + +class ParamRootCollision: + def __init__(self, f): + self.svc = f.Alpha() + + def run(self): + witness = Alien() + witness.ping() + return self.svc.ping() + + +class ModuleRootCollision: + def __init__(self): + self.svc = shared_factory.Alpha() + + def run(self): + witness = Alien() + witness.ping() + return self.svc.ping() + + +class PlainMethodCallControl: + def __init__(self, factory): + self.svc = factory.build() + + def run(self): + witness = Alien() + witness.ping() + return self.svc.inner() + + +class DoubleAssign: + def __init__(self): + self.conn = Outer() + self.conn = Registry.get() + + def run(self): + return self.conn.inner() + + +class SingleAssign: + def __init__(self): + self.conn = Outer() + + def run(self): + return self.conn.inner() + + +shared_factory = Factory() +`; + +const MODELS_FILE = 'src/models.py'; +const MODELS_SOURCE = `class User: + def greet(self): + return "hi" +`; + +const IMPORTS_FILE = 'src/imports_app.py'; +const IMPORTS_SOURCE = `import models +from models import User as User2 + + +class ImportedBareConstructor: + def __init__(self): + self.u = User2() + + def run(self): + return self.u.greet() + + +def module_level_dotted_local(): + u = models.User() + return u.greet() +`; + +interface Row { + readonly name: string; + /** Exact node id of the method holding the statement under test. */ + readonly callerId: string; + /** Every distinct CALLS target id this caller emits, sorted. */ + readonly targets: readonly string[]; +} + +const ROWS: readonly Row[] = [ + // ── S4: the fabrication, and that it is not about the root's binding form ── + // + // Pre-fix both of these also emitted `Alpha.ping`. The root is a PARAMETER in + // the first and a MODULE-LEVEL VARIABLE in the second; both fabricate, which + // is why the fix tests the callee's SHAPE and not what its root binds to. + { + name: 's4-param-rooted-dotted-callee-does-not-type-the-field', + callerId: `Method:${PY_FILE}:ParamRootCollision.run#0`, + targets: [`Method:${PY_FILE}:Alien.ping#0`], + }, + { + name: 's4-module-rooted-dotted-callee-does-not-type-the-field', + callerId: `Method:${PY_FILE}:ModuleRootCollision.run#0`, + targets: [`Method:${PY_FILE}:Alien.ping#0`], + }, + // The control the fix must not disturb: an ordinary `factory.build()` has a + // trailing segment that names no class, so it never fabricated and must still + // emit nothing for the field. Unchanged by the fix in both directions. + { + name: 's4-control-ordinary-method-call-still-emits-no-field-edge', + callerId: `Method:${PY_FILE}:PlainMethodCallControl.run#0`, + targets: [`Method:${PY_FILE}:Alien.ping#0`], + }, + + // ── S5: the displacement, closed by the same change ──────────────────────── + // + // Pre-fix this row's target set was EMPTY — the one place this file asserts a + // recovered edge rather than a removed one. + { + name: 's5-later-dotted-assignment-no-longer-displaces-the-constructor', + callerId: `Method:${PY_FILE}:DoubleAssign.run#0`, + targets: [`Method:${PY_FILE}:Outer.inner#0`], + }, + { + name: 's5-control-single-assignment-types-the-field', + callerId: `Method:${PY_FILE}:SingleAssign.run#0`, + targets: [`Method:${PY_FILE}:Outer.inner#0`], + }, + + // ── The over-tightening guards ───────────────────────────────────────────── + // + // These are the rows that go red if the bare-name arm is narrowed past the + // dotted case — a same-file construction (`SingleAssign` above) and an + // IMPORTED one, which is the shape that dies first if the rule is written + // against the callee's binding rather than its syntax. + { + name: 'bare-name-imported-constructor-still-types-the-field', + callerId: `Method:${IMPORTS_FILE}:ImportedBareConstructor.run#0`, + targets: [`Method:${MODELS_FILE}:User.greet#0`], + }, + // Dotted construction still resolves where it is actually implemented: a + // module-level LOCAL, via `query.ts`'s own capture. This row is what makes + // the docblock's "re-enabling dotted callees is a resolution-side change" + // checkable — it is untouched by `receiver-binding.ts` and must stay green. + { + name: 'module-level-dotted-local-construction-is-untouched', + callerId: `Function:${IMPORTS_FILE}:module_level_dotted_local`, + targets: [`Class:${MODELS_FILE}:User`, `Method:${MODELS_FILE}:User.greet#0`], + }, +]; + +describe('Python constructor-field receiver typing refuses a dotted callee (#2807 review)', () => { + let result: PipelineResult; + + beforeAll(async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-py-ctor-field-')); + try { + writeFixtureRepo(dir, { + [PY_FILE]: PY_SOURCE, + [MODELS_FILE]: MODELS_SOURCE, + [IMPORTS_FILE]: IMPORTS_SOURCE, + }); + // CALLS resolution is complete before the graph phases run and nothing + // here reads what they produce. + result = await runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }); + } finally { + // Not a bare `rmSync`: a pipeline run can still hold a handle open when + // this fires, which surfaces as EBUSY/EPERM on Windows. + cleanupTempDirSync(dir); + } + }, 600_000); + + function callTargets(callerId: string): string[] { + return [ + ...new Set( + getRelationships(result, 'CALLS') + .filter((edge) => edge.rel.sourceId === callerId) + .map((edge) => edge.rel.targetId), + ), + ].sort(); + } + + // Every row's caller must exist before any target assertion means anything: + // an id-scheme change or fixture drift would otherwise turn a row into a + // silently vacuous empty-vs-empty comparison. + it('every row has a live caller node', () => { + const found = Object.fromEntries( + ROWS.map((row) => [row.name, result.graph.getNode(row.callerId) !== undefined]), + ); + expect(found).toEqual(Object.fromEntries(ROWS.map((row) => [row.name, true]))); + }); + + // Non-vacuity, the other half: the two fabrication rows are only meaningful + // while a class named `Alpha` and a same-named METHOD both exist for the + // trailing-segment match to find. If either disappears the rows keep passing + // for the wrong reason, so assert the collision itself. + it('the name collision the fabrication needs is actually present', () => { + expect({ + classAlpha: result.graph.getNode(`Class:${PY_FILE}:Alpha`) !== undefined, + methodAlpha: result.graph.getNode(`Method:${PY_FILE}:Factory.Alpha#0`) !== undefined, + }).toEqual({ classAlpha: true, methodAlpha: true }); + }); + + for (const row of ROWS) { + it(row.name, () => { + expect(callTargets(row.callerId)).toEqual([...row.targets].sort()); + }); + } +}); diff --git a/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts b/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts new file mode 100644 index 000000000..ff7c0fff2 --- /dev/null +++ b/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts @@ -0,0 +1,134 @@ +/** + * #2807 follow-up — a FUNCTION-LOCAL callable must keep its own graph node when + * the def and the node disagree about the callable LABEL. + * + * Swift's structure phase emits a type's methods as `Function` nodes while the + * scope extractor derives `Method` from the `@declaration.method` anchor. Every + * key `resolveDefGraphId` builds is scoped to one label, so under that split the + * position key (#2699) missed AND the fail-closed guard beside it could never + * fire — the guard is scoped to the def's own label too, so it was unreachable + * in exactly the case the sibling-label retry below it serves. The local then + * fell through to that retry and was aliased onto the class method of the same + * name. + * + * Measured before the fix, on this fixture: the local `func helper` inside + * `Host.run` resolved to `Function:src/app.swift:Host.helper#1`, so the `sink()` + * call in the LOCAL's body was emitted as an outgoing edge of the public + * one-argument method — a caller that does not make that call, present in the + * graph, in a file whose two `helper`s do not even share an arity. + * + * The two `helper`s are deliberately given DIFFERENT arities. Arity is the + * disambiguator every name-keyed bridge lookup falls back on, so a fixture where + * they matched could pass on a lookup that still cannot tell the two apart. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js'; +import type { PipelineResult } from './helpers.js'; +import { cleanupTempDirSync } from '../../helpers/test-db.js'; +import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; + +const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); + +const FILE = 'src/app.swift'; + +/** + * Line/column-sensitive: the local's node id encodes its declaration position + * (`@8:8` — 0-based row 8, column 8), so reindenting or moving a line renames + * that node. The inventory test below fails loudly rather than silently + * measuring nothing if this fixture is edited. + */ +const SOURCE = `func sink(_ v: Int) -> Int { return v } +func probe(_ v: Int) -> Int { return v } + +class Host { + func helper(_ a: Int) -> Int { + return probe(a) + } + func run(_ x: Int) -> Int { + func helper(_ v: Int, _ w: Int) -> Int { + return sink(v + w) + } + return helper(x, x) + } +} +`; + +const METHOD_HELPER = `Function:${FILE}:Host.helper#1`; +const LOCAL_HELPER = `Function:${FILE}:Host.run.helper@8:8#2`; +const RUN = `Function:${FILE}:Host.run#1`; +const SINK = `Function:${FILE}:sink`; +const PROBE = `Function:${FILE}:probe`; + +describe.skipIf(!swiftAvailable)('a function-local callable keeps its own node (#2807)', () => { + let result: PipelineResult; + + beforeAll(async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-label-split-')); + try { + writeFixtureRepo(dir, { [FILE]: SOURCE }); + // CALLS resolution completes before the graph phases run and nothing here + // reads what they produce. + result = await runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }); + } finally { + // Not a bare `rmSync`: a pipeline run can still hold a handle open when + // this fires, which surfaces as EBUSY/EPERM on Windows — `force` does not + // suppress that — and this suite runs in the sharded Windows CI. + cleanupTempDirSync(dir); + } + }, 120000); + + /** Distinct CALLS targets emitted by one exact caller id, sorted. */ + const targetsFrom = (callerId: string): string[] => + [ + ...new Set( + getRelationships(result, 'CALLS') + .filter((edge) => edge.rel.sourceId === callerId) + .map((edge) => edge.rel.targetId), + ), + ].sort(); + + // Non-vacuity: the whole point is that the local and the method are TWO + // nodes. If the structure phase ever stopped minting the local — or the + // fixture drifted and renamed it — every edge assertion below would degrade + // into an empty-vs-empty comparison and pass while measuring nothing. + it('mints a distinct node for the method and for the function-local', () => { + expect({ + method: result.graph.getNode(METHOD_HELPER) !== undefined, + local: result.graph.getNode(LOCAL_HELPER) !== undefined, + run: result.graph.getNode(RUN) !== undefined, + }).toEqual({ method: true, local: true, run: true }); + }); + + // The regression itself. Asserted as ONE object over both callers on purpose: + // the defect moved an edge from one to the other, so checking either side + // alone would let a fix that merely dropped the edge look correct. + it('attributes each body’s call to the callable that actually contains it', () => { + expect({ + [METHOD_HELPER]: targetsFrom(METHOD_HELPER), + [LOCAL_HELPER]: targetsFrom(LOCAL_HELPER), + }).toEqual({ + [METHOD_HELPER]: [PROBE], + [LOCAL_HELPER]: [SINK], + }); + }); + + /** + * KNOWN GAP, pinned so it cannot be mistaken for part of the fix above. + * + * `helper(x, x)` inside `run` still targets the one-argument METHOD instead of + * the two-argument local. That is decided upstream of the graph bridge: the + * free-call binding hands `emitFreeCallFallback` the class-member def + * (`def:src/app.swift#5:4:Method:helper`), never the local's + * (`def:src/app.swift#9:8:Method:helper`), so no def→node mapping can correct + * it — both defs carry the same `qualifiedName` and the same label, and the + * binding walk picks the member. Recorded here rather than fixed because it + * lives in the scope walk, not in `resolveDefGraphId`. + */ + it('KNOWN GAP: the call to the local still binds to the same-named method', () => { + expect(targetsFrom(RUN)).toEqual([METHOD_HELPER]); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript-inferred-field-receiver.test.ts b/gitnexus/test/integration/resolvers/typescript-inferred-field-receiver.test.ts index 6b6d8dc3d..a2fcd2ba9 100644 --- a/gitnexus/test/integration/resolvers/typescript-inferred-field-receiver.test.ts +++ b/gitnexus/test/integration/resolvers/typescript-inferred-field-receiver.test.ts @@ -1,14 +1,12 @@ /** - * Resolver pin: a TypeScript class field whose type must be INFERRED from its - * initializer cannot act as a call receiver — the calling method emits NO CALLS - * edges at all, not even for the first, ordinary named-receiver link. + * Resolver pin: every TypeScript receiver FORM resolves a chained call, whether + * the receiver's type is declared or inferred from its initializer (#2807). * - * ── WHERE THE SUPPORT ACTUALLY STOPS ────────────────────────────────────────── + * ── WHAT THIS FILE PINS ─────────────────────────────────────────────────────── * - * Measured against the single-file fixture below (nine receiver shapes in one + * Measured against the single-file fixture below (all receiver shapes in one * repo). Every caller runs the same statement, `.inner().compute(x)`; - * only the receiver FORM varies. The discriminator is whether the receiver's - * type is DECLARED, not whether it is a local or a field: + * only the receiver FORM varies: * * receiver form CALLS edges emitted * ------------------------------------------------------- -------------------------- @@ -19,44 +17,34 @@ * field `constructor(private p: Outer)` (param property) Outer.inner + Inner.compute * result `makeOuter().inner().compute()` makeOuter + both links * chain `o.inner().mid().compute()` (three links) all three links - * field `private p = new Outer()` (INFERRED) NONE <- known gap - * field `private p;` + ctor `this.p = new Outer()` NONE <- known gap + * field `private p = new Outer()` (INFERRED) Outer.inner + Inner.compute + * field `private p;` + ctor `this.p = new Outer()` Outer.inner + Inner.compute + * field `private p;` + method `this.p = new Outer()` Outer.inner + Inner.compute * - * The two gap rows do not merely lose the CHAINED link. They emit nothing: the - * caller has no outgoing CALLS edge whatsoever, so `Outer.inner` — a plainly - * named receiver call — is lost too. `KNOWN GAP` below asserts that empty value - * EXACTLY, alongside a `callerExists` probe so the assertion cannot pass - * vacuously if fixture drift or an id-scheme change moved the caller node. + * ── WHY THE LAST THREE ROWS ARE HERE (#2807) ────────────────────────────────── * - * `only the receiver TYPE is lost` narrows where to look: the `new Outer()` - * initializer of an inferred field IS resolved (it emits its own constructor - * CALLS edge, exactly as the annotated twin does). What is missing is the step - * that turns that initializer into a type binding for the field. + * They used to emit NOTHING — not a partial chain, no outgoing CALLS edge at + * all, so even `Outer.inner`, a plainly named receiver call, was lost. The + * discriminator was whether the field DECLARED its type: an untyped field had + * no entry in its class scope's `typeBindings`, so `typeOfMemberOnClass` came + * back empty and `foldReceiverChain` declined at the very first step. * - * The annotated twins are pinned in the same file on purpose — a gap test that - * shows only the broken shape does not tell the next engineer where the - * boundary is. + * The `new Outer()` initializer was never the problem — it always emitted its + * own constructor edge, exactly as the annotated twin does (still asserted + * below). What was missing was the step turning that initializer into a TYPE + * BINDING for the field, i.e. a `@type-binding.constructor` capture pattern + * anchored on `public_field_definition` and on `this. = new …`. * - * ── THIS PIN IS SELF-DIFFING: IT WILL GO RED ON PURPOSE ─────────────────────── + * The annotated twins stay pinned alongside on purpose: they are what proves a + * regression would be a regression, and one of them — + * `AnnotationBeatsInitializerCaller` — deliberately mistypes its annotation so + * that the annotation-over-initializer source-strength tie-break is asserted + * executably rather than assumed. * - * The gap is tracked as issue #2807 ("Inference-typed field receivers resolve to - * no CALLS edges at all"); PR #2810 is open against it at the time of writing. - * `KNOWN GAP` asserts that the gap EXISTS — no CALLS edges, exactly — so it is a - * record rather than a regression guard. When the resolver learns to infer a - * field's type from its initializer it fails with the newly resolved ids in the - * diff; that is the intended signal. The fix is to update this file (the table - * above, the rows' `resolution`, and the pin's expected value), not to relax the - * assertion into something a passing fix would also satisfy. - * - * The gap was FOUND during #2802 work but is PRE-EXISTING and independent of it: - * nothing on that branch touches receiver typing. Track the gap itself at #2807. - * - * The same fact is also observable one layer down, as an empty - * `BasicBlock.calleeIds` cell, in `test/integration/cfg/ - * pdg-chained-receiver-callees.test.ts` — but that is the PDG's view of a - * RESOLVER fact, behind a full `--pdg` pipeline. Whoever closes this gap will - * be working in the resolver suite, so the fact is pinned here too, at the - * level the fix actually changes. + * The same fact is observable one layer down as a `BasicBlock.calleeIds` cell + * in `test/integration/cfg/pdg-chained-receiver-callees.test.ts` — that is the + * PDG's view of this RESOLVER fact, behind a full `--pdg` pipeline. Keep the + * two files in step: whoever changes receiver typing changes both. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import path from 'node:path'; @@ -169,6 +157,37 @@ export class CtorAssignedInferredCaller { return r; } } + +export class MethodAssignedInferredCaller { + private lateBound; + setUp(): void { + this.lateBound = new Outer(); + } + runMethodAssignedInferred(x: number): number { + const r = this.lateBound.inner().compute(x); + return r; + } +} + +// Deliberately mistyped: the annotation says \`Mismatch\`, the initializer +// constructs an \`Outer\`. TypeScript would reject it; the resolver must still +// prefer the ANNOTATION, because \`annotation\` outranks \`constructor-inferred\` +// in \`typeBindingStrength\`. \`Mismatch\` has no \`inner\`, so a resolver that let +// the initializer win would emit \`Outer.inner\` here — the one row in this file +// that fails if the source-strength tie-break regresses. +export class Mismatch { + notInner(): number { + return 0; + } +} + +export class AnnotationBeatsInitializerCaller { + private mistyped: Mismatch = new Outer(); + runAnnotationBeatsInitializer(x: number): number { + const r = this.mistyped.inner().compute(x); + return r; + } +} `; // EXACT node ids — never names or substrings. `compute` alone is ambiguous @@ -182,15 +201,13 @@ const INNER_MID = `Method:${FIXTURE_PATH}:Inner.mid#0`; const MID_COMPUTE = `Method:${FIXTURE_PATH}:Mid.compute#1`; const MAKE_OUTER = `Function:${FIXTURE_PATH}:makeOuter`; -/** - * `resolves` — every chain link becomes a CALLS edge today. - * `known-gap-no-calls-edges` — the resolver cannot type the receiver, so the - * caller emits no CALLS edge at all and even the named first link is lost. - */ -type ChainResolution = 'resolves' | 'known-gap-no-calls-edges'; +/** Every chain link becomes a CALLS edge. The only value today — the + * inference-typed rows joined it in #2807 — but kept as a named type so a + * future gap row has somewhere to say so instead of being a bare boolean. */ +type ChainResolution = 'resolves'; interface ReceiverShape { - /** Row name; also the key of the known-gap pin below. */ + /** Row name; also the assertion key in the diff when a row moves. */ readonly name: string; /** Exact node id of the function or method holding the chained statement. */ readonly callerId: string; @@ -242,25 +259,35 @@ const RECEIVER_SHAPES: readonly ReceiverShape[] = [ targets: [OUTER_CLASS, OUTER_INNER, INNER_MID, MID_COMPUTE], resolution: 'resolves', }, - // ── Known gaps ──────────────────────────────────────────────────────────── + // ── Inference-typed fields (#2807) ──────────────────────────────────────── // Identical to `annotated-field-initializer` / `ctor-assigned-annotated` - // above except that the field carries no type annotation, so its type would - // have to be inferred from the initializer. + // above except that the field carries no type annotation, so its type is + // inferred from the initializer. These two emitted NOTHING before #2807 — + // not even the first, plainly named link — because an untyped field had no + // type binding for the receiver fold to stand on. { name: 'inferred-field-initializer', callerId: `Method:${FIXTURE_PATH}:InferredFieldCaller.runInferredField#1`, - targets: [], - resolution: 'known-gap-no-calls-edges', + targets: [OUTER_INNER, INNER_COMPUTE], + resolution: 'resolves', }, { name: 'ctor-assigned-inferred', callerId: `Method:${FIXTURE_PATH}:CtorAssignedInferredCaller.runCtorAssignedInferred#1`, - targets: [], - resolution: 'known-gap-no-calls-edges', + targets: [OUTER_INNER, INNER_COMPUTE], + resolution: 'resolves', + }, + // The assignment that types the field need not be in the constructor — the + // capture matches any `this. = new …`, so a setter binds it too. + { + name: 'method-assigned-inferred', + callerId: `Method:${FIXTURE_PATH}:MethodAssignedInferredCaller.runMethodAssignedInferred#1`, + targets: [OUTER_INNER, INNER_COMPUTE], + resolution: 'resolves', }, ]; -describe('TypeScript chained receiver calls by field-type form (known gap: #2807)', () => { +describe('TypeScript chained receiver calls by field-type form (#2807)', () => { let result: PipelineResult; let repoDir: string | undefined; @@ -304,26 +331,33 @@ describe('TypeScript chained receiver calls by field-type form (known gap: #2807 }); } - // Pins the CURRENT broken value, not merely that the chain fails. An - // `it.fails` row here would be strictly weaker — it is satisfied by ANY - // throw, so a renamed fixture symbol would keep it green on a rotted - // premise. `callerExists` is folded into the same object so an empty - // `calls` list can never be read as "resolved fine, wrong node id". - // This asserts the gap EXISTS (issue #2807), so closing #2807 turns it red - // BY DESIGN; update it together with the header table and the rows' - // `resolution` rather than loosening it. - it('KNOWN GAP (#2807): an inference-typed field receiver emits NO CALLS edges at all', () => { - const gaps = RECEIVER_SHAPES.filter((s) => s.resolution === 'known-gap-no-calls-edges'); - const observed = Object.fromEntries( - gaps.map((s) => [ - s.name, - { callerExists: nodeExists(s.callerId), calls: callTargetsFrom(s.callerId) }, - ]), - ); - - expect(observed).toEqual({ - 'inferred-field-initializer': { callerExists: true, calls: [] }, - 'ctor-assigned-inferred': { callerExists: true, calls: [] }, + // The source-strength tie-break, as an executable row rather than a comment. + // `private mistyped: Mismatch = new Outer()` matches BOTH the annotation + // pattern and the field constructor-inferred pattern added for #2807; + // `annotation` outranks `constructor-inferred` in `typeBindingStrength`, so + // the field must stay typed as `Mismatch` — which has no `inner` — and the + // caller must emit NO call edge. If the inferred binding ever wins instead, + // this is the only row in the file that notices: every other row would keep + // resolving, because for them the two sources agree. + // + // The load-bearing half of the assertion is the ABSENCE of `Outer.inner`: + // that is the edge a resolver would emit if the initializer had won. + // + // `Inner.compute` IS present, and deliberately pinned rather than filtered + // out. It does not come from the field at all — `Mismatch` has no `inner`, so + // the fold falls through to the hoisted branch in `typeOfMemberOnClass`, + // finds the module-level return-type binding `inner -> Inner` that + // `hoistTypeBindingsToModule` puts there, and types the NEXT position from + // it. Verified byte-identical on the pre-#2807 tree (same fixture, same + // single id), so it is a pre-existing property of the hoisted lookup and not + // something the field-initializer patterns introduced. Pinning the exact list + // rather than asserting "no Outer.inner" means a future change to either + // mechanism has to come through this row. + it('an annotated field beats its own initializer — the tie-break is by source strength', () => { + const callerId = `Method:${FIXTURE_PATH}:AnnotationBeatsInitializerCaller.runAnnotationBeatsInitializer#1`; + expect({ callerExists: nodeExists(callerId), calls: callTargetsFrom(callerId) }).toEqual({ + callerExists: true, + calls: [INNER_COMPUTE], }); }); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index e22a301e4..1251a6eb2 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -106,7 +106,10 @@ describe('PARSE_CACHE_VERSION', () => { // conditional-directive parse-semantics change (#2771), 38 -> 39 for // receiver-chain wire format v2: every persisted chain string changed prefix // and a v2 decoder refuses v1 by design, so a stale cache replays chains this - // build silently discards. + // build silently discards. 39 -> 40 for inference-typed field captures in six + // languages (#2807) — all parse-time emission, so a warm cache replays the + // pre-fix capture set for byte-unchanged files and the new receiver edges + // never appear. // // This pin has now earned its keep EIGHT times, and twice it caught an EXACT // clash rather than a near-miss: main took 37 for #2416 while this branch @@ -115,8 +118,8 @@ describe('PARSE_CACHE_VERSION', () => { // second clash was caught — after review, while the branch sat waiting to // merge — which is precisely the window in which `main` allocates. Re-check // against origin/main immediately before merge, not at review time. - it('pins SCHEMA_BUMP to 39 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(39); + it('pins SCHEMA_BUMP to 42 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(42); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/scope-resolution/graph-bridge-label-split.test.ts b/gitnexus/test/unit/scope-resolution/graph-bridge-label-split.test.ts new file mode 100644 index 000000000..ef758e0ae --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/graph-bridge-label-split.test.ts @@ -0,0 +1,118 @@ +/** + * #2807 follow-up — `resolveDefGraphId` under a def/node LABEL SPLIT. + * + * Some structure phases emit a type's methods as `Function` nodes while the + * scope extractor derives `Method` from the `@declaration.method` anchor. Every + * key the resolver builds is label-scoped, so under that split all of them miss + * and the def lands on the label-agnostic, first-write-wins `simpleKey`. + * + * The shipped fix retried the QUALIFIED keys under the sibling callable label, + * but left the two keys ABOVE them — the #2699 position key and its fail-closed + * guard — scoped to the def's own label. Since the split is the premise, both + * were dead there: the guard could never fire for the case the retry serves, so + * the retry inherited the aliasing the guard exists to stop. These cases pin the + * mirrored behaviour at the unit level, where a lookup can be posed directly + * instead of being coaxed out of a language. + */ +import { describe, expect, it } from 'vitest'; +import { resolveDefGraphId } from '../../../src/core/ingestion/scope-resolution/graph-bridge/ids.js'; +import { + AMBIGUOUS_POSITION, + localNameKey, + positionKey, + qualifiedKey, + simpleKey, +} from '../../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js'; + +const FILE = 'src/app.swift'; +const METHOD_NODE = `Function:${FILE}:Host.helper#1`; +const LOCAL_NODE = `Function:${FILE}:Host.run.helper@8:8#2`; + +/** + * The scope-side def for the function-local `helper`: labelled `Method` (the + * declaration anchor), declared on 1-based line 9, and qualified by its owning + * TYPE — which is why it collides with the class method's name at all. + */ +const LOCAL_DEF = { + nodeId: `def:${FILE}#9:8:Method:helper`, + qualifiedName: 'Host.helper', + type: 'Method', +} as const; + +/** The class method's own def: same name, same label, 1-based line 5. */ +const METHOD_DEF = { + nodeId: `def:${FILE}#5:4:Method:helper`, + qualifiedName: 'Host.helper', + type: 'Method', +} as const; + +/** Keys the method's `Function` node always registers. */ +const methodNameKeys: readonly (readonly [string, string])[] = [ + [qualifiedKey(FILE, 'Function', 'Host.helper'), METHOD_NODE], + [qualifiedKey(FILE, 'Function', 'Host.helper#1'), METHOD_NODE], + [simpleKey(FILE, 'helper'), METHOD_NODE], +]; + +const lookupOf = (entries: readonly (readonly [string, string])[]): ReadonlyMap => + new Map(entries.map(([k, v]) => [k, v])); + +describe('resolveDefGraphId across a def/node label split (#2807)', () => { + it('consults the position key under the sibling label', () => { + const lookup = lookupOf([ + ...methodNameKeys, + [positionKey(FILE, 'Function', 4, 'helper'), METHOD_NODE], + [positionKey(FILE, 'Function', 8, 'helper'), LOCAL_NODE], + ]); + expect({ + local: resolveDefGraphId(FILE, LOCAL_DEF, lookup), + method: resolveDefGraphId(FILE, METHOD_DEF, lookup), + }).toEqual({ local: LOCAL_NODE, method: METHOD_NODE }); + }); + + // The guard's whole job: when the position join misses but a function-local of + // this name is registered, emitting NO edge is correct and aliasing onto the + // same-named method is not. Without the sibling arm this def reaches the + // qualified retry and comes back as the method. + it('fails closed under the sibling label when a same-named local exists', () => { + const lookup = lookupOf([ + ...methodNameKeys, + [localNameKey(FILE, 'Function', 'helper'), LOCAL_NODE], + ]); + expect(resolveDefGraphId(FILE, LOCAL_DEF, lookup)).toBeUndefined(); + }); + + // …and only then. A file with no such local keeps resolving through the + // sibling qualified retry, which is what the shipped #2807 fix added; a guard + // that fired here would delete every Swift method edge in the repo. + it('still resolves through the sibling qualified key when no local exists', () => { + expect(resolveDefGraphId(FILE, METHOD_DEF, lookupOf(methodNameKeys))).toBe(METHOD_NODE); + }); + + // An `AMBIGUOUS_POSITION` tombstone means two callables already claim this + // line under the def's OWN label. Relabelling must not resolve that by + // picking a third node — the tombstone keeps falling through to the name keys. + it('does not let the sibling label resolve an AMBIGUOUS_POSITION tombstone', () => { + const lookup = lookupOf([ + ...methodNameKeys, + [positionKey(FILE, 'Method', 8, 'helper'), AMBIGUOUS_POSITION], + [positionKey(FILE, 'Function', 8, 'helper'), LOCAL_NODE], + ]); + expect(resolveDefGraphId(FILE, LOCAL_DEF, lookup)).toBe(METHOD_NODE); + }); + + // The dot gate on the qualified retry is untouched: a bare name carries no + // owner, so crossing the labels there is precisely the top-level-vs-method + // aliasing the label was introduced to prevent. + it('keeps the dot gate on the qualified retry', () => { + const lookup = lookupOf([ + [qualifiedKey(FILE, 'Function', 'helper'), `Function:${FILE}:helper`], + ]); + expect( + resolveDefGraphId( + FILE, + { nodeId: `def:${FILE}#9:8:Method:helper`, qualifiedName: 'helper', type: 'Method' }, + lookup, + ), + ).toBeUndefined(); + }); +}); From cabd5b82f9e5174fdd3bd3511c5c690fe17605f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 4 Aug 2026 21:52:37 +0100 Subject: [PATCH 11/17] fix(go): model Go method sets exactly so interface satisfaction is decidable (#2813) (#2829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(go): pin calls through an interface-typed struct field (#2813) A call through an interface-typed struct field never reaches the implementation: the CALLS edge stops at the interface DECLARATION, so `impact()` on the implementing method reports 0 callers. This commit adds the executable statement of that defect; the fixes follow. Two stacked defects produce it, and either alone is enough to reproduce — which is why no existing fixture could observe it: D1 `buildDetectionIndexes` skips every POINTER-receiver method, so a struct whose methods are all `func (r *T)` has an empty method set, structurally satisfies nothing, and gets no IMPLEMENTS edge. Go's rule is that the method set of *T includes pointer-receiver methods, and idiomatic Go stores *T in an interface-typed field. D2 Case 0 (compound receiver) emits its primary edge and short-circuits without the interface-dispatch fan-out Case 4 performs. A struct field receiver `s.orderRepo` contains a dot and so always takes Case 0; a local or parameter receiver is a bare name and reaches Case 4. Every implementor in both pre-existing structural-dispatch fixtures uses a VALUE receiver, and the one pointer-receiver type is pinned as a negative (`not.toContain('PointerOnlyThing -> PointerOnly')`), so the corpus could not see D1 by construction. The new fixture is pointer-receiver throughout, cross-package, and carries concrete-field controls in the same structs. Failing-first, verified against this tree: 7 of the 11 new assertions fail and 4 pass. The 4 that pass are exactly the controls that must not regress — the primary edge to the interface declaration, the concrete-field call, the absence of fan-out on a concrete field, and the partial-signature negative — so the suite discriminates rather than merely failing. Two recorded artifacts move here because the FIXTURE was added, not because capture output changed: - test/fixtures/go-captures-golden/expected-captures.json — regenerated additively (32 insertions, 0 deletions). - bench/scope-capture/baselines.json — go fingerprint, fixture_count 102 -> 110. Both are regenerated in this commit rather than deferred to the end of the series: the fixture is their only cause, no later commit touches capture emission, so they cannot re-drift and every commit stays green. The check that this is corpus growth and not a capture regression is that go was the only one of 15 language fingerprints to move on the same run. Co-Authored-By: Claude Opus 5 (1M context) * fix(go): count pointer-receiver methods toward structural interface satisfaction (#2813) D1 of two stacked defects. `buildDetectionIndexes` skipped every method whose receiver is a pointer, so a struct declaring `func (r *OrderRepo) DeleteItem(...)` had an EMPTY method set, structurally satisfied nothing, and produced no IMPLEMENTS edge at all. Go's method-set rule is per-type, and there are two types involved: the method set of `T` holds only value-receiver methods, while the method set of `*T` holds both. #1966 implemented the `T` reading, which is exactly right for `T` — and leaves `*T` permanently empty. GitNexus models one Struct node per type with no separate `*T` node, so only one of the two can be represented, and the `T` reading is the one idiomatic Go almost never uses: methods take pointer receivers so they can mutate, and `*T` is what gets stored in an interface-typed field. The cost was silence rather than caution. With no IMPLEMENTS edge, a call through an interface-typed field resolved to the interface DECLARATION and `impact()` on the implementing method returned 0 callers — byte-identical to a symbol that genuinely has none, which is what made the reporter's blast-radius check unusable rather than merely incomplete. This picks the `*T` reading: the graph now answers "which types provide this interface's behaviour", and no longer proves `var x I = T{}` invalid. The trade is deliberate and was checked against every consumer of IMPLEMENTS before being made — MRO/METHOD_IMPLEMENTS derivation, community clustering, the receiver-dispatch fan-out index, and the epistemic heritage probe. None performs value-assignability checking. Two negative pins encoded the #1966 decision and are REVERSED here rather than deleted, each keeping a comment that explains why the polarity moved: - go.test.ts: `PointerOnlyThing -> PointerOnly` now expected to be emitted. - go-hooks.test.ts: the pointer-receiver-only unit case now expects the implementor instead of `undefined`. `goReceiverKind` is still stamped in method-owners.ts — it is the hook a future value/pointer-aware model would read — but is deliberately no longer a filter. Its now-dead local predicate and type alias are removed so the file no longer carries a helper asserting the reverted rule. Measured on the #2813 fixture, this commit alone: the two IMPLEMENTS assertions flip to passing (6 pass, up from 4) while the five interface-dispatch fan-out assertions still fail — those are D2, fixed in the next commit. Keeping the two commits separate is what makes that attribution visible. Go unit suite: 91 passed. Co-Authored-By: Claude Opus 5 (1M context) * fix(resolution): fan out interface dispatch from a compound receiver (#2813) D2 of two stacked defects, and the one that closes the issue. Case 0 (compound receiver) emitted its primary edge and short-circuited without the interface-dispatch fan-out that Case 4 performs, so a call whose receiver is a struct FIELD stopped at the interface's method DECLARATION and never reached any implementation. The gap was a property of receiver SYNTAX rather than of types. Case 0 is selected by `receiverName.includes('.')`, so a field receiver (`s.orderRepo`) always lands there, while the very same interface reached through a local or a parameter is a bare name and falls through to Case 4 — which fans out correctly. Field-held interfaces, i.e. dependency injection, were the half that silently lost every implementation edge; the pre-existing fixtures exercise the local and parameter forms only, which is why the suite was green. The fix is the call Case 4 already makes, placed after Case 0's primary `tryEmitEdge` and before its `handledSites.add`. It stays language-agnostic (AGENTS.md section 42): `emitInterfaceDispatchFor` self-gates on `ownerDef.type !== 'Interface'`, so a receiver that folds to a Struct emits nothing extra and no language check is needed. Confidence is Case 0's own 0.85 literal, not Case 4's site.kind-dependent value — Case 0 has no read/write arm to mirror. The case ladder itself is untouched: invariant I4 in contract/scope-resolver.ts makes the ordering a contract, so the fan-out is added INSIDE Case 0 rather than by reordering or merging cases. Also flips a second, previously unnoticed encoding of the #1966 value-only reading that the full sweep surfaced: the exact-set assertion at go.test.ts:361 enumerates every structural IMPLEMENTS edge, and D1 correctly adds `PointerOnlyThing -> PointerOnly` to it. It is D1 fallout rather than D2's, but D1 had already landed; recording it here with its reason beats amending a commit whose separate measurability is the point. Measured: - #2813 suite: 11 of 11 pass (was 7 failing after D1 alone, which fixed only the two IMPLEMENTS rows). - go.test.ts: 160 passed. - Full cross-language sweep, test/integration/resolvers: 3027 passed, 1 skipped, across 52 files. The single failure in that run was the exact-set assertion above, fixed here; no other language regressed. `detect_changes` rates this HIGH (6 affected flows, all EmitReceiverBoundCalls at step 1) — inherent to editing a hub symbol in the resolution pipeline. The sweep above is the empirical answer to that label. An existing index must be re-analyzed to show the new edges; this changes what the resolver produces, not how it is stored, so no SCHEMA_BUMP applies. Co-Authored-By: Claude Opus 5 (1M context) * test(go): pin the heritage edges that make impact() hedge an interface-bound count (#2813) The epistemic half of the issue, resolved by MEASUREMENT rather than by new code, and pinned at its mechanism. The reporter's disqualifying complaint was that `impact()` reported `impactedCount 0, epistemic "exact", risk LOW` for a method reachable only through an interface-typed field — byte-identical to what it reports for a symbol that genuinely has no callers. A zero therefore could not be used defensively, which was the entire use case. That verdict comes from `computeEpistemicBoundary`, which has two producers and neither fired: the call sites were not DROPPED (they resolved, just to the interface declaration, so the #2744 receiver-typing producer saw nothing), and its heritage probe walks IMPLEMENTS/METHOD_IMPLEMENTS edges out of the queried symbol — of which there were none, because the pointer-receiver exclusion (D1) meant no such edge was ever emitted. Restoring those edges fixes the epistemics as a side effect, so the planned conditional change to local-backend.ts is NOT needed. Measured on this fixture against the fixed tree: impact(OrderRepo.DeleteItem, upstream) before: impactedCount 0, epistemic "exact" after: impactedCount 3, epistemic "lower-bound", with an interface boundary note; the three callers are OrderHandlers.Delete, PickService.StartSession and WaveService.Release — all correct. impact(CartRepo.Get, upstream) [concrete receiver, no interface] after: impactedCount 1, epistemic "exact" The second row is the one that matters for trust: the hedge discriminates instead of firing on everything, so "exact" still means exact. This test asserts the METHOD_IMPLEMENTS edges the probe walks. Pinning the mechanism keeps the resolver suite from reaching into the MCP layer while still failing loudly if the edges regress; the impact() numbers above are recorded in the commit message and PR body rather than re-asserted here. #2813 suite: 12 passed. Co-Authored-By: Claude Opus 5 (1M context) * fix(go): model Go method sets exactly so interface satisfaction is decidable (#2813) Replaces the approximate structural-interface model with the rules the Go spec actually defines, so the graph answers what the compiler answers instead of a useful-but-wrong summary of it. Three answers were provably wrong before; all three are now exact and covered. Method sets (go.dev/ref/spec#Method_sets): MS(T) = methods declared with receiver T MS(*T) = methods declared with receiver *T OR T Promotion (#Struct_types): S embeds T -> MS(S) and MS(*S) get promoted methods with receiver T; MS(*S) ALSO gets those with receiver *T S embeds *T -> MS(S) AND MS(*S) both get receiver T or *T Identifier identity (#Uniqueness_of_identifiers): "Two identifiers are different if they are spelled differently, OR IF THEY APPEAR IN DIFFERENT PACKAGES AND ARE NOT EXPORTED." func (b *Base) Ping() // pointer receiver type ByValue struct{ Base } type ByPointer struct{ *Base } type before exact answer Base IMPLEMENTS only *Base implements ByValue IMPLEMENTS only *ByValue implements ByPointer IMPLEMENTS the VALUE type implements All three were the same edge. Two of the three were wrong, and nothing in the graph could tell them apart. Worse, in a different direction: package sealed; type Sealed interface { seal() } package foreign; func (t *T) seal() {} `foreign.T` cannot implement `sealed.Sealed` in Go — `seal` is unexported, so the two identifiers are DIFFERENT. Matching on the bare name emitted a FALSE IMPLEMENTS edge, and the interface-dispatch fan-out then turned it into an impossible CALLS edge. That is the entire basis of the sealed-interface idiom. - `methodSetKey` qualifies UNEXPORTED method names with their declaring package, leaving exported names unqualified (which is what makes cross-package satisfaction work at all). Exactness, not a heuristic: the sealed case now emits no edge, while the legitimate same-package implementor is retained. - `collectStructMethodEntries` builds MS(T) and MS(*T) together and applies the promotion table above. The embed FORM is load-bearing, so it is now captured: `@reference.embedded-pointer` records `*T` versus `T`, which the parser previously discarded (the `*` is an unnamed token). - Detection returns `{ structDefId, receiverForm }`. `receiverForm: 'pointer'` means the value type does NOT implement and only `*T` does — the fact `var x I = T{}` turns on. - The form rides in the edge `reason` (`-structural-implements-pointer`). Relationships carry no arbitrary properties, so a new field would change the relation DDL, move SCHEMA_FINGERPRINT and force a rebuild for a fact a string already expresses. Value-form implementors keep the ORIGINAL unsuffixed reason, so a consumer matching the old string now sees exactly the assignable set — which is what that string always claimed to mean. - `emitInterfaceDispatchFor` walks the SUBTYPE CLOSURE (IMPLEMENTS + EXTENDS) and skips bodiless declarations, instead of stopping at depth 1. Two reproduced Java shapes emitted an edge to a second abstract declaration while the only class with a body got nothing: a sub-interface that re-declares the method, and an abstract base between interface and implementation. Both now reach the implementation and neither emits the declaration edge. - The fan-out is bounded by `MAX_INTERFACE_DISPATCH_FANOUT` (32, `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT`) and reports what it dropped, mirroring `MAX_PROPERTY_DISPATCH_FANOUT`. A bare cap would silently discard valid dispatch targets, which is the same false-safe silence this issue is about. - Corrects a rationale comment that was factually wrong about the code 70 lines above it (Case 0 DOES branch on `site.kind`, at :713-716; what it lacks is a read/write branch in its reason/confidence computation). - Updates both copies of the case-ladder contract, which still described the fan-out as Case-4-exclusive. The embed-pointer marker is PARSE-TIME capture emission, so a warm cache would replay the pre-marker capture set and the distinction would never appear — silently, the v27/v30 failure mode. 43 and not 40 because origin/main allocated 40, 41 and 42 while this branch was in review, which is exactly the window this file's history records both prior EXACT clashes landing in. Pin moved with it. RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGE. - Go unit: 93 passed, including new rows pinning that `populateGoOwners` stamps `goReceiverKind` (previously the field had no reader and could rot silently) and that a pointer-receiver-only type implements in POINTER form only. - Cross-language sweep, test/integration/resolvers: 3034 passed, 1 skipped, 52 files, zero regressions. - scope-capture bench: PASS (15 languages). Go is the ONLY fingerprint that moved, which is the check that this is a Go capture change and not a cross-language regression; rebaselined with rationale. - Also closes review gaps in this PR's own tests: the concrete-field control was vacuous with respect to the type gate (repointed at a struct that IS an implementor), the two-service-file row could not distinguish the two files it is named for (both ends now file-qualified), plus new rows for signature mismatch, emitted confidence, and an exact N-by-M fan-out bound. An existing index must be re-analyzed; the schema bump forces it. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) --- .../src/scope-resolution/reference-site.ts | 24 ++ gitnexus/bench/scope-capture/baselines.json | 6 +- .../core/ingestion/languages/go/captures.ts | 34 ++- .../ingestion/languages/go/interface-impls.ts | 265 ++++++++++++++---- .../core/ingestion/languages/go/interpret.ts | 8 +- .../src/core/ingestion/scope-extractor.ts | 8 + .../contract/scope-resolver.ts | 25 +- .../passes/receiver-bound-calls.ts | 220 +++++++++++++-- .../scope-resolution/pipeline/run.ts | 45 ++- gitnexus/src/storage/parse-cache.ts | 19 +- .../go-captures-golden/expected-captures.json | 34 ++- .../go-interface-field-dispatch/go.mod | 3 + .../internal/handlers/orders.go | 13 + .../internal/handlers/picking.go | 15 + .../internal/repository/audit_repo.go | 12 + .../internal/repository/interfaces.go | 18 ++ .../internal/repository/mock_repo.go | 23 ++ .../internal/repository/order_repo.go | 11 + .../internal/services/pick_service.go | 21 ++ .../internal/services/wave_service.go | 28 ++ .../test/integration/resolvers/go.test.ts | 245 +++++++++++++++- .../test/unit/incremental-parse-cache.test.ts | 6 +- .../unit/scope-resolution/go/go-hooks.test.ts | 102 ++++++- 23 files changed, 1068 insertions(+), 117 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/handlers/orders.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/handlers/picking.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/audit_repo.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/interfaces.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/mock_repo.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/order_repo.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/services/pick_service.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/services/wave_service.go diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index e8a58e1b2..6629dacd3 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -162,6 +162,30 @@ export interface ReferenceSite { * in callee position, so nothing changes for languages that never set it. */ readonly inCalleePosition?: boolean; + /** + * This `inherits` site describes an embedded field written as a POINTER + * (`struct S { *T }`) rather than as a value (`struct S { T }`). + * + * Go's method-set rules make the two forms genuinely different, so the + * distinction cannot be normalized away without producing wrong answers + * (go.dev/ref/spec#Struct_types): + * + * - `S` embeds `T` → `MS(S)` and `MS(*S)` get promoted methods with + * receiver `T`; only `MS(*S)` also gets those with + * receiver `*T`. + * - `S` embeds `*T` → `MS(S)` AND `MS(*S)` get promoted methods with + * receiver `T` **or** `*T`. + * + * So with `func (t *T) Ping()`, `S{T}` does not implement a `Ping` interface + * by value while `S{*T}` does. Collapsing the forms makes both answers the + * same, and one of them is then wrong. + * + * A POSITION FACT, like `inCalleePosition`: the capture layer records how the + * field was spelled and resolution decides what it means. Set only by + * languages with pointer-embedding semantics (Go today); absent everywhere + * else, so every other language's sites stay byte-identical. + */ + readonly embeddedAsPointer?: boolean; } /** diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 4860470f1..58b449287 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,7 +1,7 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).", "go": { - "fingerprint": "e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3", + "fingerprint": "c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.", @@ -11,10 +11,12 @@ "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.", "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.", "_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites \u2014 the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected \u2014 go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.", - "_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103." + "_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.", + "_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303." }, "cobol": { "fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa", + "_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness — Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: COBOL procedure-pointer callable flow facts; multi-topic extraction now consumes each grouped scope/declaration match once instead of requiring a duplicate declaration-only match. Prior 68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1 -> d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e; scaling 0.853 < 1.5.", "_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959.", diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts index baff677af..c1a8cf23c 100644 --- a/gitnexus/src/core/ingestion/languages/go/captures.ts +++ b/gitnexus/src/core/ingestion/languages/go/captures.ts @@ -41,6 +41,21 @@ const CALLEE_POSITION_MARKER: Capture = Object.freeze({ text: '', }); +/** + * Presence-only marker for an embedded field written as `*T` rather than `T`. + * + * Go's method-set rules treat the two forms differently — `S{T}` promotes only + * the value-receiver methods of `T` into `MS(S)`, while `S{*T}` promotes both + * value- and pointer-receiver ones (go.dev/ref/spec#Struct_types). Structural + * interface detection cannot give an exact answer without knowing which was + * written, so the spelling is recorded here and interpreted downstream. + */ +const EMBEDDED_POINTER_MARKER: Capture = Object.freeze({ + name: '@reference.embedded-pointer', + range: ZERO_RANGE, + text: '', +}); + const GO_CALLABLE_CAPTURE_OPTIONS = { functionNodeTypes: new Set(['function_declaration', 'method_declaration', 'func_literal']), callNodeTypes: new Set(['call_expression']), @@ -358,7 +373,12 @@ function synthesizeGoInheritanceReferences(root: SyntaxNode): CaptureMatch[] { if (field.childForFieldName('name') !== null) continue; // `field.type` is the embedded base — bare/qualified/generic, with any // `*` pointer marker as an unnamed sibling token (already unwrapped). - emitGoEmbedInheritance(field.childForFieldName('type'), out); + // That token is the ONLY record of `*T` versus `T`, and the two have + // different method sets, so read it off the field text before it is + // lost. An embedded field has no name, so a leading `*` can only be + // the pointer marker. + const embeddedAsPointer = field.text.trimStart().startsWith('*'); + emitGoEmbedInheritance(field.childForFieldName('type'), out, embeddedAsPointer); } } else if (typeNode.type === 'interface_type') { for (const elem of typeNode.namedChildren) { @@ -367,7 +387,8 @@ function synthesizeGoInheritanceReferences(root: SyntaxNode): CaptureMatch[] { // `type_elem` with >1 named child — skip them (legacy // `shouldSkipExtends`); a single-element `type_elem` is the embed. if (elem.type !== 'type_elem' || elem.namedChildCount !== 1) continue; - emitGoEmbedInheritance(elem.namedChild(0), out); + // An interface embed is never a pointer form. + emitGoEmbedInheritance(elem.namedChild(0), out, false); } } } @@ -380,13 +401,20 @@ function synthesizeGoInheritanceReferences(root: SyntaxNode): CaptureMatch[] { * node, reducing the name to its bare simple identifier. No-ops when `baseNode` * is null or not one of the embed shapes. */ -function emitGoEmbedInheritance(baseNode: SyntaxNode | null, out: CaptureMatch[]): void { +function emitGoEmbedInheritance( + baseNode: SyntaxNode | null, + out: CaptureMatch[], + embeddedAsPointer: boolean, +): void { if (baseNode === null) return; const nameNode = goEmbedBaseNameNode(baseNode); if (nameNode === null) return; out.push({ '@reference.inherits': nodeToCapture('@reference.inherits', baseNode), '@reference.name': nodeToCapture('@reference.name', nameNode), + // Added ONLY for the pointer form, so a value embed's capture set is + // byte-identical to what it was before this marker existed. + ...(embeddedAsPointer ? { '@reference.embedded-pointer': EMBEDDED_POINTER_MARKER } : {}), }); } diff --git a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts index 55b3186a3..d684340d3 100644 --- a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts +++ b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts @@ -12,7 +12,22 @@ type MethodSetEntry = { readonly ambiguous: boolean; }; type MutableMethodSetEntries = Map; -type GoMethodDefinition = SymbolDefinition & { readonly goReceiverKind?: 'value' | 'pointer' }; +/** A struct embedded in another, plus HOW it was embedded (`T` vs `*T`). */ +type EmbeddedParent = { readonly structId: string; readonly asPointer: boolean }; +/** + * The two method sets Go defines for a defined type, kept separately because + * they answer different questions and only one of them is assignability. + * `pointer` = MS(*T) — every method callable on a *T value. + * `value` = MS(T) — the subset callable on a T value. + */ +type DualMethodSet = { readonly value: MutableMethodSet; readonly pointer: MutableMethodSet }; +/** Which method set satisfied an interface. `value` implies pointer too. */ +export type GoReceiverForm = 'value' | 'pointer'; +/** One structural implementor plus the form in which it implements. */ +export type GoStructuralImplementor = { + readonly structDefId: string; + readonly receiverForm: GoReceiverForm; +}; type SignatureContext = { readonly packageQualifier: string | undefined; readonly importQualifiers: ReadonlyMap; @@ -25,7 +40,8 @@ type DetectionIndexes = { readonly interfaceById: ReadonlyMap; readonly interfaceOwnMethodsById: ReadonlyMap; readonly embeddedSitesByInterfaceId: ReadonlyMap; - readonly parentStructIdsByStructId: ReadonlyMap; + readonly parentStructIdsByStructId: ReadonlyMap; + readonly valueMethodsByStructId: ReadonlyMap; readonly structIdsByMethodName: ReadonlyMap>; readonly signatureContextByDefId: ReadonlyMap; readonly scopeIndexes: ScopeResolutionIndexes; @@ -35,7 +51,7 @@ export function detectGoInterfaceImplementations( parsedFiles: readonly ParsedFile[], _indexes: ScopeResolutionIndexes, _model: SemanticModel, -): Map { +): Map { return detectGoInterfaceImplementationsFromIndexes(buildDetectionIndexes(parsedFiles, _indexes)); } @@ -50,7 +66,8 @@ function buildDetectionIndexes( const interfaceById = new Map(); const interfaceOwnMethodsById = new Map(); const embeddedSitesByInterfaceId = new Map(); - const parentStructIdsByStructId = new Map(); + const parentStructIdsByStructId = new Map(); + const valueMethodsByStructId = new Map(); const structIdsByMethodName = new Map>(); const signatureContextByDefId = new Map(); const interfaceIdByScopeId = new Map(); @@ -71,11 +88,44 @@ function buildDetectionIndexes( } if (def.type !== 'Method' && def.type !== 'Function') continue; if (def.ownerId === undefined) continue; - if (isPointerReceiverMethod(def)) continue; - const methodName = simpleQualifiedName(def); - if (methodName === undefined || methodName.length === 0) continue; + // POINTER-receiver methods count toward the method set (#2813). + // + // Go's rule is per-type, and there are two types here: the method set of + // `T` holds only its value-receiver methods, while the method set of `*T` + // holds BOTH. #1966 kept only the value-receiver half, which makes the + // `T` answer exactly right — and the `*T` answer permanently empty, so a + // struct whose methods all read `func (r *T)` satisfied nothing and got + // no IMPLEMENTS edge at all. + // + // That is the shape idiomatic Go actually writes: methods take pointer + // receivers so they can mutate, and `*T` is what gets stored in an + // interface-typed field. Excluding it did not make the graph + // conservative, it made it silent — every call through such a field + // resolved to the interface DECLARATION and `impact()` on the + // implementation reported zero callers, indistinguishable from a symbol + // that genuinely has none. + // + // GitNexus models one Struct node per type with no separate `*T` node, so + // the two method sets cannot both be represented. This picks the `*T` + // reading: the graph now answers "which types provide this interface's + // behaviour", and no longer proves `var x I = T{}` invalid. That trade is + // deliberate — blast radius is what every consumer of IMPLEMENTS asks for + // (verified: MRO/METHOD_IMPLEMENTS derivation, community clustering, the + // receiver-dispatch fan-out index, and the epistemic heritage probe; none + // performs value-assignability checking). + // + // `goReceiverKind` is still stamped in method-owners.ts and is the hook a + // future value/pointer-aware model would read; it is deliberately no + // longer a filter here. + const simpleName = simpleQualifiedName(def); + if (simpleName === undefined || simpleName.length === 0) continue; - addMethod(methodsByOwner, def.ownerId, methodName, def); + addMethod( + methodsByOwner, + def.ownerId, + methodSetKey(simpleName, def, signatureContextByDefId), + def, + ); } } @@ -104,9 +154,11 @@ function buildDetectionIndexes( for (const childScope of childScopesByParent.get(scope.id) ?? []) { for (const def of childScope.ownedDefs) { if (def.type !== 'Method' && def.type !== 'Function') continue; - const methodName = simpleQualifiedName(def); - if (methodName === undefined || methodName.length === 0) continue; - addMethodOverload(methods, methodName, def); + const simpleName = simpleQualifiedName(def); + if (simpleName === undefined || simpleName.length === 0) continue; + // Same key function as the struct side — an interface requiring an + // unexported `seal()` must only be satisfied from its own package. + addMethodOverload(methods, methodSetKey(simpleName, def, signatureContextByDefId), def); } } interfaceOwnMethodsById.set(ifaceId, methods); @@ -126,13 +178,20 @@ function buildDetectionIndexes( if (structId === undefined) continue; const parent = resolveInheritanceBaseInScope(site.inScope, site.name, indexes); if (parent === undefined || parent.type !== 'Struct') continue; - addParentStruct(parentStructIdsByStructId, structId, parent.nodeId); + // `embeddedAsPointer` is the capture-layer record of `*T` vs `T`; the two + // promote different method sets (go.dev/ref/spec#Struct_types). + addParentStruct( + parentStructIdsByStructId, + structId, + parent.nodeId, + site.embeddedAsPointer === true, + ); } } - const structMethodSetCache = new Map(); + const structMethodSetCache = new Map(); for (const structId of structsById.keys()) { - const effective = collectStructMethodSet( + const dual = collectStructMethodSet( structId, { parentStructIdsByStructId, @@ -141,9 +200,12 @@ function buildDetectionIndexes( new Set(), structMethodSetCache, ); - if (effective === undefined) continue; - effectiveMethodsByStructId.set(structId, effective); - for (const methodName of effective.keys()) { + if (dual === undefined) continue; + // `pointer` is MS(*T) and is the superset, so it drives candidate lookup: + // a type that implements only in pointer form is still an implementor. + effectiveMethodsByStructId.set(structId, dual.pointer); + valueMethodsByStructId.set(structId, dual.value); + for (const methodName of dual.pointer.keys()) { addStructMethodCandidate(structIdsByMethodName, methodName, structId); } } @@ -158,6 +220,7 @@ function buildDetectionIndexes( embeddedSitesByInterfaceId, parentStructIdsByStructId, structIdsByMethodName, + valueMethodsByStructId, signatureContextByDefId, scopeIndexes: indexes, }; @@ -165,21 +228,31 @@ function buildDetectionIndexes( function detectGoInterfaceImplementationsFromIndexes( indexes: DetectionIndexes, -): Map { - const implementations = new Map(); +): Map { + const implementations = new Map(); const methodSetCache = new Map(); for (const iface of indexes.interfaces) { const required = collectInterfaceMethodSet(iface, indexes, new Set(), methodSetCache); if (required === undefined || required.size === 0) continue; if (!methodSetHasVerifiableSignatures(required)) continue; - const implementors: string[] = []; + const implementors: GoStructuralImplementor[] = []; for (const structId of candidateStructIdsFor(required, indexes)) { - const actual = indexes.effectiveMethodsByStructId.get(structId); - if (actual === undefined) continue; - if (methodSetSatisfies(actual, required, indexes.signatureContextByDefId)) { - implementors.push(structId); - } + const pointerSet = indexes.effectiveMethodsByStructId.get(structId); + if (pointerSet === undefined) continue; + // MS(*T) is the superset: if it does not satisfy, neither does MS(T). + if (!methodSetSatisfies(pointerSet, required, indexes.signatureContextByDefId)) continue; + // Then ask the narrower question separately — does the VALUE type satisfy? + // This is the distinction `var x I = T{}` turns on, and it is a fact about + // the program, not a heuristic. + const valueSet = indexes.valueMethodsByStructId.get(structId); + const satisfiesByValue = + valueSet !== undefined && + methodSetSatisfies(valueSet, required, indexes.signatureContextByDefId); + implementors.push({ + structDefId: structId, + receiverForm: satisfiesByValue ? 'value' : 'pointer', + }); } if (implementors.length > 0) implementations.set(iface.nodeId, implementors); } @@ -187,6 +260,46 @@ function detectGoInterfaceImplementationsFromIndexes( return implementations; } +/** + * The key a method occupies in a method set. + * + * Go spec, Uniqueness of identifiers: "Two identifiers are different if they are + * spelled differently, **or if they appear in different packages and are not + * exported**." So an UNEXPORTED method name is scoped to its declaring package — + * `seal` in package `sealed` is a different identifier from `seal` in package + * `foreign`, and a type outside `sealed` can never satisfy `interface { seal() }`. + * That is the whole basis of the sealed-interface idiom. + * + * Matching on the bare name made those types satisfy each other, which is a + * FALSE implementation rather than an over-approximation. Qualifying the key + * with the declaring package for unexported names makes the comparison exact. + * + * Exported names are deliberately left unqualified: the spec makes them the same + * identifier across packages, which is what allows cross-package interface + * satisfaction to work at all. + */ +function methodSetKey( + simpleName: string, + def: SymbolDefinition, + signatureContextByDefId: ReadonlyMap, +): string { + if (isExportedGoIdentifier(simpleName)) return simpleName; + const pkg = signatureContextByDefId.get(def.nodeId)?.packageQualifier; + return pkg === undefined ? simpleName : `${pkg}\u0000${simpleName}`; +} + +/** + * Go spec, Exported identifiers: exported iff the first character is a Unicode + * uppercase letter (category Lu). Method names always satisfy the second clause + * (they are method names), so the first character is the whole test. + */ +function isExportedGoIdentifier(name: string): boolean { + const first = name.codePointAt(0); + if (first === undefined) return false; + const ch = String.fromCodePoint(first); + return ch !== ch.toLowerCase() && ch === ch.toUpperCase(); +} + function addMethod( methodsByOwner: Map>, ownerId: string, @@ -222,59 +335,115 @@ function addStructMethodCandidate( } function addParentStruct( - parentStructIdsByStructId: Map, + parentStructIdsByStructId: Map, structId: string, parentStructId: string, + asPointer: boolean, ): void { const parents = parentStructIdsByStructId.get(structId) ?? []; - parents.push(parentStructId); + parents.push({ structId: parentStructId, asPointer }); parentStructIdsByStructId.set(structId, parents); } +/** The two entry maps built in parallel: MS(T) and MS(*T). */ +type DualEntries = { + readonly value: MutableMethodSetEntries; + readonly pointer: MutableMethodSetEntries; +}; + function collectStructMethodSet( structId: string, indexes: Pick, visiting: Set, - cache: Map, -): MutableMethodSet | undefined { + cache: Map, +): DualMethodSet | undefined { const entries = collectStructMethodEntries(structId, indexes, visiting, cache); - return entries === undefined ? undefined : methodEntriesToMethodSet(entries); + if (entries === undefined) return undefined; + return { + value: methodEntriesToMethodSet(entries.value), + pointer: methodEntriesToMethodSet(entries.pointer), + }; } +/** + * Build MS(T) and MS(*T) together, applying the spec's promotion table exactly + * (go.dev/ref/spec#Method_sets, #Struct_types): + * + * declared receiver T -> in MS(T) and MS(*T) + * declared receiver *T -> in MS(*T) only + * S embeds T (value) -> MS(S) gets P's MS(T); MS(*S) gets P's MS(*T) + * S embeds *T (pointer) -> MS(S) AND MS(*S) both get P's MS(*T) + * + * The last row is the one that makes the embed FORM load-bearing: with + * `func (b *Base) Ping()`, `struct{ Base }` does not implement a `Ping` + * interface by value while `struct{ *Base }` does. Collapsing the forms gives + * both the same answer and one of them is then wrong. + */ function collectStructMethodEntries( structId: string, indexes: Pick, visiting: Set, - cache: Map, -): MutableMethodSetEntries | undefined { + cache: Map, +): DualEntries | undefined { const cached = cache.get(structId); - if (cached !== undefined) return cloneMethodEntries(cached); + if (cached !== undefined) { + return { value: cloneMethodEntries(cached.value), pointer: cloneMethodEntries(cached.pointer) }; + } if (visiting.has(structId)) return undefined; visiting.add(structId); - const merged = directMethodEntries(indexes.methodsByOwner.get(structId)); + const own = indexes.methodsByOwner.get(structId); + // MS(*T) holds every declared method; MS(T) drops the pointer-receiver ones. + const pointer = directMethodEntries(own); + const value = directMethodEntries(filterValueReceiverMethods(own)); - for (const parentStructId of indexes.parentStructIdsByStructId.get(structId) ?? []) { - const parentEntries = collectStructMethodEntries(parentStructId, indexes, visiting, cache); + for (const parent of indexes.parentStructIdsByStructId.get(structId) ?? []) { + const parentEntries = collectStructMethodEntries(parent.structId, indexes, visiting, cache); if (parentEntries === undefined) { visiting.delete(structId); return undefined; } - for (const [methodName, entry] of parentEntries) { - if (entry.ambiguous) continue; - mergePromotedMethodEntry(merged, methodName, { - overloads: entry.overloads, - depth: entry.depth + 1, - ambiguous: false, - }); - } + // Embedding by POINTER lifts the parent's pointer-receiver methods into the + // embedder's VALUE method set; embedding by value does not. + const promotedIntoValue = parent.asPointer ? parentEntries.pointer : parentEntries.value; + promoteEntries(value, promotedIntoValue); + promoteEntries(pointer, parentEntries.pointer); } visiting.delete(structId); - cache.set(structId, cloneMethodEntries(merged)); - return merged; + cache.set(structId, { value: cloneMethodEntries(value), pointer: cloneMethodEntries(pointer) }); + return { value, pointer }; } +/** Merge one depth-level of promoted entries, preserving the shallowest-depth + * and ambiguity rules the selector spec defines. */ +function promoteEntries(target: MutableMethodSetEntries, source: MutableMethodSetEntries): void { + for (const [methodName, entry] of source) { + if (entry.ambiguous) continue; + mergePromotedMethodEntry(target, methodName, { + overloads: entry.overloads, + depth: entry.depth + 1, + ambiguous: false, + }); + } +} + +/** MS(T) excludes methods declared with a `*T` receiver. */ +function filterValueReceiverMethods(methods: MethodSet | undefined): MutableMethodSet | undefined { + if (methods === undefined) return undefined; + const out = new Map(); + for (const [name, overloads] of methods) { + const valueOnly = overloads.filter( + (def) => (def as GoMethodDefinition).goReceiverKind !== 'pointer', + ); + if (valueOnly.length > 0) out.set(name, valueOnly); + } + return out; +} + +/** Receiver-kind sidecar stamped by `populateGoOwners` (method-owners.ts). */ +type GoMethodDefinition = SymbolDefinition & { readonly goReceiverKind?: 'value' | 'pointer' }; + function collectInterfaceMethodSet( iface: SymbolDefinition, indexes: DetectionIndexes, @@ -475,10 +644,6 @@ function methodSetHasVerifiableSignatures(methods: MethodSet): boolean { return true; } -function isPointerReceiverMethod(def: SymbolDefinition): boolean { - return (def as GoMethodDefinition).goReceiverKind === 'pointer'; -} - function hasVerifiableSignature(def: SymbolDefinition): boolean { return ( def.parameterCount !== undefined || diff --git a/gitnexus/src/core/ingestion/languages/go/interpret.ts b/gitnexus/src/core/ingestion/languages/go/interpret.ts index d3a9d6113..cb9fcc047 100644 --- a/gitnexus/src/core/ingestion/languages/go/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/go/interpret.ts @@ -29,8 +29,12 @@ export function interpretGoTypeBinding(captures: CaptureMatch): ParsedTypeBindin if (captures['@type-binding.self'] !== undefined) { source = 'self'; // Preserve pointer shape on receiver self-bindings (`*T` vs `T`). - // Method-owner enrichment consumes that raw shape to model Go value and - // pointer receiver method sets conservatively. + // Method-owner enrichment consumes that raw shape to stamp `goReceiverKind` + // on each method. Since #2813 that stamp is metadata, NOT a filter: + // structural interface satisfaction counts pointer-receiver methods, because + // `*T`'s method set is what an interface-typed field actually holds. The + // preserved distinction is the hook a future value/pointer-aware model would + // read — do not reintroduce it as an exclusion. normalizedType = type.trim(); } else if (captures['@type-binding.constructor'] !== undefined) { source = 'constructor-inferred'; diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 231d37c97..63a4058fc 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -1149,6 +1149,12 @@ function pass5CollectReferences( // languages whose read pattern has no call-position exclusion; absent // everywhere else, so the site stays byte-identical for them. const inCalleePosition = match['@reference.callee-position'] !== undefined; + // Pointer-embedding marker: `struct S { *T }` rather than `struct S { T }`. + // Recorded, not acted on — Go's method-set rules make the two forms differ + // (see `ReferenceSite.embeddedAsPointer`), and only structural interface + // detection knows what to do with that. Absent for every language without + // pointer embedding, so their sites stay byte-identical. + const embeddedAsPointer = match['@reference.embedded-pointer'] !== undefined; const site: ReferenceSite = { name: nameCap.text, @@ -1168,6 +1174,7 @@ function pass5CollectReferences( ...(argumentTypeClasses !== undefined ? { argumentTypeClasses } : {}), ...(receiverChain !== undefined ? { receiverChain } : {}), ...(inCalleePosition ? { inCalleePosition: true } : {}), + ...(embeddedAsPointer ? { embeddedAsPointer: true } : {}), }; referenceSites.push(site); } @@ -1569,6 +1576,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@reference.qualified-name', '@reference.property-key', '@reference.callee-position', + '@reference.embedded-pointer', '@reference.receiver', '@reference.operator', '@reference.arity', diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index aa0641367..089d98364 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -113,8 +113,9 @@ * 3. Case 0.5 implicit-`this` chain walk — GATED: fires only for * languages that set `resolveThisViaEnclosingClass === true`; * it intercepts every bare-`this` call/read/write site ahead of - * Case 4 and does NOT emit Case 4's interface-dispatch fan-out, - * so enabling the toggle for a language changes that language's + * Case 4 and does NOT emit the interface-dispatch fan-out that + * Cases 0 and 4 both perform (Case 0 gained it in #2829), so + * enabling the toggle for a language changes that language's * `this` dispatch semantics (see the toggle's doc below) * 4. Case 1 namespace-receiver * 5. Case 2 class-name receiver @@ -319,6 +320,13 @@ export type ElementAccessRoute = | { readonly kind: 'index' } | { readonly kind: 'accessor'; readonly name: string }; +/** One structurally-detected implementor plus the receiver form in which it + * satisfies the interface (see `detectInterfaceImplementations`). */ +export interface StructuralImplementor { + readonly structDefId: string; + readonly receiverForm: 'value' | 'pointer'; +} + export interface ScopeResolver { /** Identity for telemetry + per-language flag check. */ readonly language: SupportedLanguages; @@ -1230,14 +1238,23 @@ export interface ScopeResolver { * Languages like Go use structural typing — a struct satisfies an * interface if its method set is a superset, without an explicit * `implements` keyword. Runs after finalize, before resolution passes. - * Returns: Map. + * Returns: Map, where each entry + * names the implementing type AND the form in which it implements. + * + * `receiverForm` is not a confidence signal — it is the language's own + * distinction. In Go the method set of `T` and of `*T` differ (a + * pointer-receiver method belongs only to `*T`), so `receiverForm: 'pointer'` + * means the VALUE type does not implement the interface and only `*T` does. + * `'value'` means both do. Consumers that only want blast radius can ignore + * it; consumers reasoning about assignability must not. + * * Default: undefined (no structural interface detection). */ readonly detectInterfaceImplementations?: ( parsedFiles: readonly ParsedFile[], indexes: ScopeResolutionIndexes, model: SemanticModel, - ) => Map; + ) => Map; /** * Optional: mirror typeBindings from namespace-import target modules diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index e75aa0045..1202687a4 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -8,14 +8,18 @@ * * 1. **super branch** — `provider.isSuperReceiver(receiverName)` → * MRO walk skipping self - * 2. **Case 0 (compound)** — receiver has `.` or `(` → compound resolver + * 2. **Case 0 (compound)** — receiver has `.` or `(` → compound resolver. + * Also emits the interface-dispatch fan-out when the folded receiver type + * is an Interface (#2829) — see Case 4, which does the same. * 3. **Case 0.5 (implicit `this` receiver)** — GATED: fires only when * the language sets `resolveThisViaEnclosingClass === true` AND the * receiver is literally `this` → enclosing-class + MRO chain walk * with C++ member-name-hiding semantics. Languages that leave the * toggle unset skip this case entirely; their `this` sites fall * through to Case 4 via the synthesized `this` typeBinding (which - * also emits interface-dispatch fan-out that this case does not). + * emits the interface-dispatch fan-out that this case does not — + * as does Case 0 since #2829; Case 0.5 remains the only resolving + * case without it). * 4. **Case 1 (namespace)** — receiver in `namespaceTargets` → exported def * 5. **Case 2 (class-name / static receiver)** — receiver resolves to a * class-like binding (Class/Interface/Struct/Record/Enum/Trait) → MRO @@ -283,6 +287,38 @@ export function classifyReceiverOrigin( return 'unknown'; } +/** + * Upper bound on how many implementors ONE interface member may fan out to at a + * single call site (#2829). + * + * Mirrors `MAX_PROPERTY_DISPATCH_FANOUT` in `property-dispatch.ts`, deliberately + * including its reporting half: a bare cap would silently discard valid dispatch + * targets, which is the same false-safe silence #2813 was filed about. The + * default matches that sibling's 32 — the fan-out is a per-call-site product, so + * an interface with hundreds of implementors (mock proliferation is the usual + * cause) multiplies the graph without adding information a reader can act on. + * + * Override with `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT` for a repo with + * legitimately high implementor counts. + */ +export const MAX_INTERFACE_DISPATCH_FANOUT = (() => { + const env = Number(process.env.GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT); + return Number.isInteger(env) && env >= 1 ? env : 32; +})(); + +/** Bound on the sample of over-cap interface members kept for the warning. */ +const MAX_REPORTED_SKIPPED_INTERFACES = 20; + +/** What `emitReceiverBoundCalls` reports back to the orchestrator. */ +export interface ReceiverBoundResult { + /** CALLS/ACCESSES edges emitted by this pass. */ + readonly emitted: number; + /** Dispatch targets DROPPED because a member exceeded the fan-out cap. */ + readonly dispatchFanoutSkipped: number; + /** Bounded sample naming which interface members lost targets. */ + readonly dispatchFanoutSkippedNames: readonly string[]; +} + export function emitReceiverBoundCalls( graph: KnowledgeGraph, scopes: ScopeResolutionIndexes, @@ -308,8 +344,10 @@ export function emitReceiverBoundCalls( * edge. */ readonly isBuiltInName?: (name: string) => boolean; } = {}, -): number { +): ReceiverBoundResult { let emitted = 0; + let dispatchFanoutSkipped = 0; + const dispatchFanoutSkippedNames: string[] = []; // Per-pass dedup so the multiple cases don't double-emit if two of // them resolve the same site to the same target. NEVER pre-seed // from the reference index — see Contract Invariant I5. @@ -345,23 +383,73 @@ export function emitReceiverBoundCalls( if (graphId !== undefined) graphIdToClassDef.set(graphId, def); } } - const implementorsByInterfaceDefId = new Map(); - for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { - const ifaceDef = graphIdToClassDef.get(rel.targetId); - const implDef = graphIdToClassDef.get(rel.sourceId); - if (ifaceDef === undefined || implDef === undefined) continue; - let list = implementorsByInterfaceDefId.get(ifaceDef.nodeId); + // Direct subtypes of a type, keyed by the SUPERtype's def id. + // + // Built from IMPLEMENTS **and** EXTENDS (#2829). IMPLEMENTS alone is not the + // set of implementations: `preEmitInheritanceEdges` classifies heritage by the + // TARGET's kind, so `interface B extends A` is stored as `B IMPLEMENTS A` and + // an INTERFACE lands in A's list; and a concrete class reaches its interface + // through `class C extends AbstractBase` (EXTENDS) + `AbstractBase implements + // I` (IMPLEMENTS), so it is two hops away and invisible to a depth-1 walk. + // Both shapes previously ended the fan-out on a bodiless declaration while the + // only executable target got no edge at all. + const subtypesBySupertypeDefId = new Map(); + const addSubtype = (superId: string, sub: SymbolDefinition): void => { + let list = subtypesBySupertypeDefId.get(superId); if (list === undefined) { list = []; - implementorsByInterfaceDefId.set(ifaceDef.nodeId, list); + subtypesBySupertypeDefId.set(superId, list); + } + list.push(sub); + }; + for (const relType of ['IMPLEMENTS', 'EXTENDS'] as const) { + for (const rel of graph.iterRelationshipsByType(relType)) { + const superDef = graphIdToClassDef.get(rel.targetId); + const subDef = graphIdToClassDef.get(rel.sourceId); + if (superDef === undefined || subDef === undefined) continue; + addSubtype(superDef.nodeId, subDef); } - list.push(implDef); } - /** Emit secondary CALLS edges with reason='interface-dispatch' - * when the primary receiver-typed edge targeted an Interface's - * method. Each implementing class's same-named method gets a - * secondary edge (excluding the primary target itself). */ + /** + * Is this member a bodiless DECLARATION rather than an implementation? + * + * An interface method, and an `abstract` method on an abstract base, are + * both declarations: dispatching to them names something with no body while + * the executable target sits further down the hierarchy. `isAbstract` lives + * on the graph NODE (the structure phase sets it), not on `SymbolDefinition`, + * so this resolves the def to its node. A def that cannot be resolved is + * treated as NOT abstract — the fail-open direction, matching how the rest of + * this pass treats an unresolvable lookup. + */ + const isDeclarationOnly = (def: SymbolDefinition): boolean => { + const graphId = resolveDefGraphId(def.filePath, def, nodeLookup); + if (graphId === undefined) return false; + return graph.getNode(graphId)?.properties.isAbstract === true; + }; + + /** + * Emit secondary CALLS edges with reason='interface-dispatch' when the primary + * receiver-typed edge targeted an Interface's method. + * + * Walks the SUBTYPE CLOSURE of the interface rather than its direct + * implementors (#2829). Two shapes made the depth-1 walk both wrong and + * incomplete, each reproduced in plain Java: + * + * interface ReadCloser extends Reader { int read(String p); } // re-declares + * abstract class AbstractHandler implements Handler { public abstract void handle(String s); } + * + * In both, the depth-1 list contains a type whose `read`/`handle` is a bodiless + * declaration, so the fan-out emitted an edge to *that* — while the only class + * with a body (`FileRC`, `RealHandler`) is one hop further down and received + * nothing at all. Descending and skipping declarations fixes both directions + * at once: the wrong edge disappears and the real implementation gains one. + * + * Descent continues THROUGH a type that supplied a concrete member, because an + * override further down is an equally real runtime target — dispatch is an + * over-approximation by design, and stopping early would silently prefer the + * base. + */ const emitInterfaceDispatchFor = ( ownerDef: SymbolDefinition, memberName: string, @@ -371,19 +459,53 @@ export function emitReceiverBoundCalls( calleeCapture: CalleeIdCaptureCtx | undefined, ): number => { if (ownerDef.type !== 'Interface') return 0; - const impls = implementorsByInterfaceDefId.get(ownerDef.nodeId); - if (impls === undefined) return 0; - let n = 0; - for (const implDef of impls) { - const implMember = pickOverload(implDef.nodeId, memberName, site, model, provider); - if ( - implMember === undefined || - implMember === OVERLOAD_AMBIGUOUS || - implMember.isDeleted === true - ) { - continue; + if (subtypesBySupertypeDefId.get(ownerDef.nodeId) === undefined) return 0; + + // Collect concrete targets across the closure first, so the cap below counts + // real dispatch targets rather than types visited. + const targets: SymbolDefinition[] = []; + const seenTypes = new Set([ownerDef.nodeId]); + const queue: string[] = [ownerDef.nodeId]; + while (queue.length > 0) { + const superId = queue.shift() as string; + for (const subDef of subtypesBySupertypeDefId.get(superId) ?? []) { + if (seenTypes.has(subDef.nodeId)) continue; + seenTypes.add(subDef.nodeId); + queue.push(subDef.nodeId); + const implMember = pickOverload(subDef.nodeId, memberName, site, model, provider); + if ( + implMember === undefined || + implMember === OVERLOAD_AMBIGUOUS || + implMember.isDeleted === true + ) { + continue; + } + if (implMember.nodeId === primaryMemberDef.nodeId) continue; + // A re-declared interface method or an `abstract` override is not an + // implementation — keep descending past it rather than emitting to it. + if (isDeclarationOnly(implMember)) continue; + targets.push(implMember); } - if (implMember.nodeId === primaryMemberDef.nodeId) continue; + } + + // Bounded, and NEVER silently (#2829). An interface with a very large + // implementor set multiplies edges by every call site — Go, TypeScript and + // Kotlin do not set `collapseMemberCallsByCallerTarget`, so the product is + // per SITE. Truncating without saying so would recreate the false-safe + // silence this whole issue is about, which is why the sibling + // `MAX_PROPERTY_DISPATCH_FANOUT` reports its dropped keys too. + if (targets.length > MAX_INTERFACE_DISPATCH_FANOUT) { + dispatchFanoutSkipped += targets.length - MAX_INTERFACE_DISPATCH_FANOUT; + if (dispatchFanoutSkippedNames.length < MAX_REPORTED_SKIPPED_INTERFACES) { + dispatchFanoutSkippedNames.push( + `${ownerDef.qualifiedName ?? ownerDef.nodeId}.${memberName} (${targets.length} targets)`, + ); + } + targets.length = MAX_INTERFACE_DISPATCH_FANOUT; + } + + let n = 0; + for (const implMember of targets) { const ok = tryEmitEdge( graph, scopes, @@ -644,6 +766,48 @@ export function emitReceiverBoundCalls( calleeCapture, ); if (ok) emitted++; + // Interface dispatch, exactly as Case 4 does it (#2813). When the + // folded receiver type is an Interface, the primary edge above + // lands on the interface's own method DECLARATION; these secondary + // edges are what reach the implementations. + // + // Case 4 had this and Case 0 did not, which made the gap a property + // of receiver SYNTAX rather than of types: a struct-field receiver + // (`s.orderRepo`) contains a dot, so it always takes Case 0, while + // the same interface reached through a local or parameter is a bare + // name and reaches Case 4. Field-held interfaces — dependency + // injection, in other words — were the half that silently lost every + // implementation edge. + // + // `currentClass` is the receiver's own folded type, matching what + // Case 4 passes. `emitInterfaceDispatchFor` self-gates on + // `ownerDef.type !== 'Interface'`, so this is inert for every + // concrete receiver and needs no language check of its own — a + // member whose owner resolved to a Struct emits nothing extra. + // + // Confidence mirrors THIS case's own primary emit above — the 0.85 + // literal — so a site's dispatch edges never claim more certainty + // than the edge they hang off. + // + // Case 4 passes a site.kind-dependent value instead (1.0 for + // read/write, `:1399-1405`) because ITS primary varies the same way. + // Case 0 does branch on `site.kind` when picking the member + // (`:713-716`), it simply does not vary reason/confidence with it, + // so there is no 1.0 arm here to mirror. That leaves a read/write + // ACCESSES through a compound receiver at 0.85 while the same access + // through a bare name is 1.0 — a PRE-EXISTING difference between the + // two cases' primaries, not something this fan-out introduces. + // Deliberately not "fixed" here: changing Case 0's primary + // confidence is a separate behavioural change affecting every + // language, and is out of scope for #2813. + emitted += emitInterfaceDispatchFor( + currentClass, + memberName, + memberDef, + site, + 0.85, + calleeCapture, + ); // Always mark handled when the site was resolved, even // if the edge was deduplicated (collapse mode), so // `emitReferencesViaLookup` doesn't re-emit from the @@ -1541,7 +1705,7 @@ export function emitReceiverBoundCalls( } } - return emitted; + return { emitted, dispatchFanoutSkipped, dispatchFanoutSkippedNames }; } /** Resolve a member by name on a class def, narrowing by argument diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index e11ae7c9a..72f1b1168 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -66,7 +66,10 @@ import type { FunctionCfg } from '../../cfg/types.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js'; import { propagateImportedReturnTypes } from '../passes/imported-return-types.js'; -import { emitReceiverBoundCalls } from '../passes/receiver-bound-calls.js'; +import { + emitReceiverBoundCalls, + MAX_INTERFACE_DISPATCH_FANOUT, +} from '../passes/receiver-bound-calls.js'; import { emitFreeCallFallback } from '../passes/free-call-fallback.js'; import { emitPropertyDispatchCalls, @@ -244,8 +247,8 @@ function emitDetectedInterfaceImplementations( for (const [interfaceDefId, implementorDefIds] of detected) { const targetId = graphIdByDefId.get(interfaceDefId); if (targetId === undefined) continue; - for (const implementorDefId of implementorDefIds) { - const sourceId = graphIdByDefId.get(implementorDefId); + for (const implementor of implementorDefIds) { + const sourceId = graphIdByDefId.get(implementor.structDefId); if (sourceId === undefined) continue; const edgeKey = `${sourceId}->${targetId}`; if (existing.has(edgeKey)) continue; @@ -256,7 +259,17 @@ function emitDetectedInterfaceImplementations( targetId, type: 'IMPLEMENTS', confidence: 0.85, - reason: `${provider.language}-structural-implements`, + // The receiver form rides in `reason` because relationships carry no + // arbitrary properties — adding one would change the relation DDL and + // move SCHEMA_FINGERPRINT, forcing a full re-analyze for a fact that a + // string already expresses. `-pointer` means ONLY the pointer type + // implements: `var x I = T{}` is invalid, `var x I = &T{}` is fine. + // The unsuffixed form is unchanged from before, so a consumer matching + // the old string keeps seeing exactly the value-form implementors. + reason: + implementor.receiverForm === 'pointer' + ? `${provider.language}-structural-implements-pointer` + : `${provider.language}-structural-implements`, }); emitted++; } @@ -796,8 +809,12 @@ export function runScopeResolution( : (filePath, line, col) => callableArgumentSites.has(`${filePath}:${line}:${col}`), ) : undefined; - const receiverExtras = callableFlowOnly - ? 0 + const receiverBound = callableFlowOnly + ? { + emitted: 0, + dispatchFanoutSkipped: 0, + dispatchFanoutSkippedNames: [] as readonly string[], + } : emitReceiverBoundCalls( graph, indexes, @@ -816,6 +833,22 @@ export function runScopeResolution( isBuiltInName: provider.languageProvider.isBuiltInName, }, ); + const receiverExtras = receiverBound.emitted; + if (receiverBound.dispatchFanoutSkipped > 0) { + // Never drop dispatch coverage silently (#2829) — same contract as the + // property-dispatch cap below. An interface member over the cap loses real + // implementors, so `impact()` on those implementations under-reports; an + // operator has to be able to see WHICH member lost them. + logger.warn( + { + lang: provider.language, + dispatchFanoutSkipped: receiverBound.dispatchFanoutSkipped, + dispatchFanoutSkippedNames: receiverBound.dispatchFanoutSkippedNames, + fanoutCap: MAX_INTERFACE_DISPATCH_FANOUT, + }, + 'interface-dispatch: members over the fan-out cap dropped implementors (their CALLS edges were not emitted)', + ); + } const unresolvedReceiverExtras = !callableFlowOnly && provider.emitUnresolvedReceiverEdges !== undefined ? provider.emitUnresolvedReceiverEdges( diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 8b4880554..a5688b77f 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -205,7 +205,24 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // they replay the pre-fix capture set for byte-unchanged files and keep serving // the fabricated edge. main is at 39, so 40/41/42 are all this branch's. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. -const SCHEMA_BUMP = 42; +// v43: Go embedded fields now emit `@reference.embedded-pointer` when written +// as `*T` rather than `T` (#2813 exact method sets). Go's method-set rules make +// the two forms genuinely different — `struct{ Base }` does NOT get Base's +// pointer-receiver methods in its value method set while `struct{ *Base }` does +// — so structural interface satisfaction cannot be exact without the spelling. +// This is PARSE-TIME capture emission, so a warm cache replays the pre-fix +// capture set for byte-unchanged files and the distinction never appears: +// silently, with no error, the v27/v30 failure mode. `analyze` skips tree-sitter +// dispatch for unchanged chunks (GUARDRAILS.md), so a plain re-analyze does NOT +// surface it without this bump. +// +// 42 -> 43: this branch originally allocated 43 while sitting at 39, because +// main had already taken 40/41/42 for #2807. main has since merged those and +// this branch rebased onto it, so 43 remains the next free number and the +// value is unchanged by the rebase — the reason it was chosen is simply now +// visible in the history above. RE-CHECK AGAINST origin/main IMMEDIATELY +// BEFORE MERGING; this file records eight prior collisions, two EXACT. +const SCHEMA_BUMP = 43; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json index 0205468f6..3065bfa0d 100644 --- a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -127,6 +127,38 @@ "captureGroups": 23, "digest": "fd35d917bc7a039ef4bcb0a6501c31ffbea0fe39bf3604938191597415aa33f7" }, + "go-interface-field-dispatch/internal/handlers/orders.go": { + "captureGroups": 22, + "digest": "438b02c0990fa6c830767327d07de642f7aff6287e95217c3dfd7423a520942c" + }, + "go-interface-field-dispatch/internal/handlers/picking.go": { + "captureGroups": 28, + "digest": "0ad401ba19e7cf7deace88ea7973572cb6ee2a63a6006aa5cc06f55bac56e14b" + }, + "go-interface-field-dispatch/internal/repository/audit_repo.go": { + "captureGroups": 18, + "digest": "2710feb4bee288248e346d7ab0a32a8511b002d8c0fad4cd7db3588ee1926bc2" + }, + "go-interface-field-dispatch/internal/repository/interfaces.go": { + "captureGroups": 10, + "digest": "ee5b78fc03ea3283b2e06b9b8d0cf4999c2de091262571966d1d4670036c479d" + }, + "go-interface-field-dispatch/internal/repository/mock_repo.go": { + "captureGroups": 47, + "digest": "7b9219cc06e8c45fb08c304b71ebc58e1a9e052cb9c92fd01b9f22ab8328a91d" + }, + "go-interface-field-dispatch/internal/repository/order_repo.go": { + "captureGroups": 26, + "digest": "00e0f98586043ff84f96af92d9e6b3593018b6b37f92d183b5c05c6de745e2b0" + }, + "go-interface-field-dispatch/internal/services/pick_service.go": { + "captureGroups": 39, + "digest": "036d1a4b7304bbbf963f640710fd286702ed34809df2940411c14da8f9de9a6f" + }, + "go-interface-field-dispatch/internal/services/wave_service.go": { + "captureGroups": 46, + "digest": "62a3830311106e4a349c5b604e914531249aacc47001a888ecf5d38e2bc4c821" + }, "go-local-shadow/cmd/main.go": { "captureGroups": 14, "digest": "ce7220df98df08741f772150b5572bec0364d576741bc8c76647615fab859f92" @@ -285,7 +317,7 @@ }, "go-qualified-base/consumers/qualified.go": { "captureGroups": 14, - "digest": "01fe99cadcdf3ce6f00cafce8db0474ad152983f766c8dee9b9da42c9256ae2f" + "digest": "4680bb7a7c62a54a91d1d7b8ab3150dfb0c54b1683bee8cf7fc747afe99ca0f8" }, "go-receiver-method-free-call/example.go": { "captureGroups": 8, diff --git a/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/go.mod b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/go.mod new file mode 100644 index 000000000..02eafb037 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/go.mod @@ -0,0 +1,3 @@ +module github.com/example/wms + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/handlers/orders.go b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/handlers/orders.go new file mode 100644 index 000000000..de3e40fc5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/handlers/orders.go @@ -0,0 +1,13 @@ +package handlers + +import "github.com/example/wms/internal/repository" + +type OrderHandlers struct { + repo repository.OrderRepository + auditRepo *repository.AuditRepo +} + +func (h *OrderHandlers) Delete(id string) error { + h.auditRepo.LogAuditEventAsync("delete") + return h.repo.DeleteItem(id) +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/handlers/picking.go b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/handlers/picking.go new file mode 100644 index 000000000..f71db2ec0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/handlers/picking.go @@ -0,0 +1,15 @@ +package handlers + +import "github.com/example/wms/internal/repository" + +type PickHandlers struct { + repo repository.OrderRepository +} + +func (h *PickHandlers) Queue(id string) ([]string, error) { + return h.repo.GetPickQueue(id) +} + +func (h *PickHandlers) Unsplit(id string) error { + return h.repo.UnsplitOrder(id) +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/audit_repo.go b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/audit_repo.go new file mode 100644 index 000000000..ad115ff0a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/audit_repo.go @@ -0,0 +1,12 @@ +package repository + +// AuditRepo and CartRepo are the CONCRETE-field controls: calls through a +// concrete-typed field already resolved before #2813 and must keep resolving +// to the implementation, with no interface-dispatch fan-out. +type AuditRepo struct{} + +func (a *AuditRepo) LogAuditEventAsync(msg string) {} + +type CartRepo struct{} + +func (c *CartRepo) Get(id string) string { return "" } diff --git a/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/interfaces.go b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/interfaces.go new file mode 100644 index 000000000..bd3da43d4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/interfaces.go @@ -0,0 +1,18 @@ +package repository + +// OrderRepository is satisfied only by types whose methods use POINTER +// receivers (#2813). In Go the method set of *T includes both value- and +// pointer-receiver methods, so *OrderRepo implements this interface even +// though OrderRepo (the value type) does not. +type OrderRepository interface { + DeleteItem(id string) error + GetPickQueue(id string) ([]string, error) + UnsplitOrder(id string) error +} + +// PartialRepository is deliberately satisfied by NOTHING in this fixture — +// the negative control for structural detection. +type PartialRepository interface { + DeleteItem(id string) error + NeverImplemented(id string) error +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/mock_repo.go b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/mock_repo.go new file mode 100644 index 000000000..78261cdb8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/mock_repo.go @@ -0,0 +1,23 @@ +package repository + +// MockOrderRepo mirrors the mock proliferation of a real codebase: a second +// pointer-receiver implementor, so the fan-out must reach BOTH. +type MockOrderRepo struct{} + +func (m *MockOrderRepo) DeleteItem(id string) error { return nil } + +func (m *MockOrderRepo) GetPickQueue(id string) ([]string, error) { return nil, nil } + +func (m *MockOrderRepo) UnsplitOrder(id string) error { return nil } + +// WrongSigRepo has every method NAME the interface requires, at the same arity, +// but with an incompatible parameter type. It must NOT be detected as an +// implementor — signature comparison is the only guard left now that +// pointer-receiver methods count toward the method set (#2813). +type WrongSigRepo struct{} + +func (w *WrongSigRepo) DeleteItem(id int) error { return nil } + +func (w *WrongSigRepo) GetPickQueue(id int) ([]string, error) { return nil, nil } + +func (w *WrongSigRepo) UnsplitOrder(id int) error { return nil } diff --git a/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/order_repo.go b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/order_repo.go new file mode 100644 index 000000000..081f50c69 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/repository/order_repo.go @@ -0,0 +1,11 @@ +package repository + +type OrderRepo struct { + dsn string +} + +func (r *OrderRepo) DeleteItem(id string) error { return nil } + +func (r *OrderRepo) GetPickQueue(id string) ([]string, error) { return nil, nil } + +func (r *OrderRepo) UnsplitOrder(id string) error { return nil } diff --git a/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/services/pick_service.go b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/services/pick_service.go new file mode 100644 index 000000000..cbd910d1c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/services/pick_service.go @@ -0,0 +1,21 @@ +package services + +import "github.com/example/wms/internal/repository" + +// PickService holds BOTH an interface-typed field and concrete-typed fields, +// so one fixture covers the failing case and its control. +type PickService struct { + orderRepo repository.OrderRepository + cartRepo *repository.CartRepo + auditRepo *repository.AuditRepo +} + +func (s *PickService) GetPickQueue(id string) ([]string, error) { + s.auditRepo.LogAuditEventAsync("pick") + return s.orderRepo.GetPickQueue(id) +} + +func (s *PickService) StartSession(id string) error { + _ = s.cartRepo.Get(id) + return s.orderRepo.DeleteItem(id) +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/services/wave_service.go b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/services/wave_service.go new file mode 100644 index 000000000..7ea0d25de --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-interface-field-dispatch/internal/services/wave_service.go @@ -0,0 +1,28 @@ +package services + +import "github.com/example/wms/internal/repository" + +// WaveService declares the SAME field shape as PickService — the issue +// reported these two behaving differently at scale (#2813). +type WaveService struct { + orderRepo repository.OrderRepository + orderRepo2 *repository.OrderRepo + auditRepo *repository.AuditRepo +} + +func (s *WaveService) Release(id string) error { + s.auditRepo.LogAuditEventAsync("wave") + return s.orderRepo.DeleteItem(id) +} + +func (s *WaveService) Queue(id string) ([]string, error) { + return s.orderRepo.GetPickQueue(id) +} + +// Recount calls through a CONCRETE field whose own type (*OrderRepo) is itself +// an implementor of OrderRepository. This is the shape where the dispatch type +// gate is load-bearing: the call must resolve to OrderRepo only and must NOT +// fan out to the sibling implementor MockOrderRepo (#2829 review). +func (s *WaveService) Recount(id string) error { + return s.orderRepo2.UnsplitOrder(id) +} diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 7ccea1aeb..3f92964ae 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -355,14 +355,20 @@ describe('Go structural interface dispatch', () => { } it('emits signature-checked structural IMPLEMENTS edges only for valid implementors', () => { - const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter( - (edge) => edge.rel.reason === 'go-structural-implements', + const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter((edge) => + (edge.rel.reason ?? '').startsWith('go-structural-implements'), ); expect(edgeSet(implementsEdges)).toEqual([ 'File → ReadCloser', 'File → Reader', 'FileBase → Reader', 'MemoryRepository → Repository', + // ADDED in #2813, same deliberate reversal as the PointerOnlyThing pin + // below: `func (p *PointerOnlyThing) Touch()` puts Touch in the method + // set of *PointerOnlyThing, which is the type idiomatic Go stores in a + // PointerOnly-typed field. This exact-set assertion was the second place + // the #1966 value-only reading was encoded. + 'PointerOnlyThing → PointerOnly', 'SqlRepository → Repository', ]); expect(implementsEdges.every((edge) => edge.rel.confidence === 0.85)).toBe(true); @@ -409,9 +415,36 @@ describe('Go structural interface dispatch', () => { expect(edgeSet(implementsEdges)).not.toContain('ShadowReadFile → ReadCloser'); }); - it('does not emit value-type IMPLEMENTS for pointer-receiver-only methods', () => { + // POLARITY DELIBERATELY REVERSED in #2813 (was: `.not.toContain`). + // + // #1966 read Go's method-set rule for the VALUE type `T`, where pointer- + // receiver methods genuinely do not count. But GitNexus has one Struct node + // per type and no separate `*T` node, so that reading left `*T` — the shape + // idiomatic Go stores in an interface-typed field — unable to implement + // anything. The cost was silence, not caution: calls through such a field + // stopped at the interface declaration and `impact()` on the implementation + // reported zero callers. See the rationale block in interface-impls.ts. + it('emits IMPLEMENTS for a pointer-receiver-only implementor', () => { const implementsEdges = getRelationships(result, 'IMPLEMENTS'); - expect(edgeSet(implementsEdges)).not.toContain('PointerOnlyThing → PointerOnly'); + expect(edgeSet(implementsEdges)).toContain('PointerOnlyThing → PointerOnly'); + }); + + // The FORM is the exact fact, not a confidence hedge. `func (p *PointerOnlyThing) + // Touch()` puts Touch in MS(*PointerOnlyThing) only, so `var x PointerOnly = + // PointerOnlyThing{}` is a Go compile error while `&PointerOnlyThing{}` is fine. + // Value-satisfying implementors keep the unsuffixed reason. + it('records WHICH method set satisfies the interface', () => { + const byPair = new Map( + getRelationships(result, 'IMPLEMENTS').map((e) => [ + `${e.source} → ${e.target}`, + e.rel.reason, + ]), + ); + expect(byPair.get('PointerOnlyThing → PointerOnly')).toBe('go-structural-implements-pointer'); + // SqlRepository/MemoryRepository use VALUE receivers, so the value type + // itself implements and the reason stays unsuffixed. + expect(byPair.get('SqlRepository → Repository')).toBe('go-structural-implements'); + expect(byPair.get('MemoryRepository → Repository')).toBe('go-structural-implements'); }); it('fans out embedded-interface receivers only to complete implementors', () => { @@ -446,8 +479,8 @@ describe('Go cross-package structural interface dispatch', () => { } it('matches local interface types against package-qualified implementation signatures', () => { - const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter( - (edge) => edge.rel.reason === 'go-structural-implements', + const implementsEdges = getRelationships(result, 'IMPLEMENTS').filter((edge) => + (edge.rel.reason ?? '').startsWith('go-structural-implements'), ); expect(edgeSet(implementsEdges)).toEqual([ 'File → ReadCloser', @@ -1794,3 +1827,203 @@ describe('Go pointer-receiver field chains (#2766)', () => { expect(imports.map((e) => `${e.source} → ${e.target}`)).toContain('handler.go → repo.go'); }); }); + +// --------------------------------------------------------------------------- +// #2813: calls through an interface-typed struct field must reach the +// IMPLEMENTATION, not stop at the interface declaration. +// --------------------------------------------------------------------------- +// +// Two stacked defects produced the reported symptom, and either alone is +// enough to reproduce it — which is why the pre-existing fixtures, uniformly +// value-receiver, could not observe it: +// +// D1 `buildDetectionIndexes` skipped every POINTER-receiver method, so a +// struct whose methods are all `func (r *T)` had an empty method set, +// structurally satisfied nothing, and got no IMPLEMENTS edge. Go's rule +// is that the method set of *T includes pointer-receiver methods, and +// idiomatic Go stores *T in an interface-typed field. +// D2 Case 0 (compound receiver) emitted its primary edge and short-circuited +// without the interface-dispatch fan-out Case 4 performs. A struct-field +// receiver `s.orderRepo` contains a dot, so it always takes Case 0; a +// local or parameter receiver is a bare name and reaches Case 4. +// +// The fixture is deliberately pointer-receiver throughout, cross-package, and +// carries concrete-field controls in the same structs. +describe('Go interface-typed struct field dispatch (#2813)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-interface-field-dispatch'), + () => {}, + ); + }, 120000); + + const calls = (): string[] => edgeSet(getRelationships(result, 'CALLS')); + const implementsEdges = (): string[] => edgeSet(getRelationships(result, 'IMPLEMENTS')); + /** CALLS rows carrying the emitting reason, so a fan-out edge is + * distinguishable from the primary edge to the interface declaration. */ + const callsWithReason = (): string[] => + getRelationships(result, 'CALLS').map((e) => `${e.source} → ${e.target}:${e.rel.reason}`); + /** CALLS rows qualified by the target's FILE — `→ DeleteItem` alone cannot + * tell the interface declaration apart from the implementation, which is + * the entire distinction under test. */ + const callsToFile = (): string[] => + getRelationships(result, 'CALLS').map( + (e) => `${e.source} → ${e.target}@${e.targetFilePath.split('/').slice(-1)[0]}`, + ); + /** Both ENDS file-qualified. Required wherever two callers share a method + * name — `callsToFile()` alone cannot tell them apart, so a row asserting + * per-file behaviour must use this instead. */ + const callsFromFileToFile = (): string[] => + getRelationships(result, 'CALLS').map( + (e) => + `${e.sourceFilePath.split('/').slice(-1)[0]}:${e.source} → ` + + `${e.target}@${e.targetFilePath.split('/').slice(-1)[0]}`, + ); + /** CALLS rows carrying the emitted confidence, so a change to the literal + * is caught rather than silently accepted. */ + const callsWithConfidence = (): string[] => + getRelationships(result, 'CALLS').map( + (e) => + `${e.source} → ${e.target}@${e.targetFilePath.split('/').slice(-1)[0]}=${e.rel.confidence}`, + ); + + // D1: a pointer-receiver implementor must be discoverable at all. + it('detects a pointer-receiver struct as an interface implementor', () => { + expect(implementsEdges()).toContain('OrderRepo → OrderRepository'); + }); + + it('detects every pointer-receiver implementor, not just the first', () => { + expect(implementsEdges()).toContain('MockOrderRepo → OrderRepository'); + }); + + // D2: the headline defect. Before the fix these were absent entirely. + it('resolves an interface-typed field call to the implementation', () => { + expect(callsToFile()).toContain('StartSession → DeleteItem@order_repo.go'); + }); + + it('resolves an interface-typed field call from a handler to the implementation', () => { + expect(callsToFile()).toContain('Delete → DeleteItem@order_repo.go'); + }); + + it('fans out to every implementor, not only the first', () => { + expect(callsToFile()).toContain('StartSession → DeleteItem@mock_repo.go'); + }); + + it('emits the implementation edge with reason interface-dispatch', () => { + expect(callsWithReason()).toContain('StartSession → DeleteItem:interface-dispatch'); + }); + + // R11-style control: the fan-out must ADD edges, never MOVE them. If the + // primary edge disappears, the fix relocated resolution instead of widening + // it and every consumer of the interface node silently loses its callers. + it('keeps the primary edge to the interface declaration', () => { + expect(callsToFile()).toContain('StartSession → DeleteItem@interfaces.go'); + }); + + // The epistemic half of #2813, pinned at its MECHANISM. + // + // The reporter's disqualifying complaint was that `impact()` returned + // `impactedCount 0, epistemic "exact"` for a method reached only through an + // interface field — byte-identical to a symbol that genuinely has no callers, + // so a zero could not be trusted defensively. That verdict is produced by + // `computeEpistemicBoundary`, which walks HERITAGE edges out of the queried + // symbol; with no IMPLEMENTS/METHOD_IMPLEMENTS edge it found no boundary, and + // the call sites were never *dropped* (they resolved, to the wrong node), so + // neither of its two producers fired. + // + // These are the edges that make the hedge fire. Measured on this fixture + // after the fix, `impact(OrderRepo.DeleteItem, upstream)` returns + // impactedCount 3 with epistemic "lower-bound" and an interface-boundary + // note, while the concrete `CartRepo.Get` still returns "exact" — so the + // signal discriminates rather than hedging on everything. Asserting the edges + // here keeps that mechanism from silently regressing without requiring the + // resolver suite to reach into the MCP layer. + it('emits METHOD_IMPLEMENTS from the implementation to the interface method', () => { + const methodImpls = getRelationships(result, 'METHOD_IMPLEMENTS').map( + (e) => + `${e.sourceFilePath.split('/').slice(-1)[0]}:${e.source} → ` + + `${e.targetFilePath.split('/').slice(-1)[0]}:${e.target}`, + ); + expect(methodImpls).toContain('order_repo.go:DeleteItem → interfaces.go:DeleteItem'); + expect(methodImpls).toContain('mock_repo.go:DeleteItem → interfaces.go:DeleteItem'); + }); + + // Concrete-field control: resolved before #2813 and must be untouched. + it('keeps resolving a concrete-typed field to its implementation', () => { + expect(callsToFile()).toContain('GetPickQueue → LogAuditEventAsync@audit_repo.go'); + }); + + // `AuditRepo` implements nothing, so it is never a key in the implementor + // index — this row is satisfied by that map miss alone and would still pass + // with the `ownerDef.type !== 'Interface'` gate deleted. Kept as a regression + // test for the user-visible property, NOT as a control for the gate. + it('does not fan out a concrete-typed field receiver that implements nothing', () => { + expect(callsWithReason()).not.toContain('GetPickQueue → LogAuditEventAsync:interface-dispatch'); + }); + + // The gate's real control. `OrderRepo` IS an implementor of `OrderRepository`, + // so it IS a live participant in the dispatch index; a call through a + // *concrete* `*OrderRepo` field must still resolve only to `OrderRepo` and + // must not fan out to its sibling implementor `MockOrderRepo`. This is the + // shape where the type gate does the work, so deleting the gate fails here. + it('does not fan out a concrete field whose own type is an implementor', () => { + expect(callsToFile()).toContain('Recount → UnsplitOrder@order_repo.go'); + expect(callsWithReason()).not.toContain('Recount → UnsplitOrder:interface-dispatch'); + expect(callsToFile()).not.toContain('Recount → UnsplitOrder@mock_repo.go'); + }); + + // Negative control for structural detection: a partial match is not an + // implementation. Without this, "everything implements everything" passes. + it('does not treat a partial signature match as an implementation', () => { + expect(implementsEdges()).not.toContain('OrderRepo → PartialRepository'); + }); + + // Signature comparison is the ONLY remaining guard now that pointer-receiver + // methods are admitted, so it needs a same-name/same-arity/different-type row + // and not just the missing-method negative above. + it('does not treat a same-name same-arity method with a different signature as an implementation', () => { + expect(implementsEdges()).not.toContain('WrongSigRepo → OrderRepository'); + }); + + // The fan-out emits at the same confidence as the primary edge it hangs off. + // Without this row the 0.85 literal can be changed with nothing failing — + // the sibling IMPLEMENTS block already pins its own confidence. + it('emits dispatch edges at the same confidence as the primary edge', () => { + expect(callsWithConfidence()).toContain('StartSession → DeleteItem@order_repo.go=0.85'); + expect(callsWithConfidence()).toContain('StartSession → DeleteItem@interfaces.go=0.85'); + }); + + // Bounds the cross product. Two implementors x the call sites below is the + // whole fan-out for this interface, so a future cap, collapse or dedup change + // becomes visible here instead of silently multiplying or truncating edges. + it('emits exactly one dispatch edge per implementor per call site', () => { + const deleteItemDispatch = getRelationships(result, 'CALLS').filter( + (e) => e.target === 'DeleteItem' && e.rel.reason === 'interface-dispatch', + ); + // 3 call sites on DeleteItem (pick_service StartSession, wave_service + // Release, handlers Delete) x 2 implementors (OrderRepo, MockOrderRepo). + expect(deleteItemDispatch.length).toBe(6); + const targets = [...new Set(deleteItemDispatch.map((e) => e.targetFilePath.split('/').pop()))]; + expect(targets.sort()).toEqual(['mock_repo.go', 'order_repo.go']); + }); + + // The issue reported these two files behaving differently at scale despite + // declaring the same field shape; both must resolve here. + // + // The SOURCE is file-qualified on purpose. Two methods in this fixture are + // named `Queue` (services/wave_service.go and handlers/picking.go), and + // `callsToFile()` qualifies only the target — so a bare `'Queue → …'` row is + // satisfied by EITHER of them and cannot detect one file resolving while the + // other does not. That per-file divergence is the exact #2813 symptom this + // row exists to catch, so it has to name both sides. + it('resolves the same field shape identically across two service files', () => { + expect(callsFromFileToFile()).toContain('wave_service.go:Release → DeleteItem@order_repo.go'); + expect(callsFromFileToFile()).toContain('wave_service.go:Queue → GetPickQueue@order_repo.go'); + expect(callsFromFileToFile()).toContain( + 'pick_service.go:StartSession → DeleteItem@order_repo.go', + ); + expect(callsFromFileToFile()).toContain('picking.go:Queue → GetPickQueue@order_repo.go'); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 1251a6eb2..ec4224b21 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -118,8 +118,10 @@ describe('PARSE_CACHE_VERSION', () => { // second clash was caught — after review, while the branch sat waiting to // merge — which is precisely the window in which `main` allocates. Re-check // against origin/main immediately before merge, not at review time. - it('pins SCHEMA_BUMP to 42 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(42); + // Moved 42 -> 43 for #2813's `@reference.embedded-pointer` capture, which is + // parse-time emission and so cannot be served from a v42 warm cache. + it('pins SCHEMA_BUMP to 43 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(43); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts index 330cbb85e..d631b69a0 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts @@ -15,6 +15,7 @@ import { goReceiverBinding, } from '../../../../src/core/ingestion/languages/go/index.js'; import { detectGoInterfaceImplementations } from '../../../../src/core/ingestion/languages/go/interface-impls.js'; +import { populateGoOwners } from '../../../../src/core/ingestion/languages/go/method-owners.js'; describe('Go arity compatibility', () => { const makeDef = (overrides: Partial = {}): SymbolDefinition => ({ @@ -272,6 +273,23 @@ function inheritsSite(name: string, inScope: ScopeId): ReferenceSite { }; } +/** + * Structural detection now returns the receiver FORM alongside each + * implementor (`{ structDefId, receiverForm }`), because Go's method sets for + * `T` and `*T` genuinely differ. Most rows below only care about WHICH types + * implement, so this extracts the ids; the rows that care about the form assert + * it explicitly. + */ +function implIds( + result: Map, + ifaceId: string, +): string[] | undefined { + const found = result.get(ifaceId); + // Deliberately NOT sorted: several rows below assert detection ORDER + // (shallowest-promoted-first), which sorting would silently destroy. + return found === undefined ? undefined : found.map((i) => i.structDefId); +} + describe('Go structural interface detection', () => { it('detects a struct implementing every interface method with matching signatures', () => { const iface = goDef('iface:Repository', 'Interface', 'Repository'); @@ -319,10 +337,18 @@ describe('Go structural interface detection', () => { {} as any, ); - expect(result.get(iface.nodeId)).toEqual([struct.nodeId]); + expect(implIds(result, iface.nodeId)).toEqual([struct.nodeId]); }); - it('does not treat pointer-receiver-only methods as value type implementations', () => { + // POLARITY DELIBERATELY REVERSED in #2813 (was: expected `undefined`). + // A type whose methods use pointer receivers is exactly what idiomatic Go + // stores in an interface-typed field; excluding it emitted no IMPLEMENTS edge + // at all, so calls through such a field never reached the implementation. + // See the rationale block in interface-impls.ts. + // Exactness, not just presence: a pointer-receiver-only type implements the + // interface ONLY in pointer form. `var x Closer = PointerOnlyCloser{}` is a + // compile error in Go and the graph must be able to say so. + it('treats pointer-receiver-only methods as implementations in POINTER form only', () => { const iface = goDef('iface:Closer', 'Interface', 'Closer'); const struct = goDef('struct:PointerOnlyCloser', 'Struct', 'PointerOnlyCloser'); const ifaceClose = goDef('iface:Closer.Close', 'Method', 'Closer.Close', iface.nodeId, { @@ -347,7 +373,8 @@ describe('Go structural interface detection', () => { {} as any, ); - expect(result.get(iface.nodeId)).toBeUndefined(); + expect(implIds(result, iface.nodeId)).toEqual([struct.nodeId]); + expect(result.get(iface.nodeId)?.[0]?.receiverForm).toBe('pointer'); }); it('rejects same-name methods with incompatible parameter types', () => { @@ -577,7 +604,7 @@ describe('Go structural interface detection', () => { {} as any, ); - expect(result.get(readCloser.nodeId)).toEqual([struct.nodeId]); + expect(implIds(result, readCloser.nodeId)).toEqual([struct.nodeId]); }); it('accepts structs implementing interface methods through promoted embedded struct methods', () => { @@ -606,7 +633,7 @@ describe('Go structural interface detection', () => { {} as any, ); - expect(result.get(reader.nodeId)).toEqual([base.nodeId, file.nodeId]); + expect(implIds(result, reader.nodeId)).toEqual([base.nodeId, file.nodeId]); }); it('lets direct struct methods shadow promoted embedded struct methods', () => { @@ -650,7 +677,7 @@ describe('Go structural interface detection', () => { {} as any, ); - expect(result.get(reader.nodeId)).toEqual([base.nodeId]); + expect(implIds(result, reader.nodeId)).toEqual([base.nodeId]); }); it('does not use ambiguous promoted embedded struct methods for interface matching', () => { @@ -689,7 +716,7 @@ describe('Go structural interface detection', () => { {} as any, ); - expect(result.get(reader.nodeId)).toEqual([baseA.nodeId, baseB.nodeId]); + expect(implIds(result, reader.nodeId)).toEqual([baseA.nodeId, baseB.nodeId]); }); it('uses the shallowest promoted embedded struct method when deeper methods share the name', () => { @@ -734,7 +761,7 @@ describe('Go structural interface detection', () => { {} as any, ); - expect(result.get(reader.nodeId)).toEqual([ + expect(implIds(result, reader.nodeId)).toEqual([ shallow.nodeId, deepBase.nodeId, deepWrapper.nodeId, @@ -840,7 +867,7 @@ describe('Go structural interface detection', () => { {} as any, ); - expect(result.get(readCloser.nodeId)).toEqual([file.nodeId]); + expect(implIds(result, readCloser.nodeId)).toEqual([file.nodeId]); }); it('does not emit implementations for cyclic embedded interfaces', () => { @@ -908,8 +935,8 @@ describe('Go structural interface detection', () => { {} as any, ); - expect(result.get(reader.nodeId)).toEqual([file.nodeId]); - expect(result.get(closer.nodeId)).toEqual([file.nodeId]); + expect(implIds(result, reader.nodeId)).toEqual([file.nodeId]); + expect(implIds(result, closer.nodeId)).toEqual([file.nodeId]); }); it('does not emit implementations when an embedded interface cannot be resolved', () => { @@ -977,7 +1004,7 @@ describe('Go structural interface detection', () => { {} as any, ); - expect(result.get(iface.nodeId)).toEqual([struct.nodeId]); + expect(implIds(result, iface.nodeId)).toEqual([struct.nodeId]); }); it('preserves package qualifiers when checking signatures', () => { @@ -1113,3 +1140,54 @@ describe('Go structural interface detection', () => { expect(result.get(iface.nodeId)).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// #2829 review: `goReceiverKind` must stay stamped even though nothing filters +// on it any more. +// --------------------------------------------------------------------------- +// +// #2813 removed the only functional READER of `goReceiverKind` (the +// pointer-receiver exclusion in `buildDetectionIndexes`). The field is kept +// deliberately — it is the hook a future value/pointer-aware model would read, +// and `interpret.ts` preserves the raw `*T` shape specifically to feed it. But +// a written-and-never-read field rots: before these rows you could delete the +// assignment in `method-owners.ts` and the whole suite stayed green. +// +// These pin the stamp itself, so the promise the comment makes stays true. +describe('Go method-owner receiver-kind stamping (#2829)', () => { + const ownerScope = (receiverRaw: string, methodDef: SymbolDefinition): Scope => { + const s = scope('fn:1' as ScopeId, 'Function', [methodDef]); + (s.typeBindings as Map).set('r', { + rawName: receiverRaw, + source: 'self', + }); + return s; + }; + + const stampFor = (receiverRaw: string): SymbolDefinition => { + const struct = goDef('struct:Repo', 'Struct', 'Repo'); + const method = goDef('struct:Repo.Save', 'Method', 'Save'); + const parsed = { + filePath: 'repo.go', + language: 'go', + scopes: [scope('mod' as ScopeId, 'Module', [struct]), ownerScope(receiverRaw, method)], + imports: [], + localDefs: [struct, method], + referenceSites: [], + } as any; + populateGoOwners(parsed); + return method; + }; + + it('stamps a POINTER receiver as pointer', () => { + const m = stampFor('*Repo') as SymbolDefinition & { goReceiverKind?: string }; + expect(m.goReceiverKind).toBe('pointer'); + expect(m.ownerId).toBe('struct:Repo'); + }); + + it('stamps a VALUE receiver as value', () => { + const m = stampFor('Repo') as SymbolDefinition & { goReceiverKind?: string }; + expect(m.goReceiverKind).toBe('value'); + expect(m.ownerId).toBe('struct:Repo'); + }); +}); From f36c3eb67812ccba1b0df4e8862e9d0a4c0d7b84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:16:34 +0000 Subject: [PATCH 12/17] chore(deps)(deps): bump js-yaml from 5.2.2 to 5.2.3 in /gitnexus (#2831) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 5.2.2 to 5.2.3. - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.2.2...5.2.3) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.2.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 16e374d9e..109607116 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -3583,9 +3583,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", - "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", + "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", "funding": [ { "type": "github", From a6a8aa788c33aed1b67d2d5a27ad0a57736b8cd9 Mon Sep 17 00:00:00 2001 From: Shifra Williams Date: Tue, 4 Aug 2026 22:52:39 -0700 Subject: [PATCH 13/17] feat(serve): validate and port-scope the origin/proxy configuration surface (#2820) --- CONTRIBUTING.md | 1 + README.md | 2 + gitnexus/.env.example | 36 ++ gitnexus/package-lock.json | 12 + gitnexus/package.json | 2 + gitnexus/src/server/api.ts | 98 +++-- gitnexus/src/server/middleware.ts | 347 ++++++++++++++++-- .../server-analyze-token-validation.test.ts | 2 +- .../integration/server-http-startup.test.ts | 74 +++- gitnexus/test/unit/api-analyze-upload.test.ts | 25 +- gitnexus/test/unit/cors.test.ts | 158 +++++++- .../unit/middleware-public-origin.test.ts | 294 +++++++++++++++ .../test/unit/middleware-trust-proxy.test.ts | 191 ++++++++++ gitnexus/test/unit/rate-limit.test.ts | 122 +++++- 14 files changed, 1268 insertions(+), 96 deletions(-) create mode 100644 gitnexus/test/unit/middleware-public-origin.test.ts create mode 100644 gitnexus/test/unit/middleware-trust-proxy.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e9e17369..23a6ae28c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,7 @@ Commits within a PR may use any style — only the **merged PR title** shows up - [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`. - [ ] No secrets, tokens, or machine-specific paths committed. - [ ] Documentation updated if behavior or public CLI/MCP contract changes. +- [ ] Every new `GITNEXUS_*` environment variable has a row in the **Environment variables** table in [README.md](README.md) — variable, default, effect, and when to tune it. - [ ] Pre-commit hook runs clean (`.husky/pre-commit` — formatting via lint-staged + typecheck for staged packages; tests run in CI only). ## Code review diff --git a/README.md b/README.md index 6bc8a7e5d..7158cbd66 100644 --- a/README.md +++ b/README.md @@ -508,6 +508,8 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. | | `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. | | `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. | +| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. | +| `GITNEXUS_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` value — which upstream hops may set `X-Forwarded-*`, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. `false`/`no`/`off` (and a `0` hop count) trust no hop; a proxy list Express can compile (`loopback`, `10.0.0.0/8, 127.0.0.1`) names them instead. `true`/`yes`/`on` is **rejected**: it reads the client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (`ERR_ERL_PERMISSIVE_TRUST_PROXY`). Counts above `16` are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and `serve` warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. | `serve` sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global. | diff --git a/gitnexus/.env.example b/gitnexus/.env.example index 3d4d73bad..2b257ac15 100644 --- a/gitnexus/.env.example +++ b/gitnexus/.env.example @@ -45,3 +45,39 @@ # integer only — non-integer or < 1 values fall back to 32. # See README § "Scope-resolution dispatch-target cap". # GITNEXUS_MAX_CALLABLE_VALUE_TARGETS=64 + +# `serve` behind a reverse proxy +# Only needed when `gitnexus serve` is reached through a proxy, or bound to a +# wildcard address. Leave both unset for a local install. + +# The single browser origin this server is reached through. A wildcard bind has +# no host identity, so without this the write-route guard rejects the UI's +# writes. +# +# NOT USABLE YET: `serve` has no authentication, and requests with no Origin +# header (curl, any script) already reach POST /api/analyze and DELETE /api/repo, +# so setting this makes `serve` refuse to start rather than open a public bind +# with nothing behind it. +# +# Matching rules for when the gate lifts: the hostname must match exactly, and so +# must the scheme. A value with no scheme means https, so plain HTTP needs the +# explicit http:// form. An explicit port must match; with no port, any port on +# that hostname is accepted. Anything that is not one reachable host (a list, a +# wildcard, a bare port number, a :0 port, a trailing dot) warns and allows +# nothing. +# GITNEXUS_PUBLIC_ORIGIN=https://gitnexus.example.com + +# Which upstream hops may set X-Forwarded-*, so the per-IP rate limiter keys on +# the client IP and not the proxy's. Set it to the exact number of proxies you +# control. Every hop past that is one more entry of the chain the caller gets to +# write. false/no/off (and a 0 hop count) trust no hop; a proxy list Express can +# compile ("loopback", "10.0.0.0/8, 127.0.0.1") names them instead. +# true/yes/on is REJECTED: it reads the client-controlled leftmost +# X-Forwarded-For entry, so a spoofed chain earns a fresh rate-limit key per +# request, and express-rate-limit rejects it too. Counts above 16 are rejected as +# well, as a sanity ceiling rather than a safety boundary. Any invalid value warns +# and falls back to "loopback, linklocal, uniquelocal". +# Bind non-loopback with this unset and `serve` warns: a load balancer outside the +# private ranges is untrusted, so every request keys to the balancer and the +# per-IP limit becomes one shared limit. +# GITNEXUS_TRUST_PROXY=1 diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 109607116..9ba2740e8 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -33,6 +33,7 @@ "pandemonium": "^2.4.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", + "proxy-addr": "^2.0.7", "tree-sitter": "0.21.1", "tree-sitter-c-sharp": "0.23.1", "tree-sitter-cpp": "0.23.2", @@ -59,6 +60,7 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.6", "@types/node": "^26.0.0", + "@types/proxy-addr": "^2.0.3", "@vitest/coverage-v8": "^4.0.18", "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", @@ -1946,6 +1948,16 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/proxy-addr": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/proxy-addr/-/proxy-addr-2.0.3.tgz", + "integrity": "sha512-TgAHHO4tNG3HgLTUhB+hM4iwW6JUNeQHCLnF1DjaDA9c69PN+IasoFu2MYDhubFc+ZIw5c5t9DMtjvrD6R3Egg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 6c438f879..8d01b2415 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -79,6 +79,7 @@ "pandemonium": "^2.4.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", + "proxy-addr": "^2.0.7", "tree-sitter": "0.21.1", "tree-sitter-c-sharp": "0.23.1", "tree-sitter-cpp": "0.23.2", @@ -106,6 +107,7 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.6", "@types/node": "^26.0.0", + "@types/proxy-addr": "^2.0.3", "@vitest/coverage-v8": "^4.0.18", "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 8a831aa9b..bd7ab685c 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -63,7 +63,16 @@ import { GITHUB_TOKEN_HOSTS, } from './git-clone.js'; import { createAnalyzeUploadHandler } from './analyze-upload.js'; -import { createLocalhostOriginGuard, normalizeBoundHost } from './middleware.js'; +import { + assertServeAuthForPublicOrigin, + createPublicOriginMatcher, + createWriteOriginGuard, + logOriginPolicy, + PUBLIC_ORIGIN_ENV, + resolveTrustProxy, + TRUST_PROXY_ENV, + warnIfRateLimitKeysCollapse, +} from './middleware.js'; import { createLaunchAnalysisWorker } from './analyze-launch.js'; import { UPLOAD_ROOT } from './upload-paths.js'; import { sweepStaleUploads } from './upload-sweep.js'; @@ -85,6 +94,8 @@ const pkg = _require('../../package.json'); * 172.16.0.0/12 → 172.16.x.x – 172.31.x.x * 192.168.0.0/16 → 192.168.x.x * - https://gitnexus.vercel.app — the deployed GitNexus web UI + * - the origin named by GITNEXUS_PUBLIC_ORIGIN, when set — matched on hostname + * always, and on scheme and port when the configured value carries them * * @param origin - The value of the HTTP `Origin` request header, or `undefined` * when the header is absent (non-browser request). @@ -110,21 +121,22 @@ export const isAllowedOrigin = (origin: string | undefined): boolean => { // RFC 1918 private network ranges — allow any port on these hosts. // We parse the hostname out of the origin URL and check against each range. - let hostname: string; - let protocol: string; + let parsed: URL; try { - const parsed = new URL(origin); - hostname = parsed.hostname; - protocol = parsed.protocol; + parsed = new URL(origin); } catch { // Malformed origin — reject return false; } // Only allow HTTP(S) origins — reject ftp://, file://, etc. - if (protocol !== 'http:' && protocol !== 'https:') return false; + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; - return isRfc1918PrivateIpv4(hostname); + // The matcher is rebuilt per call, so changing the env var takes effect + // without a restart. The write guard in middleware.ts snapshots it instead. + if (createPublicOriginMatcher(process.env[PUBLIC_ORIGIN_ENV])?.matches(parsed)) return true; + + return isRfc1918PrivateIpv4(parsed.hostname); }; type GraphStreamRecord = @@ -703,6 +715,11 @@ export function validateAnalyzeToken( } export const createServer = async (port: number, host: string = '127.0.0.1') => { + // Refuse a public-origin config before anything is opened or bound: `serve` + // has no authentication yet, so the setting that makes a public bind usable + // must not be usable either. Throws — `serve` reports it and exits non-zero. + assertServeAuthForPublicOrigin(); + // Surface a cleartext Azure DevOps PAT config at boot (operators rarely // read per-request logs). Warn-only — http:// self-hosted stays supported. warnIfInsecureAzureConfig(); @@ -710,26 +727,13 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => const app = express(); app.disable('x-powered-by'); - // Trust X-Forwarded-* headers only when the connection comes from the - // local loopback or RFC1918 private/link-local addresses — exactly the - // origins the CORS allowlist accepts. Without this, every request behind - // any reverse proxy / Docker bridge counts as the same `req.ip` and a - // single user can trip the per-IP rate limiter for everyone. - // - // SCOPE: this setting is process-wide. Every middleware and route in this - // Express app sees req.ip resolved from X-Forwarded-For when the upstream - // hop is in the trusted set above — not just the rate-limited routes. - // Future IP-based middleware (audit logging, IP-bound authz) inherits this - // behavior. - // - // CLOUD-DEPLOY CAVEAT: a public cloud LB (AWS ALB, Cloudflare, Fly.io - // edge, CGNAT 100.64/10) is NOT in the trusted set. In those topologies - // req.ip will collapse to the LB hop IP for every request and the per-IP - // rate limiter degrades to per-server. Add an explicit env-var override - // and document the cloud-deploy story before binding to a non-loopback - // host in those topologies (tracked as a follow-up; not blocking for the - // local-bound default). - app.set('trust proxy', 'loopback, linklocal, uniquelocal'); + // Which upstream hops may set X-Forwarded-*. Process-wide: every route's + // req.ip, and so the per-IP rate limiter, resolves through this. + app.set('trust proxy', resolveTrustProxy(process.env[TRUST_PROXY_ENV])); + // resolveTrustProxy validates the value in isolation; only here do we know + // what we bound, and so whether the default is about to collapse the per-IP + // rate limit to one global limit behind a load balancer. + warnIfRateLimitKeysCollapse(host); // Chromium Private Network Access (required since Chrome 130+). Must run before // cors: the cors middleware ends OPTIONS preflight responses, so this header @@ -753,21 +757,10 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => ); app.use(express.json({ limit: '10mb' })); - // Same-host origin guard for write routes. Only allows loopback and the - // server's own bound host — scoped to prevent CSRF from other LAN devices. - const requireLocalhostOrigin = createLocalhostOriginGuard(host); - - // A wildcard bind (`0.0.0.0`/`::`) has no single host identity for the - // same-host check, so browser write routes accept only loopback origins. - // Warn the operator so a remote-access deployment isn't silently write-blocked. - if (host && normalizeBoundHost(host) === undefined) { - logger.warn( - { host }, - `[gitnexus serve] Bound to a wildcard address (${host}); browser write routes ` + - `accept only loopback origins (localhost/127.0.0.1/[::1]). To allow writes from a ` + - `specific LAN address, bind --host instead of a wildcard.`, - ); - } + // Origin guard for write routes: loopback, the server's own bound host, and + // any configured public origin — prevents CSRF from other devices. + const requireTrustedOrigin = createWriteOriginGuard(host, port); + logOriginPolicy(host); // No explicit OPTIONS route is registered. The Chromium Private Network // Access header is set by the global middleware above (pre-cors), and @@ -988,7 +981,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // Rate-limited (CodeQL js/missing-rate-limiting): destructive operation // doing fs.rm of clone + storage dirs. Default 60 rpm/IP is generous for // delete; tighten if abuse is observed. - app.delete('/api/repo', createRouteLimiter(), requireLocalhostOrigin, async (req, res) => { + app.delete('/api/repo', createRouteLimiter(), requireTrustedOrigin, async (req, res) => { try { const repoName = requestedRepo(req); if (!repoName) { @@ -1496,7 +1489,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => app.post( '/api/analyze', createRouteLimiter({ limit: 10 }), - requireLocalhostOrigin, + requireTrustedOrigin, async (req, res) => { try { const { @@ -1535,9 +1528,10 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // (both collapse `..` identically) and only false-rejected trailing // slashes, so it is dropped. Analyzing a local path the operator names // is the tool's intended capability (same as the CLI); the dangerous - // part was cross-origin reach, which is closed by requireLocalhostOrigin - // on this route (scoped to the server's own bound host — other LAN - // devices are NOT trusted). We only require an absolute path here and + // part was cross-origin reach, which is closed by requireTrustedOrigin + // on this route (scoped to loopback, the server's own bound host, and a + // configured GITNEXUS_PUBLIC_ORIGIN — other LAN devices are NOT + // trusted). We only require an absolute path here and // let the analyze worker surface a clear error if it does not exist. // (We do NOT realpath/stat the path in-route: that would be a // user-controlled filesystem read — CodeQL js/path-injection — for no @@ -1627,7 +1621,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => app.post( '/api/analyze/upload', createRouteLimiter({ limit: 5 }), - requireLocalhostOrigin, + requireTrustedOrigin, createAnalyzeUploadHandler({ createJob: (params) => jobManager.createJob(params), launch: (job, targetPath, opts) => launchAnalysisWorker(job, targetPath, opts), @@ -1659,7 +1653,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => mountSSEProgress(app, '/api/analyze/:jobId/progress', jobManager); // DELETE /api/analyze/:jobId — cancel a running analysis job - app.delete('/api/analyze/:jobId', requireLocalhostOrigin, (req, res) => { + app.delete('/api/analyze/:jobId', requireTrustedOrigin, (req, res) => { const jobId = req.params.jobId as string; const job = jobManager.getJob(jobId); if (!job) { @@ -1682,7 +1676,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => app.post( '/api/embed', createRouteLimiter({ limit: 20 }), - requireLocalhostOrigin, + requireTrustedOrigin, async (req, res) => { try { const entry = await resolveRepo(requestedRepo(req)); @@ -1981,7 +1975,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => mountSSEProgress(app, '/api/embed/:jobId/progress', embedJobManager); // DELETE /api/embed/:jobId — cancel embedding job - app.delete('/api/embed/:jobId', requireLocalhostOrigin, (req, res) => { + app.delete('/api/embed/:jobId', requireTrustedOrigin, (req, res) => { const jobId = req.params.jobId as string; const job = embedJobManager.getJob(jobId); if (!job) { diff --git a/gitnexus/src/server/middleware.ts b/gitnexus/src/server/middleware.ts index 286d0b337..078c077f3 100644 --- a/gitnexus/src/server/middleware.ts +++ b/gitnexus/src/server/middleware.ts @@ -1,13 +1,36 @@ /** - * Shared Express route guards (alongside createRouteLimiter in validation.ts). + * Shared Express route guards (alongside createRouteLimiter in validation.ts), + * plus the `serve` configuration surface they read: GITNEXUS_PUBLIC_ORIGIN and + * GITNEXUS_TRUST_PROXY. */ import type { Request, Response } from 'express'; +import proxyaddr from 'proxy-addr'; +import { logger } from '../core/logger.js'; + +/** Port a browser omits from `Origin`, so both sides compare on one value. */ +function effectivePort(url: URL): string { + if (url.port) return url.port; + return url.protocol === 'https:' ? '443' : '80'; +} /** - * Canonicalize a bound-host string into the form a browser `Origin` hostname - * takes after WHATWG URL parsing, so the same-host comparison in - * {@link createLocalhostOriginGuard} can use a plain `===`. + * The three loopback spellings a browser can send, and that + * {@link normalizeBoundHost} can return. + * + * Takes a hostname already through WHATWG URL parsing, so IPv6 arrives + * bracketed — unlike `isLoopbackHost` in `mcp/http-transport.ts`, which compares + * a raw `--host` value and so matches bare `::1`. + */ +function isLoopbackHostname(hostname: string | undefined): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; +} + +/** + * Canonicalize a configured host — `--host`, or the one inside + * {@link PUBLIC_ORIGIN_ENV} — into the form a browser `Origin` hostname takes + * after WHATWG URL parsing, so the comparisons in + * {@link createWriteOriginGuard} can use a plain `===`. * * Returns `undefined` when the host carries no single comparable identity: * - empty / not provided @@ -40,20 +63,128 @@ export function normalizeBoundHost(boundHost?: string): string | undefined { } /** - * Restrict a route to same-host browser origins. Allows: + * Browser origin a hosted deployment is reached through. A wildcard bind makes + * {@link normalizeBoundHost} undefined, so writes would stay loopback-only. + */ +export const PUBLIC_ORIGIN_ENV = 'GITNEXUS_PUBLIC_ORIGIN'; + +/** + * Matches a parsed browser `Origin` against {@link PUBLIC_ORIGIN_ENV}. Carries + * the hostname the env value resolved to, so startup can log what it parsed. + */ +export interface PublicOriginMatcher { + readonly hostname: string; + matches(origin: URL): boolean; +} + +/** + * Build a matcher for {@link PUBLIC_ORIGIN_ENV}. Compares hostname and scheme + * always — a value with no scheme is read as `https`, never as either — and + * port only when the configured value carried an explicit one. + * + * `undefined` when unset or not a single reachable host, mirroring + * {@link normalizeBoundHost}: an invalid origin must never widen the + * allow-list, and must not read as a configured one either. + */ +export function createPublicOriginMatcher(rawOrigin?: string): PublicOriginMatcher | undefined { + const trimmed = rawOrigin?.trim(); + if (!trimmed) return undefined; + + const scheme = /^(https?):\/\//i.exec(trimmed)?.[1].toLowerCase(); + // An `Origin` never carries a path, but a pasted URL often ends in a slash. + const raw = (scheme ? trimmed.slice(scheme.length + 3) : trimmed).replace(/\/$/, ''); + const authority = splitAuthority(raw); + if (!authority) return undefined; + + const hostname = normalizeBoundHost(authority.host); + if (!hostname) return undefined; + + // A bare host stays permissive on the port, since platform service-discovery + // fields resolve to a hostname with neither — but it defaults to https rather + // than to any scheme, because those platforms always terminate TLS, and + // accepting either would make `app.example.com` an http downgrade path into + // both the CORS read allowlist and the write guard. Plain http needs the + // explicit `http://` form. + const expectedProtocol = `${scheme ?? 'https'}:`; + let expectedPort: string | undefined; + if (authority.port) { + let configured: URL; + try { + // Round-trip through `new URL` so the configured port normalizes the same + // way a request Origin's does — an elided default, a leading zero — and so + // an out-of-range port throws here rather than yielding a dead matcher. + configured = new URL(`${expectedProtocol}//${hostname}:${authority.port}`); + } catch { + return undefined; + } + // Port 0 survives that round-trip (so does `:00`) but no browser ever sends + // it, so keeping it would build exactly the dead-but-configured matcher this + // function exists to reject. `:0080` is unaffected — it normalizes to the + // elided default, not to 0. + if (configured.port === '0') return undefined; + expectedPort = effectivePort(configured); + } + + return { + hostname, + matches: (origin: URL): boolean => + origin.hostname === hostname && + origin.protocol === expectedProtocol && + (expectedPort === undefined || effectivePort(origin) === expectedPort), + }; +} + +/** + * Split `host[:port]` into parts `new URL` can reassemble, or `undefined` when + * the value is not a single reachable host. + */ +function splitAuthority(authority: string): { host: string; port?: string } | undefined { + const bracketed = /^(\[[0-9A-Fa-f:.]+\])(?::(\d{1,5}))?$/.exec(authority); + if (bracketed) return { host: bracketed[1], port: bracketed[2] }; + + // More than one colon means a bare IPv6 literal, which carries no port. + if ((authority.match(/:/g)?.length ?? 0) > 1) { + return /^[0-9A-Fa-f:.]+$/.test(authority) ? { host: `[${authority}]` } : undefined; + } + + const parts = /^([^:]+)(?::(\d{1,5}))?$/.exec(authority); + if (!parts) return undefined; + const host = parts[1]; + // `new URL` is far laxer than DNS: it takes `a.com,b.com` verbatim and reads + // `8080` as the integer IP 0.0.31.144, either of which yields a matcher that + // can never match a real Origin while still reading as configured. A trailing + // dot is the same failure — it is a legal FQDN that survives parsing as + // `example.com.`, but a browser sends `example.com`, so the matcher is dead. + if (/[\s,;*/?#@\\]/.test(host) || /^\d+$/.test(host) || host.endsWith('.')) return undefined; + return { host, port: parts[2] }; +} + +/** + * Restrict a route to the browser origins this server trusts. Allows: * - loopback (`localhost`, `127.0.0.1`, `[::1]`) * - the server's own bound host (when non-loopback, e.g. a LAN IP) + * - the configured public origin ({@link PUBLIC_ORIGIN_ENV}), if any * * Non-browser requests (no Origin header, e.g. curl / the CLI) pass through. * This closes cross-origin reach to write routes without affecting read routes. * + * Loopback stays port-agnostic — the dev UI and the server run on different + * ports — but the bound host is matched on its port too, when one is given. + * * @param boundHost - The hostname/IP the server is listening on (from * `createServer`'s `host` parameter). When `undefined`, `'localhost'`, or a * wildcard (`0.0.0.0`/`::`), only loopback origins are admitted. + * @param boundPort - The port the server actually listens on. Omit it — and + * match the bound host on any port — when that is not known, as it is not + * for an ephemeral `--port 0` bind. */ -export function createLocalhostOriginGuard(boundHost?: string) { +export function createWriteOriginGuard(boundHost?: string, boundPort?: number) { const normalizedBoundHost = normalizeBoundHost(boundHost); - return function requireLocalhostOrigin(req: Request, res: Response, next: () => void): void { + const normalizedBoundPort = boundPort === undefined ? undefined : String(boundPort); + // Snapshotted at construction like normalizedBoundHost, so a later env + // mutation cannot widen a running server's write surface. + const publicOrigin = createPublicOriginMatcher(process.env[PUBLIC_ORIGIN_ENV]); + return function requireTrustedOrigin(req: Request, res: Response, next: () => void): void { const origin = req.headers.origin; if (origin === undefined) { next(); @@ -61,20 +192,14 @@ export function createLocalhostOriginGuard(boundHost?: string) { } try { const parsed = new URL(origin); - const hostname = parsed.hostname; - const protocol = parsed.protocol; + const { hostname, protocol } = parsed; if (protocol !== 'http:' && protocol !== 'https:') { throw new Error('Unsupported origin protocol'); } - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') { - next(); - return; - } - // Allow origin matching the server's own bound host (same-host check). - // `normalizedBoundHost` is canonicalized to the WHATWG form `hostname` - // already carries; it is `undefined` for wildcard/no binds (loopback-only). - // This covers the case where the operator runs `gitnexus serve --host `. - if (normalizedBoundHost && hostname === normalizedBoundHost) { + const matchesBoundHost = + hostname === normalizedBoundHost && + (normalizedBoundPort === undefined || effectivePort(parsed) === normalizedBoundPort); + if (isLoopbackHostname(hostname) || matchesBoundHost || publicOrigin?.matches(parsed)) { next(); return; } @@ -82,14 +207,192 @@ export function createLocalhostOriginGuard(boundHost?: string) { /* malformed origin → reject */ } res.status(403).json({ - error: 'This endpoint is restricted to same-host origins', + error: 'This endpoint is restricted to trusted browser origins', code: 'origin_not_allowed', }); }; } /** - * Default guard that only allows loopback origins. For use in tests or when - * the bound host is not available. + * Whether `serve` has any request authentication configured. + * + * Nothing can configure it yet: `serve` has no authentication of any kind, and + * {@link createWriteOriginGuard} passes every request that carries no `Origin` + * header, so `curl` reaches `POST /api/analyze` and `DELETE /api/repo` + * unauthenticated. That has been safe only because `serve` bound loopback. + * + * So this returns `false` unconditionally, and it is a placeholder on purpose: + * the `serve` auth change replaces this body, and {@link assertServeAuthForPublicOrigin} + * and its tests then hold without being rewritten. */ -export const requireLocalhostOrigin = createLocalhostOriginGuard(); +export function isServeAuthConfigured(): boolean { + return false; +} + +/** + * Refuse to start when {@link PUBLIC_ORIGIN_ENV} is set and no `serve` + * authentication is configured. + * + * {@link PUBLIC_ORIGIN_ENV} is the setting that makes a public bind usable — it + * is what admits a non-loopback browser origin to the write routes. Until + * {@link isServeAuthConfigured} can return `true`, setting it opens the door + * with nothing behind it, so the door does not open at all. There is + * deliberately no override flag: an escape hatch is the thing an operator sets + * once and forgets, which is exactly the state this guards against. + * + * @throws when {@link PUBLIC_ORIGIN_ENV} is set without authentication. `serve` + * surfaces it as `serve.startFailed` and exits non-zero. + */ +export function assertServeAuthForPublicOrigin(): void { + const raw = process.env[PUBLIC_ORIGIN_ENV]?.trim(); + if (!raw || isServeAuthConfigured()) return; + throw new Error( + `${PUBLIC_ORIGIN_ENV} is set (${raw}), but 'gitnexus serve' has no authentication yet. ` + + `It would admit browser writes from that origin, and requests without an Origin header ` + + `(curl, any script) already reach POST /api/analyze and DELETE /api/repo unauthenticated — ` + + `so a reachable deployment would let anyone index and delete repositories. Unset ` + + `${PUBLIC_ORIGIN_ENV} and bind loopback (the default), or reach the server through a proxy ` + + `that authenticates for it.`, + ); +} + +/** + * Report at startup what {@link createWriteOriginGuard} will admit, so an + * operator can see it without reproducing a 403. A wildcard bind always warns — + * gating that on {@link PUBLIC_ORIGIN_ENV} being constructible would diagnose a + * misconfigured value worse than an absent one. + */ +export function logOriginPolicy(boundHost?: string): void { + const raw = process.env[PUBLIC_ORIGIN_ENV]?.trim(); + const publicOrigin = createPublicOriginMatcher(raw); + if (publicOrigin) { + logger.info( + { [PUBLIC_ORIGIN_ENV]: raw, hostname: publicOrigin.hostname }, + `[gitnexus serve] Browser write routes also accept origins on ${publicOrigin.hostname}.`, + ); + } else if (raw) { + logger.warn( + { [PUBLIC_ORIGIN_ENV]: raw }, + `[gitnexus serve] Ignoring ${PUBLIC_ORIGIN_ENV}=${raw} — not a single reachable origin, ` + + `so it admits nothing. Set it to one host, optionally with a scheme and a port.`, + ); + } + + if (!boundHost || normalizeBoundHost(boundHost) !== undefined) return; + const admitted = publicOrigin + ? `accept loopback origins (localhost/127.0.0.1/[::1]) and ${publicOrigin.hostname} via ` + + `${PUBLIC_ORIGIN_ENV}.` + : `accept only loopback origins (localhost/127.0.0.1/[::1]). To admit writes from a specific ` + + `LAN address, bind --host instead of a wildcard; to admit them from a ` + + `public origin, set ${PUBLIC_ORIGIN_ENV} to it.`; + logger.warn( + { host: boundHost }, + `[gitnexus serve] Bound to a wildcard address (${boundHost}); browser write routes ${admitted}`, + ); +} + +/** Loopback + RFC1918 + link-local: the hops a self-hosted install sees. */ +export const DEFAULT_TRUST_PROXY = 'loopback, linklocal, uniquelocal'; + +/** Overrides {@link DEFAULT_TRUST_PROXY}; a public cloud LB needs it set. */ +export const TRUST_PROXY_ENV = 'GITNEXUS_TRUST_PROXY'; + +/** + * Sanity ceiling on a hop count, well past any real proxy chain — it exists to + * catch a digit string long enough to overflow to `Infinity`, not to make any + * value under it safe. The correct hop count is the exact number of proxies you + * control; each extra hop hands the caller one more entry of the chain. + */ +export const MAX_TRUST_PROXY_HOPS = 16; + +/** + * Resolve {@link TRUST_PROXY_ENV} to a value Express accepts for `trust proxy`: + * `false` (`false`/`no`/`off`, and a `0` hop count), a hop count in + * `1..{@link MAX_TRUST_PROXY_HOPS}`, or a proxy list Express can compile. + * Anything else warns and returns {@link DEFAULT_TRUST_PROXY}. Express compiles + * this value inside `app.set`, so an unvalidated bad one takes `serve` down at + * startup; a number it accepts without any range check at all. + * + * `true` is rejected, not accepted-with-a-warning. It makes `req.ip` the + * client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a + * fresh rate-limit key per request — and the limiter is the only thing in front + * of `/api/analyze` and `/api/embed`, both of which spawn workers. It is also + * not a working configuration: express-rate-limit's own `validations.trustProxy` + * throws `ERR_ERL_PERMISSIVE_TRUST_PROXY` on it. Any real chain has a knowable + * length, so a hop count or a proxy list covers every legitimate case. + */ +export function resolveTrustProxy(raw?: string): string | number | boolean { + const value = raw?.trim(); + if (!value) return DEFAULT_TRUST_PROXY; + + if (/^(true|yes|on)$/i.test(value)) { + return rejectTrustProxy( + value, + `it trusts every hop, so req.ip is read from the client-controlled leftmost ` + + `X-Forwarded-For entry and a spoofed chain earns a fresh rate-limit key per request; ` + + `express-rate-limit rejects it too. Set the number of proxies you control instead`, + ); + } + if (/^(false|no|off)$/i.test(value)) return false; + + if (/^\d+$/.test(value)) { + const hops = Number(value); + // Express tests a hop count as `i < hops`, so 0 trusts nothing, exactly as + // `false` does. Rejecting it would fall back to a default that trusts more + // than was asked for. + if (hops === 0) return false; + // The same test means a digit string long enough to overflow to Infinity + // trusts the whole chain rather than failing loudly. + if (Number.isInteger(hops) && hops <= MAX_TRUST_PROXY_HOPS) return hops; + return rejectTrustProxy(value, `expected a hop count of 0..${MAX_TRUST_PROXY_HOPS}`); + } + + try { + // Mirror Express 5's compileTrust (`express/lib/utils.js`): it splits on `,` + // and trims before handing the list to proxy-addr, which rejects unknown + // subnet names itself. proxy-addr does not split, so pass the array form. + proxyaddr.compile(value.split(',').map((entry) => entry.trim())); + } catch (err) { + return rejectTrustProxy(value, err instanceof Error ? err.message : String(err)); + } + return value; +} + +/** + * Warn when the rate limiter is about to key every request to the same address. + * + * {@link resolveTrustProxy} cannot detect this — it sees the env value and not + * what the server bound. A non-loopback bind is the shape of a deployment behind + * a load balancer, and {@link DEFAULT_TRUST_PROXY} matches only loopback and the + * private ranges, so a cloud LB outside them is never trusted: `req.ip` is the + * LB on every request and the per-IP limit silently becomes one global limit. + * + * Silent when {@link TRUST_PROXY_ENV} is set — including to a value that then + * fails validation, which {@link resolveTrustProxy} has already warned about. + * + * @param boundHost - `createServer`'s `host`. A wildcard bind warns too: it + * accepts traffic on every interface, a load balancer included. + */ +export function warnIfRateLimitKeysCollapse(boundHost?: string): void { + if (process.env[TRUST_PROXY_ENV]?.trim()) return; + if (!boundHost) return; + // normalizeBoundHost returns undefined for a wildcard or unparseable host, + // neither of which is loopback — so both warn. + if (isLoopbackHostname(normalizeBoundHost(boundHost))) return; + logger.warn( + { host: boundHost, trustProxy: DEFAULT_TRUST_PROXY }, + `[gitnexus serve] Bound to ${boundHost} with ${TRUST_PROXY_ENV} unset, so 'trust proxy' is ` + + `'${DEFAULT_TRUST_PROXY}'. A load balancer outside those ranges is not trusted, so req.ip ` + + `is the balancer on every request and the per-IP rate limit becomes one shared limit across ` + + `all callers. Set ${TRUST_PROXY_ENV} to the number of proxies you control.`, + ); +} + +function rejectTrustProxy(value: string, reason: string): string { + logger.warn( + { [TRUST_PROXY_ENV]: value }, + `[gitnexus serve] Ignoring ${TRUST_PROXY_ENV}=${value} (${reason}); falling back to ` + + `'${DEFAULT_TRUST_PROXY}'.`, + ); + return DEFAULT_TRUST_PROXY; +} diff --git a/gitnexus/test/integration/server-analyze-token-validation.test.ts b/gitnexus/test/integration/server-analyze-token-validation.test.ts index c126517b1..9c7b57150 100644 --- a/gitnexus/test/integration/server-analyze-token-validation.test.ts +++ b/gitnexus/test/integration/server-analyze-token-validation.test.ts @@ -3,7 +3,7 @@ * * The unit tests (api-analyze-token.test.ts) cover validateAnalyzeToken in * isolation; this proves the REAL production route actually wires it in — - * express.json body parsing, the requireLocalhostOrigin guard, the route + * express.json body parsing, the requireTrustedOrigin guard, the route * handler invoking the validator, and the 400 status/error shape on the wire. * Closes the gap the PR #2223 tri-review noted: "the route validation is * otherwise only reachable by booting the server." diff --git a/gitnexus/test/integration/server-http-startup.test.ts b/gitnexus/test/integration/server-http-startup.test.ts index ef36dc944..f22bcebc7 100644 --- a/gitnexus/test/integration/server-http-startup.test.ts +++ b/gitnexus/test/integration/server-http-startup.test.ts @@ -86,7 +86,21 @@ describeServeStartup('gitnexus serve HTTP startup (Express 5)', () => { } }); - it('serve boots and GET /api/health returns ok', async () => { + /** + * Spawn the built CLI's `serve` on an ephemeral port with a throwaway + * GITNEXUS_HOME, registering both for afterEach cleanup. The streams are + * captured separately so a failure message can attribute output to the right + * one, and returned as getters because they fill in after this resolves. + */ + const spawnServe = async ( + host: string, + extraEnv: Record = {}, + ): Promise<{ + child: ChildProcessWithoutNullStreams; + port: number; + stdout: () => string; + stderr: () => string; + }> => { if (!fs.existsSync(DIST_CLI)) { throw new Error(`Missing ${DIST_CLI} — run npm run build before integration tests`); } @@ -94,34 +108,40 @@ describeServeStartup('gitnexus serve HTTP startup (Express 5)', () => { const port = await allocateFreePort(); homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-serve-home-')); - proc = spawn( + const child = spawn( process.execPath, - [DIST_CLI, 'serve', '--port', String(port), '--host', '127.0.0.1'], + [DIST_CLI, 'serve', '--port', String(port), '--host', host], { cwd: REPO_ROOT, - env: { ...process.env, GITNEXUS_HOME: homeDir, NODE_OPTIONS: '' }, + env: { ...process.env, GITNEXUS_HOME: homeDir, NODE_OPTIONS: '', ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'], }, ); + proc = child; let stdout = ''; let stderr = ''; - - proc.stdout.on('data', (buf) => { + child.stdout.on('data', (buf) => { stdout += buf.toString(); }); - proc.stderr.on('data', (buf) => { + child.stderr.on('data', (buf) => { stderr += buf.toString(); }); + return { child, port, stdout: () => stdout, stderr: () => stderr }; + }; + + it('serve boots and GET /api/health returns ok', async () => { + const { child, port, stdout, stderr } = await spawnServe('127.0.0.1'); + const startedAt = Date.now(); let status = 0; let body = ''; while (Date.now() - startedAt < STARTUP_BUDGET_MS) { - if (proc.exitCode !== null) { + if (child.exitCode !== null) { throw new Error( - `serve exited ${proc.exitCode} before ready.\nstdout:\n${stdout}\nstderr:\n${stderr}`, + `serve exited ${child.exitCode} before ready.\nstdout:\n${stdout()}\nstderr:\n${stderr()}`, ); } try { @@ -138,4 +158,40 @@ describeServeStartup('gitnexus serve HTTP startup (Express 5)', () => { expect(status).toBe(200); expect(body).toContain('"status":"ok"'); }, 60_000); + + // GITNEXUS_PUBLIC_ORIGIN is what makes a public bind usable, and `serve` has + // no authentication — so it must not boot at all. Asserted end-to-end rather + // than at the unit level, because what matters is the exit code the operator + // (or a platform health check) sees, not that a function threw. + it('serve refuses to start when GITNEXUS_PUBLIC_ORIGIN is set', async () => { + const { child, port, stdout, stderr } = await spawnServe('0.0.0.0', { + GITNEXUS_PUBLIC_ORIGIN: 'https://gitnexus.example.com', + }); + + const exitCode = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error( + `serve did not exit within budget.\nstdout:\n${stdout()}\nstderr:\n${stderr()}`, + ), + ), + STARTUP_BUDGET_MS, + ); + child.on('exit', (code) => { + clearTimeout(timer); + resolve(code); + }); + child.on('error', reject); + }); + + expect(exitCode).toBe(1); + // The CLI prints the failure through cliError, so it lands on stderr; assert + // against both streams rather than pinning which one. + const output = stdout() + stderr(); + expect(output).toContain('GITNEXUS_PUBLIC_ORIGIN'); + expect(output).toContain('no authentication'); + // And it never got as far as listening. + await expect(probeHealth(port)).rejects.toThrow(); + }, 60_000); }); diff --git a/gitnexus/test/unit/api-analyze-upload.test.ts b/gitnexus/test/unit/api-analyze-upload.test.ts index 8cc1167c0..8843231ff 100644 --- a/gitnexus/test/unit/api-analyze-upload.test.ts +++ b/gitnexus/test/unit/api-analyze-upload.test.ts @@ -1,10 +1,21 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import path from 'node:path'; import fs from 'node:fs/promises'; import { Readable } from 'node:stream'; import type { IncomingMessage } from 'node:http'; import { createAnalyzeUploadHandler } from '../../src/server/analyze-upload.js'; -import { requireLocalhostOrigin, createLocalhostOriginGuard } from '../../src/server/middleware.js'; +import { PUBLIC_ORIGIN_ENV, createWriteOriginGuard } from '../../src/server/middleware.js'; + +// The guard admits GITNEXUS_PUBLIC_ORIGIN as well as loopback, and the +// rejection cases below assume none is configured. Clear the developer's +// ambient value for the file rather than inheriting it. +const ambientPublicOrigin = process.env[PUBLIC_ORIGIN_ENV]; +beforeAll(() => { + delete process.env[PUBLIC_ORIGIN_ENV]; +}); +afterAll(() => { + if (ambientPublicOrigin !== undefined) process.env[PUBLIC_ORIGIN_ENV] = ambientPublicOrigin; +}); const BOUNDARY = '----gitnexusuploadtest'; @@ -258,7 +269,7 @@ describe('createAnalyzeUploadHandler', () => { }); }); -describe('requireLocalhostOrigin', () => { +describe('createWriteOriginGuard (no bound host)', () => { function call(origin: string | undefined): { passed: boolean; status: number } { let passed = false; let status = 0; @@ -269,7 +280,7 @@ describe('requireLocalhostOrigin', () => { return { json: () => {} }; }, } as never; - requireLocalhostOrigin(req, res, () => { + createWriteOriginGuard()(req, res, () => { passed = true; }); return { passed, status }; @@ -288,7 +299,7 @@ describe('requireLocalhostOrigin', () => { expect(r.status).toBe(403); }); - it('rejects RFC1918 origins when no boundHost is set (default guard)', () => { + it('rejects RFC1918 origins when no boundHost is set', () => { expect(call('http://10.0.0.1:4173').passed).toBe(false); expect(call('http://172.16.1.21:4173').passed).toBe(false); expect(call('http://192.168.1.100:4173').passed).toBe(false); @@ -301,12 +312,12 @@ describe('requireLocalhostOrigin', () => { }); }); -describe('createLocalhostOriginGuard (bound host)', () => { +describe('createWriteOriginGuard (bound host)', () => { function callWith( boundHost: string, origin: string | undefined, ): { passed: boolean; status: number; body?: { error?: string; code?: string } } { - const guard = createLocalhostOriginGuard(boundHost); + const guard = createWriteOriginGuard(boundHost); let passed = false; let status = 0; let body: { error?: string; code?: string } | undefined; diff --git a/gitnexus/test/unit/cors.test.ts b/gitnexus/test/unit/cors.test.ts index 711fe7fd8..d1bcfe792 100644 --- a/gitnexus/test/unit/cors.test.ts +++ b/gitnexus/test/unit/cors.test.ts @@ -13,8 +13,20 @@ * - https://gitnexus.vercel.app → allowed * - Everything else → rejected */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll, afterEach, beforeAll } from 'vitest'; import { isAllowedOrigin } from '../../src/server/api.js'; +import { createPublicOriginMatcher } from '../../src/server/middleware.js'; + +// isAllowedOrigin consults GITNEXUS_PUBLIC_ORIGIN, so every expectation below +// assumes it is unset unless the test sets it. Clear the developer's ambient +// value for the file rather than inheriting it. +const ambientPublicOrigin = process.env.GITNEXUS_PUBLIC_ORIGIN; +beforeAll(() => { + delete process.env.GITNEXUS_PUBLIC_ORIGIN; +}); +afterAll(() => { + if (ambientPublicOrigin !== undefined) process.env.GITNEXUS_PUBLIC_ORIGIN = ambientPublicOrigin; +}); // ─── No origin (non-browser / curl) ────────────────────────────────── @@ -181,3 +193,147 @@ describe('isAllowedOrigin: rejected origins', () => { expect(isAllowedOrigin('http://172.16.5.1:3000')).toBe(true); }); }); + +describe('isAllowedOrigin: GITNEXUS_PUBLIC_ORIGIN', () => { + // Back to the file's cleared baseline, not to the ambient value afterAll + // restores — the tests above assume it stays unset. + afterEach(() => { + delete process.env.GITNEXUS_PUBLIC_ORIGIN; + }); + + it('allows the configured origin as a full URL or a bare host', () => { + process.env.GITNEXUS_PUBLIC_ORIGIN = 'https://app.example.com'; + expect(isAllowedOrigin('https://app.example.com')).toBe(true); + process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com'; + expect(isAllowedOrigin('https://app.example.com')).toBe(true); + }); + + it('enforces the port when the configured value carries one', () => { + process.env.GITNEXUS_PUBLIC_ORIGIN = 'https://app.example.com:8443'; + expect(isAllowedOrigin('https://app.example.com:8443')).toBe(true); + expect(isAllowedOrigin('https://app.example.com:9999')).toBe(false); + // The browser elides the default port, so a bare origin is port 443 here. + expect(isAllowedOrigin('https://app.example.com')).toBe(false); + }); + + it('accepts any port when the configured value carries none', () => { + process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com'; + expect(isAllowedOrigin('https://app.example.com')).toBe(true); + expect(isAllowedOrigin('https://app.example.com:8443')).toBe(true); + expect(isAllowedOrigin('https://app.example.com:9999')).toBe(true); + }); + + it('matches a configured default port against an origin that elides it', () => { + process.env.GITNEXUS_PUBLIC_ORIGIN = 'https://app.example.com:443'; + expect(isAllowedOrigin('https://app.example.com')).toBe(true); + expect(isAllowedOrigin('https://app.example.com:8443')).toBe(false); + }); + + it('handles a bracketed IPv6 literal with a port, with and without a scheme', () => { + // No scheme means https, so the http probes below are scheme mismatches. + process.env.GITNEXUS_PUBLIC_ORIGIN = '[2001:db8::1]:8080'; + expect(isAllowedOrigin('https://[2001:db8::1]:8080')).toBe(true); + expect(isAllowedOrigin('https://[2001:db8::1]:9090')).toBe(false); + expect(isAllowedOrigin('http://[2001:db8::1]:8080')).toBe(false); + process.env.GITNEXUS_PUBLIC_ORIGIN = 'http://[2001:db8::1]:8080'; + expect(isAllowedOrigin('http://[2001:db8::1]:8080')).toBe(true); + expect(isAllowedOrigin('https://[2001:db8::1]:8080')).toBe(false); + }); + + it('accepts any port on a bare IPv6 literal, which carries none', () => { + process.env.GITNEXUS_PUBLIC_ORIGIN = '[2001:db8::1]'; + expect(isAllowedOrigin('https://[2001:db8::1]:4173')).toBe(true); + // Non-canonical forms compress to the same hostname a browser Origin has. + process.env.GITNEXUS_PUBLIC_ORIGIN = '2001:db8:0:0:0:0:0:1'; + expect(isAllowedOrigin('https://[2001:db8::1]:4173')).toBe(true); + }); + + it('does not widen to other hosts', () => { + process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com'; + expect(isAllowedOrigin('https://evil.example.com')).toBe(false); + expect(isAllowedOrigin('https://app.example.com.evil.com')).toBe(false); + }); + + it('enforces the scheme when the configured value carries one', () => { + process.env.GITNEXUS_PUBLIC_ORIGIN = 'https://app.example.com'; + expect(isAllowedOrigin('http://app.example.com')).toBe(false); + }); + + // A bare host is the platform service-discovery form, and those terminate + // TLS — so it means https, not either scheme. Accepting either would make it + // an http downgrade path into the read allowlist and the write guard alike. + it('reads a bare host as https, not as either scheme', () => { + process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com'; + expect(isAllowedOrigin('https://app.example.com')).toBe(true); + expect(isAllowedOrigin('http://app.example.com')).toBe(false); + }); + + it('accepts http on a bare host only when http:// is spelled out', () => { + process.env.GITNEXUS_PUBLIC_ORIGIN = 'http://app.example.com'; + expect(isAllowedOrigin('http://app.example.com')).toBe(true); + expect(isAllowedOrigin('https://app.example.com')).toBe(false); + }); + + it('still rejects non-http protocols on the configured host', () => { + process.env.GITNEXUS_PUBLIC_ORIGIN = 'app.example.com'; + expect(isAllowedOrigin('ftp://app.example.com')).toBe(false); + }); + + it('is inert when unset', () => { + delete process.env.GITNEXUS_PUBLIC_ORIGIN; + expect(isAllowedOrigin('https://app.example.com')).toBe(false); + }); +}); + +// A value that yields a matcher no real Origin can satisfy is worse than no +// value at all: it reads as configured, so the wildcard-bind warning in +// createServer reports a working origin where there is none. +describe('createPublicOriginMatcher: values that are not one reachable host', () => { + it.each([ + ['', 'empty'], + [' ', 'whitespace only'], + ['*', 'a wildcard'], + ['a.com,b.com', 'a comma-separated list'], + ['a.com;b.com', 'a semicolon-separated list'], + ['8080', 'a bare port number — new URL reads it as the integer IP 0.0.31.144'], + ['under score', 'an embedded space'], + ['a.com:99999', 'a port out of range'], + ['a.com:0', 'port 0, which parses but which no browser ever sends'], + ['https://a.com:00', 'port 00, which normalizes to 0 rather than to a default'], + ['ftp://a.com', 'a non-http scheme'], + ['https://a.com/ui', 'a path, which an Origin never has'], + ['0.0.0.0', 'a wildcard bind, which has no host identity'], + ['a.com.', 'a trailing dot — a legal FQDN, but not what a browser sends'], + ['https://a.com.:8443', 'a trailing dot with a scheme and a port'], + ])('returns undefined for %j (%s)', (raw) => { + expect(createPublicOriginMatcher(raw)).toBeUndefined(); + }); + + it('returns undefined when the env var is unset', () => { + expect(createPublicOriginMatcher(undefined)).toBeUndefined(); + }); +}); + +describe('createPublicOriginMatcher: values that resolve to one host', () => { + it('tolerates the trailing slash a pasted URL carries', () => { + const matcher = createPublicOriginMatcher('https://app.example.com/'); + expect(matcher?.hostname).toBe('app.example.com'); + expect(matcher?.matches(new URL('https://app.example.com'))).toBe(true); + }); + + // Guards the port-0 rejection above from over-reaching: a leading zero on a + // real port normalizes to that port, not to 0. + it('reads a leading-zero port as the port it normalizes to', () => { + const matcher = createPublicOriginMatcher('http://a.com:0080'); + expect(matcher?.matches(new URL('http://a.com'))).toBe(true); + expect(matcher?.matches(new URL('http://a.com:80'))).toBe(true); + expect(matcher?.matches(new URL('http://a.com:8080'))).toBe(false); + }); + + it('reports the hostname it resolved, for the startup log line', () => { + expect(createPublicOriginMatcher('https://App.Example.com:8443')?.hostname).toBe( + 'app.example.com', + ); + expect(createPublicOriginMatcher('2001:db8:0:0:0:0:0:1')?.hostname).toBe('[2001:db8::1]'); + }); +}); diff --git a/gitnexus/test/unit/middleware-public-origin.test.ts b/gitnexus/test/unit/middleware-public-origin.test.ts new file mode 100644 index 000000000..53d7c13ba --- /dev/null +++ b/gitnexus/test/unit/middleware-public-origin.test.ts @@ -0,0 +1,294 @@ +/** + * Unit Tests: the write-route origin guard's port awareness, and what it + * reports at startup. + * + * cors.test.ts covers the read side (isAllowedOrigin) of the same matcher. + * This file covers the write side — createWriteOriginGuard — where a + * mismatch is a 403 rather than a missing CORS header, plus the port-aware + * bound-host comparison and logOriginPolicy's startup diagnostics. + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + PUBLIC_ORIGIN_ENV, + assertServeAuthForPublicOrigin, + createWriteOriginGuard, + isServeAuthConfigured, + logOriginPolicy, +} from '../../src/server/middleware.js'; +import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js'; + +// The bound-host cases never set the var, so clear the developer's ambient +// value for the file and return each test to that cleared baseline. +const ambientPublicOrigin = process.env[PUBLIC_ORIGIN_ENV]; +beforeAll(() => { + delete process.env[PUBLIC_ORIGIN_ENV]; +}); +afterAll(() => { + if (ambientPublicOrigin !== undefined) process.env[PUBLIC_ORIGIN_ENV] = ambientPublicOrigin; +}); + +afterEach(() => { + delete process.env[PUBLIC_ORIGIN_ENV]; +}); + +function setPublicOrigin(value: string | undefined): void { + if (value === undefined) delete process.env[PUBLIC_ORIGIN_ENV]; + else process.env[PUBLIC_ORIGIN_ENV] = value; +} + +interface GuardResult { + passed: boolean; + status: number; + body?: { error?: string; code?: string }; +} + +// The guard snapshots the env var at construction, so callers set it first. +function callGuard( + boundHost: string | undefined, + boundPort: number | undefined, + origin: string, +): GuardResult { + const guard = createWriteOriginGuard(boundHost, boundPort); + let passed = false; + let status = 0; + let body: { error?: string; code?: string } | undefined; + const req = { headers: { origin } } as never; + const res = { + status: (c: number) => { + status = c; + return { + json: (b: { error?: string; code?: string }) => { + body = b; + }, + }; + }, + } as never; + guard(req, res, () => { + passed = true; + }); + return { passed, status, body }; +} + +describe('createWriteOriginGuard — bound host is matched on its port', () => { + it('admits the bound host on the bound port', () => { + expect(callGuard('192.168.1.10', 8443, 'http://192.168.1.10:8443').passed).toBe(true); + }); + + it('rejects the bound host on a different port with origin_not_allowed', () => { + const res = callGuard('192.168.1.10', 8443, 'http://192.168.1.10:9999'); + expect(res.passed).toBe(false); + expect(res.status).toBe(403); + expect(res.body?.code).toBe('origin_not_allowed'); + }); + + it('rejects the bound host on the default port when it is not the bound one', () => { + // The pre-existing bug: `hostname === normalizedBoundHost` matched here. + expect(callGuard('192.168.1.10', 8443, 'http://192.168.1.10').passed).toBe(false); + expect(callGuard('192.168.1.10', 8443, 'https://192.168.1.10').passed).toBe(false); + }); + + it('treats an elided default port as that port', () => { + expect(callGuard('192.168.1.10', 80, 'http://192.168.1.10').passed).toBe(true); + expect(callGuard('192.168.1.10', 443, 'https://192.168.1.10').passed).toBe(true); + expect(callGuard('192.168.1.10', 80, 'https://192.168.1.10').passed).toBe(false); + }); + + it('matches any port when no bound port is given', () => { + expect(callGuard('192.168.1.10', undefined, 'http://192.168.1.10:9999').passed).toBe(true); + }); + + it('keeps loopback port-agnostic — the dev UI runs on its own port', () => { + expect(callGuard('192.168.1.10', 8443, 'http://localhost:5173').passed).toBe(true); + expect(callGuard('192.168.1.10', 8443, 'http://127.0.0.1:4173').passed).toBe(true); + expect(callGuard('192.168.1.10', 8443, 'http://[::1]:4173').passed).toBe(true); + }); +}); + +describe('createWriteOriginGuard — public origin is matched on its port', () => { + it('admits an exact scheme/host/port match on a wildcard bind', () => { + setPublicOrigin('https://app.example.com:8443'); + expect(callGuard('0.0.0.0', 3000, 'https://app.example.com:8443').passed).toBe(true); + }); + + it('rejects a port mismatch with a 403 and origin_not_allowed', () => { + setPublicOrigin('https://app.example.com:8443'); + const res = callGuard('0.0.0.0', 3000, 'https://app.example.com:9999'); + expect(res.passed).toBe(false); + expect(res.status).toBe(403); + expect(res.body?.code).toBe('origin_not_allowed'); + }); + + it('rejects a scheme mismatch when the configured value carries a scheme', () => { + setPublicOrigin('https://app.example.com'); + expect(callGuard('0.0.0.0', 3000, 'http://app.example.com').passed).toBe(false); + }); + + // A bare host is permissive on the port but NOT on the scheme: it defaults to + // https, so `app.example.com` is not an http downgrade path into the write + // routes. Plain http needs the explicit form. + it('admits any port for a bare configured host, but only over https', () => { + setPublicOrigin('app.example.com'); + expect(callGuard('0.0.0.0', 3000, 'https://app.example.com:9999').passed).toBe(true); + expect(callGuard('0.0.0.0', 3000, 'https://app.example.com').passed).toBe(true); + expect(callGuard('0.0.0.0', 3000, 'http://app.example.com').passed).toBe(false); + expect(callGuard('0.0.0.0', 3000, 'http://app.example.com:9999').passed).toBe(false); + }); + + it('admits http for a bare host only when http:// is spelled out', () => { + setPublicOrigin('http://app.example.com'); + expect(callGuard('0.0.0.0', 3000, 'http://app.example.com:9999').passed).toBe(true); + expect(callGuard('0.0.0.0', 3000, 'https://app.example.com').passed).toBe(false); + }); + + it('admits a bracketed IPv6 literal, on any port when configured without one', () => { + setPublicOrigin('[2001:db8::1]'); + expect(callGuard('0.0.0.0', 3000, 'https://[2001:db8::1]:4173').passed).toBe(true); + setPublicOrigin('https://[2001:db8::1]:8080'); + expect(callGuard('0.0.0.0', 3000, 'https://[2001:db8::1]:8080').passed).toBe(true); + expect(callGuard('0.0.0.0', 3000, 'https://[2001:db8::1]:4173').passed).toBe(false); + }); + + // A trailing dot is a legal FQDN that survives `new URL` as `example.com.`, + // but a browser sends `example.com` — so it built a matcher nothing could + // satisfy while logOriginPolicy reported it as working. + it('admits nothing for a trailing-dot hostname', () => { + setPublicOrigin('app.example.com.'); + expect(callGuard('0.0.0.0', 3000, 'https://app.example.com').passed).toBe(false); + expect(callGuard('0.0.0.0', 3000, 'https://app.example.com.').passed).toBe(false); + }); + + it('admits nothing extra when the configured value is not one reachable host', () => { + setPublicOrigin('a.com,b.com'); + expect(callGuard('0.0.0.0', 3000, 'https://a.com').passed).toBe(false); + expect(callGuard('0.0.0.0', 3000, 'https://b.com').passed).toBe(false); + }); + + it('leaves the Origin-less passthrough alone — the CLI sends no Origin', () => { + setPublicOrigin('app.example.com'); + const guard = createWriteOriginGuard('0.0.0.0', 3000); + let passed = false; + guard({ headers: {} } as never, {} as never, () => { + passed = true; + }); + expect(passed).toBe(true); + }); +}); + +describe('logOriginPolicy', () => { + let cap: LoggerCapture; + beforeEach(() => { + cap = _captureLogger(); + }); + afterEach(() => { + cap.restore(); + }); + + const infos = () => cap.records().filter((r) => r.level === 30); + const warns = () => cap.records().filter((r) => r.level === 40); + + it('says nothing on a specific bind with no public origin', () => { + setPublicOrigin(undefined); + logOriginPolicy('192.168.1.10'); + expect(cap.records()).toEqual([]); + }); + + it('names the resolved hostname at info when a public origin is configured', () => { + setPublicOrigin('https://App.Example.com:8443'); + logOriginPolicy('192.168.1.10'); + expect(infos()).toHaveLength(1); + expect(String(infos()[0].msg)).toContain('app.example.com'); + expect(infos()[0].hostname).toBe('app.example.com'); + expect(warns()).toEqual([]); + }); + + // The four-way matrix: wildcard bind × public origin absent/valid/invalid. + it('warns on a wildcard bind with no public origin, pointing at both remedies', () => { + setPublicOrigin(undefined); + logOriginPolicy('0.0.0.0'); + expect(warns()).toHaveLength(1); + expect(String(warns()[0].msg)).toContain('wildcard address (0.0.0.0)'); + expect(String(warns()[0].msg)).toContain('--host'); + expect(String(warns()[0].msg)).toContain(PUBLIC_ORIGIN_ENV); + }); + + it('still warns on a wildcard bind with a valid public origin, and names it', () => { + setPublicOrigin('https://app.example.com'); + logOriginPolicy('::'); + expect(infos()).toHaveLength(1); + expect(warns()).toHaveLength(1); + expect(String(warns()[0].msg)).toContain('app.example.com'); + }); + + it('diagnoses an unusable public origin rather than reporting one', () => { + setPublicOrigin('a.com,b.com'); + logOriginPolicy('0.0.0.0'); + expect(infos()).toEqual([]); + // One for the unusable value, one for the wildcard bind it fails to rescue. + expect(warns()).toHaveLength(2); + expect(String(warns()[0].msg)).toContain('a.com,b.com'); + expect(String(warns()[1].msg)).toContain('--host'); + }); + + it('diagnoses an unusable public origin on a specific bind too', () => { + setPublicOrigin('*'); + logOriginPolicy('192.168.1.10'); + expect(warns()).toHaveLength(1); + expect(String(warns()[0].msg)).toContain(PUBLIC_ORIGIN_ENV); + }); + + it('does not report a trailing-dot hostname as a working origin', () => { + setPublicOrigin('app.example.com.'); + logOriginPolicy('0.0.0.0'); + expect(infos()).toEqual([]); + expect(String(warns()[0].msg)).toContain('app.example.com.'); + }); +}); + +/** + * `serve` has no authentication, and the guard above passes every request with + * no Origin header — so GITNEXUS_PUBLIC_ORIGIN, the setting that makes a public + * bind usable, must not be usable until the lock exists. + */ +describe('assertServeAuthForPublicOrigin', () => { + it('is a no-op when no public origin is configured', () => { + setPublicOrigin(undefined); + expect(() => assertServeAuthForPublicOrigin()).not.toThrow(); + }); + + it.each([' ', ''])('treats a blank value (%j) as unset', (raw) => { + setPublicOrigin(raw); + expect(() => assertServeAuthForPublicOrigin()).not.toThrow(); + }); + + it('throws when a public origin is configured and no auth is', () => { + setPublicOrigin('https://app.example.com'); + expect(() => assertServeAuthForPublicOrigin()).toThrow(/has no authentication yet/); + }); + + it('names the variable and the value in the failure, and points at both remedies', () => { + setPublicOrigin('https://app.example.com'); + let message = ''; + try { + assertServeAuthForPublicOrigin(); + } catch (err) { + message = err instanceof Error ? err.message : String(err); + } + expect(message).toContain(PUBLIC_ORIGIN_ENV); + expect(message).toContain('https://app.example.com'); + expect(message).toContain('DELETE /api/repo'); + expect(message).toContain('loopback'); + }); + + // Even a value the matcher would reject throws: the operator's intent to serve + // a public origin is the risk, and an unusable value is not a safer one. + it.each(['a.com,b.com', '*', '8080'])('throws on an unusable value too: %s', (raw) => { + setPublicOrigin(raw); + expect(() => assertServeAuthForPublicOrigin()).toThrow(); + }); + + // The auth change flips this predicate; the gate above is then satisfiable + // without rewriting it. Pinned so the flip cannot happen unnoticed. + it('reports no auth configured, since serve has none', () => { + expect(isServeAuthConfigured()).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/middleware-trust-proxy.test.ts b/gitnexus/test/unit/middleware-trust-proxy.test.ts new file mode 100644 index 000000000..b0bcb57bc --- /dev/null +++ b/gitnexus/test/unit/middleware-trust-proxy.test.ts @@ -0,0 +1,191 @@ +/** + * Unit Tests: GITNEXUS_TRUST_PROXY resolution + * + * Express accepts a boolean, a hop count, or a comma-separated proxy list for + * `trust proxy`, and compiles the value inside `app.set` — so an unvalidated + * env value takes the server down at startup, or (for a number it cannot + * range-check) silently trusts every hop. resolveTrustProxy validates first. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + DEFAULT_TRUST_PROXY, + MAX_TRUST_PROXY_HOPS, + TRUST_PROXY_ENV, + resolveTrustProxy, + warnIfRateLimitKeysCollapse, +} from '../../src/server/middleware.js'; +import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js'; + +// `logger` is a Proxy with only a `get` trap, so vi.spyOn cannot replace +// `warn` on it; the module's own capture helper redirects the destination. +let cap: LoggerCapture; +beforeEach(() => { + cap = _captureLogger(); +}); +afterEach(() => { + cap.restore(); +}); + +const warnings = (): string[] => + cap + .records() + .filter((r) => r.level === 40) + .map((r) => String(r.msg)); + +describe('resolveTrustProxy — accepted', () => { + it('falls back to the loopback-scoped default when unset or blank', () => { + expect(resolveTrustProxy(undefined)).toBe(DEFAULT_TRUST_PROXY); + expect(resolveTrustProxy('')).toBe(DEFAULT_TRUST_PROXY); + expect(resolveTrustProxy(' ')).toBe(DEFAULT_TRUST_PROXY); + expect(warnings()).toEqual([]); + }); + + it('accepts the default it falls back to, so the fallback can never throw', () => { + expect(() => resolveTrustProxy(DEFAULT_TRUST_PROXY)).not.toThrow(); + expect(resolveTrustProxy(DEFAULT_TRUST_PROXY)).toBe(DEFAULT_TRUST_PROXY); + expect(warnings()).toEqual([]); + }); + + const hopCounts = Array.from({ length: MAX_TRUST_PROXY_HOPS }, (_, i) => i + 1); + it.each(hopCounts)('accepts hop count %i', (hops) => { + expect(() => resolveTrustProxy(String(hops))).not.toThrow(); + expect(resolveTrustProxy(String(hops))).toBe(hops); + expect(warnings()).toEqual([]); + }); + + it('trims surrounding whitespace off a hop count', () => { + expect(resolveTrustProxy(' 2 ')).toBe(2); + }); + + // Express tests a hop count as `i < hops`, so 0 and false are the same + // setting. Rejecting 0 would fall back to a default that trusts more. + it('normalizes a hop count of 0 to false rather than rejecting it', () => { + expect(resolveTrustProxy('0')).toBe(false); + expect(warnings()).toEqual([]); + }); + + it.each([ + ['false', false], + ['FALSE', false], + ['no', false], + ['NO', false], + ['off', false], + ['OFF', false], + ] as const)('accepts %s as a boolean without warning', (raw, expected) => { + expect(resolveTrustProxy(raw)).toBe(expected); + expect(warnings()).toEqual([]); + }); + + it.each(['loopback', 'linklocal', 'uniquelocal', '10.0.0.0/8, 127.0.0.1'])( + 'accepts the proxy list %s verbatim', + (raw) => { + expect(() => resolveTrustProxy(raw)).not.toThrow(); + expect(resolveTrustProxy(raw)).toBe(raw); + expect(warnings()).toEqual([]); + }, + ); +}); + +// `true` trusts every hop, which makes req.ip the client-controlled leftmost +// X-Forwarded-For entry — a fresh rate-limit key per spoofed request, in front +// of the two routes that spawn workers. express-rate-limit's own +// validations.trustProxy throws ERR_ERL_PERMISSIVE_TRUST_PROXY on it, so it was +// never a working configuration either. Rejected, not warned. +describe('resolveTrustProxy — rejects a trust-everything value', () => { + it.each(['true', 'TRUE', 'yes', 'YES', 'on', 'ON'])('falls back to the default on %s', (raw) => { + expect(resolveTrustProxy(raw)).toBe(DEFAULT_TRUST_PROXY); + const warned = warnings(); + expect(warned).toHaveLength(1); + expect(warned[0]).toContain(TRUST_PROXY_ENV); + expect(warned[0]).toContain(raw); + expect(warned[0]).toContain('X-Forwarded-For'); + }); + + it('never returns true, so express-rate-limit cannot reject the value we set', () => { + for (const raw of ['true', 'yes', 'on', 'TRUE', '1', '16', 'loopback', '0', 'false']) { + expect(resolveTrustProxy(raw)).not.toBe(true); + } + }); +}); + +describe('resolveTrustProxy — rejected', () => { + it.each([ + ['garbage', 'an unknown subnet name'], + ['*', 'a wildcard'], + ['9'.repeat(400), 'a hop count that overflows to Infinity'], + [String(MAX_TRUST_PROXY_HOPS + 1), 'a hop count above the range'], + ['-1', 'a negative hop count'], + ['1.5', 'a fractional hop count'], + ['a.com;b.com', 'a semicolon-separated list'], + ])('falls back to the default on %#: %s', (raw) => { + expect(resolveTrustProxy(raw)).toBe(DEFAULT_TRUST_PROXY); + const warned = warnings(); + expect(warned).toHaveLength(1); + expect(warned[0]).toContain(TRUST_PROXY_ENV); + expect(warned[0]).toContain(raw); + }); +}); + +// resolveTrustProxy sees only the env value; whether the default is about to +// collapse the per-IP rate limit to one global limit depends on what we bound. +describe('warnIfRateLimitKeysCollapse', () => { + const original = process.env[TRUST_PROXY_ENV]; + beforeEach(() => { + delete process.env[TRUST_PROXY_ENV]; + }); + afterEach(() => { + if (original === undefined) delete process.env[TRUST_PROXY_ENV]; + else process.env[TRUST_PROXY_ENV] = original; + }); + + it.each(['localhost', '127.0.0.1', '::1', '[::1]'])('stays silent on a %s bind', (host) => { + warnIfRateLimitKeysCollapse(host); + expect(warnings()).toEqual([]); + }); + + it.each([undefined, ''])('stays silent when no host is given (%o)', (host) => { + warnIfRateLimitKeysCollapse(host); + expect(warnings()).toEqual([]); + }); + + it.each([ + ['0.0.0.0', 'a wildcard bind accepts LB traffic too'], + ['::', 'the IPv6 wildcard likewise'], + ['192.168.1.10', 'a LAN bind'], + ['203.0.113.7', 'a public bind'], + ])('warns on %s (%s)', (host) => { + warnIfRateLimitKeysCollapse(host); + const warned = warnings(); + expect(warned).toHaveLength(1); + expect(warned[0]).toContain(TRUST_PROXY_ENV); + expect(warned[0]).toContain(host); + expect(warned[0]).toContain('one shared limit'); + }); + + it.each(['1', 'loopback', 'garbage'])( + 'stays silent when %s is configured, valid or not', + (raw) => { + // An invalid value is resolveTrustProxy's warning to make, not a second one + // here — the operator has already been told about that value. + process.env[TRUST_PROXY_ENV] = raw; + warnIfRateLimitKeysCollapse('0.0.0.0'); + expect(warnings()).toEqual([]); + }, + ); + + it('treats a whitespace-only value as unset', () => { + process.env[TRUST_PROXY_ENV] = ' '; + warnIfRateLimitKeysCollapse('0.0.0.0'); + expect(warnings()).toHaveLength(1); + }); +}); + +describe('resolveTrustProxy — contract', () => { + it('names the env var it reads', () => { + expect(TRUST_PROXY_ENV).toBe('GITNEXUS_TRUST_PROXY'); + }); + + it('defaults to loopback plus the private ranges', () => { + expect(DEFAULT_TRUST_PROXY).toBe('loopback, linklocal, uniquelocal'); + }); +}); diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index ac977ab9a..85f3b9253 100644 --- a/gitnexus/test/unit/rate-limit.test.ts +++ b/gitnexus/test/unit/rate-limit.test.ts @@ -23,6 +23,12 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import os from 'node:os'; import { createRouteLimiter } from '../../src/server/validation.js'; +import { + DEFAULT_TRUST_PROXY, + TRUST_PROXY_ENV, + resolveTrustProxy, +} from '../../src/server/middleware.js'; +import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js'; let tmpFile: string; @@ -246,13 +252,13 @@ describe('production routes — rate-limit middleware wiring', () => { it('POST /api/analyze is wired with createRouteLimiter', () => { // Tolerate Prettier wrapping the registration across lines (it does once - // the route carries extra middleware like requireLocalhostOrigin). + // the route carries extra middleware like requireTrustedOrigin). expect(apiSource).toMatch(/app\.post\(\s*'\/api\/analyze',\s*createRouteLimiter\(/); }); it('POST /api/embed is wired with createRouteLimiter', () => { // Tolerate Prettier wrapping the registration across lines (it does once - // the route carries extra middleware like requireLocalhostOrigin). + // the route carries extra middleware like requireTrustedOrigin). expect(apiSource).toMatch(/app\.post\(\s*'\/api\/embed',\s*createRouteLimiter\(/); }); @@ -269,9 +275,13 @@ describe('production routes — rate-limit middleware wiring', () => { expect(apiSource).not.toMatch(/app\.options\(\s*'\/\*'/); }); - it('createServer wires trust proxy to loopback/linklocal/uniquelocal', () => { + // Source-level because createServer listens and cannot be built here. Kept + // deliberately loose: the effective-value describe below covers resolution, + // so all this has to pin down is that createServer routes the env var + // through resolveTrustProxy rather than setting a literal. + it('createServer reads trust proxy from GITNEXUS_TRUST_PROXY', () => { expect(apiSource).toMatch( - /app\.set\(\s*'trust proxy'\s*,\s*'loopback,\s*linklocal,\s*uniquelocal'\s*\)/, + /app\.set\(\s*'trust proxy'\s*,\s*resolveTrustProxy\([^)]*TRUST_PROXY_ENV/, ); }); @@ -318,3 +328,107 @@ describe('validation.ts — IPv6 key normalisation (#1360)', () => { expect(validationSource).toMatch(/ipKeyGenerator\(ip\)/); }); }); + +// The effective `trust proxy` Express ends up with, rather than the text of the +// line that sets it. `createServer` listens and installs signal handlers, so it +// cannot be built here; this mirrors its one `app.set` expression instead. +describe('trust proxy — effective value from GITNEXUS_TRUST_PROXY', () => { + const saved = process.env[TRUST_PROXY_ENV]; + + // The rejection cases below warn; capture keeps them out of the suite output. + let cap: LoggerCapture; + beforeEach(() => { + cap = _captureLogger(); + }); + + afterEach(() => { + cap.restore(); + if (saved === undefined) delete process.env[TRUST_PROXY_ENV]; + else process.env[TRUST_PROXY_ENV] = saved; + }); + + const effectiveTrustProxy = (value: string | undefined): unknown => { + if (value === undefined) delete process.env[TRUST_PROXY_ENV]; + else process.env[TRUST_PROXY_ENV] = value; + const app = express(); + app.set('trust proxy', resolveTrustProxy(process.env[TRUST_PROXY_ENV])); + return app.get('trust proxy'); + }; + + it('defaults to the loopback-scoped list when the env var is unset', () => { + expect(DEFAULT_TRUST_PROXY).toBe('loopback, linklocal, uniquelocal'); + expect(effectiveTrustProxy(undefined)).toBe('loopback, linklocal, uniquelocal'); + }); + + it('carries a configured hop count through to Express', () => { + expect(effectiveTrustProxy('3')).toBe(3); + }); + + it('carries a configured proxy list through to Express', () => { + expect(effectiveTrustProxy('10.0.0.0/8, 127.0.0.1')).toBe('10.0.0.0/8, 127.0.0.1'); + }); + + // Express 5 compiles `trust proxy` inside `app.set`, so an unvalidated bad + // value would throw during createServer instead of resolving to a default. + it('never hands Express a value it rejects', () => { + expect(() => effectiveTrustProxy('garbage')).not.toThrow(); + expect(effectiveTrustProxy('garbage')).toBe(DEFAULT_TRUST_PROXY); + expect(effectiveTrustProxy('9'.repeat(400))).toBe(DEFAULT_TRUST_PROXY); + }); + + // The behaviour the describe block below demonstrates is why: `true` cannot + // reach Express through the env var at all. + it('never hands Express `true`', () => { + expect(effectiveTrustProxy('true')).toBe(DEFAULT_TRUST_PROXY); + expect(effectiveTrustProxy('yes')).toBe(DEFAULT_TRUST_PROXY); + expect(effectiveTrustProxy('on')).toBe(DEFAULT_TRUST_PROXY); + }); +}); + +// Why resolveTrustProxy rejects `true`: Express then reads the leftmost +// X-Forwarded-For entry, which the client controls, so rotating it hands the +// limiter a fresh key per request — unbounded, in front of the two routes that +// spawn workers. A hop count reads from the right instead and is immune. Both +// apps below see the same requests; only the setting differs. `true` is set +// directly here, since the env var can no longer produce it. +describe('trust proxy — a rotating X-Forwarded-For defeats `true` but not a hop count', () => { + const buildProxiedApp = (trustProxy: boolean | number): Express => { + const app = express(); + app.set('trust proxy', trustProxy); + app.get('/test/file', createRouteLimiter({ windowMs: 2000, limit: 2 }), async (_req, res) => { + const content = await fs.readFile(tmpFile, 'utf-8'); + res.json({ content }); + }); + return app; + }; + + // Leftmost entry rotates per request; the rightmost (the hop the loopback + // proxy claims to have received from) stays fixed. + const statusesUnderRotatingForwardedFor = async (app: Express): Promise => { + const { server, baseUrl } = await startServer(app); + try { + const statuses: number[] = []; + for (let i = 1; i <= 4; i++) { + const res = await fetch(`${baseUrl}/test/file`, { + headers: { 'X-Forwarded-For': `203.0.113.${i}, 198.51.100.7` }, + }); + statuses.push(res.status); + } + return statuses; + } finally { + await stopServer(server); + } + }; + + it('collapses to a single limiter key with a hop count of 1', async () => { + expect(await statusesUnderRotatingForwardedFor(buildProxiedApp(1))).toEqual([ + 200, 200, 429, 429, + ]); + }); + + it('gets a fresh limiter key per request with `true`', async () => { + expect(await statusesUnderRotatingForwardedFor(buildProxiedApp(true))).toEqual([ + 200, 200, 200, 200, + ]); + }); +}); From a857f4c5a6f7899772a49c2a264c1bec1fa4f69a Mon Sep 17 00:00:00 2001 From: Parafee41 Date: Wed, 5 Aug 2026 16:15:32 +0800 Subject: [PATCH 14/17] docs(taint): document per-language model files (#2809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(taint): document per-language model files * docs(taint): link language-specific model tests --------- Co-authored-by: Gergő Magyar --- .../gitnexus/gitnexus-taint-analysis/SKILL.md | 20 ++++++++++++------- .../skills/gitnexus-taint-analysis/SKILL.md | 20 ++++++++++++------- gitnexus/skills/gitnexus-taint-analysis.md | 20 ++++++++++++------- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md index 9bffffdac..e4069f9a8 100644 --- a/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md @@ -148,13 +148,19 @@ finding is NOT proof of safety. ## Adding a source / sink / sanitizer -Edit the language model in `taint/typescript-model.ts` (registered via the -explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The -spec is hashable data (no functions). A sanitizer's `neutralizes` lists the -EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the -finding (or its absence) in `test/unit/taint/` (real-source harness: -`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is -`test/integration/cfg/`. +Taint models cover four `SupportedLanguages` ids across three files: +TypeScript and JavaScript use `taint/typescript-model.ts`, Python uses +`taint/python-model.ts`, and Java uses `taint/java-model.ts`. Edit the model +for the language you are targeting. The explicit +`registerBuiltinTaintModels` seam in `typescript-model.ts` registers all four; +it is not an import side effect. + +The spec is hashable data (no functions). A sanitizer's `neutralizes` lists +the EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert +the finding (or its absence) in `test/unit/taint/`. TypeScript and JavaScript +use the real-source harness `test/helpers/ts-cfg-harness.ts`; Python and Java +model matches are covered by `python-model-match.test.ts` and +`java-model-match.test.ts`. The end-to-end proof is `test/integration/cfg/`. ## Validation checklist for any `--pdg` change diff --git a/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md index 9bffffdac..e4069f9a8 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md @@ -148,13 +148,19 @@ finding is NOT proof of safety. ## Adding a source / sink / sanitizer -Edit the language model in `taint/typescript-model.ts` (registered via the -explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The -spec is hashable data (no functions). A sanitizer's `neutralizes` lists the -EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the -finding (or its absence) in `test/unit/taint/` (real-source harness: -`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is -`test/integration/cfg/`. +Taint models cover four `SupportedLanguages` ids across three files: +TypeScript and JavaScript use `taint/typescript-model.ts`, Python uses +`taint/python-model.ts`, and Java uses `taint/java-model.ts`. Edit the model +for the language you are targeting. The explicit +`registerBuiltinTaintModels` seam in `typescript-model.ts` registers all four; +it is not an import side effect. + +The spec is hashable data (no functions). A sanitizer's `neutralizes` lists +the EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert +the finding (or its absence) in `test/unit/taint/`. TypeScript and JavaScript +use the real-source harness `test/helpers/ts-cfg-harness.ts`; Python and Java +model matches are covered by `python-model-match.test.ts` and +`java-model-match.test.ts`. The end-to-end proof is `test/integration/cfg/`. ## Validation checklist for any `--pdg` change diff --git a/gitnexus/skills/gitnexus-taint-analysis.md b/gitnexus/skills/gitnexus-taint-analysis.md index 9bffffdac..e4069f9a8 100644 --- a/gitnexus/skills/gitnexus-taint-analysis.md +++ b/gitnexus/skills/gitnexus-taint-analysis.md @@ -148,13 +148,19 @@ finding is NOT proof of safety. ## Adding a source / sink / sanitizer -Edit the language model in `taint/typescript-model.ts` (registered via the -explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The -spec is hashable data (no functions). A sanitizer's `neutralizes` lists the -EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the -finding (or its absence) in `test/unit/taint/` (real-source harness: -`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is -`test/integration/cfg/`. +Taint models cover four `SupportedLanguages` ids across three files: +TypeScript and JavaScript use `taint/typescript-model.ts`, Python uses +`taint/python-model.ts`, and Java uses `taint/java-model.ts`. Edit the model +for the language you are targeting. The explicit +`registerBuiltinTaintModels` seam in `typescript-model.ts` registers all four; +it is not an import side effect. + +The spec is hashable data (no functions). A sanitizer's `neutralizes` lists +the EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert +the finding (or its absence) in `test/unit/taint/`. TypeScript and JavaScript +use the real-source harness `test/helpers/ts-cfg-harness.ts`; Python and Java +model matches are covered by `python-model-match.test.ts` and +`java-model-match.test.ts`. The end-to-end proof is `test/integration/cfg/`. ## Validation checklist for any `--pdg` change From 9372b17049cc968676d620382c5cae68bcb7e7c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 5 Aug 2026 11:35:27 +0100 Subject: [PATCH 15/17] fix(python): resolve calls through an unaliased dotted namespace import (#2826) (#2828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(python): resolve calls through an unaliased dotted namespace import (#2826) `import pkg.db` followed by `pkg.db.session_scope()` emitted no CALLS edge, while all three sibling spellings resolved. In a codebase whose style guide mandates absolute imports this is close to the only cross-module call form used, so `impact()` reported `impactedCount: 0, risk: LOW, epistemic: exact` for functions with dozens of real callers — a dropped caller reading as a verified all-clear. The resolution path was never missing; one map was keyed on the wrong half of the import. `interpretPythonImport`'s plain arm splits `import pkg.db` into `localName: 'pkg'` (the name Python actually binds) and `importedName: 'pkg.db'`, and finalize carries both onto the edge as `localName` / `targetExportedName`. `collectNamespaceTargets` keyed only on `localName`, but the receiver text captured at the call site is the whole dotted path — Python's query binds the attribute's `object` field with a wildcard, so `pkg.db.session_scope()` yields the receiver `pkg.db`. Case 0 declines it (a module is not a class) and falls through, Case 1 looks up `pkg.db` and misses, and Case 1.5 needs `resolveQualifiedReceiverMember`, which only the C++ provider implements. The site drops silently. Key the map on the dotted import path as well — gated on a provider opt-in, not on the edge shape. The shape alone cannot decide it: Swift's `import Foo.Bar` produces the identical pair (`localName: 'Foo'`, `targetExportedName: 'Foo.Bar'`), but there the FIRST segment is the resolved target and `Foo.Bar` names a nested type. Minting a key for it would hand `resolveConstructionExpressionClass` an authoritative namespace — that branch deliberately does not fall through on a miss — and break `Foo.Bar(x)` construction that resolves correctly today. Hence `ScopeResolver.namespaceReceiverIncludesImportPath`, which only Python sets. The root-segment check on the added key does real work: `import pkg.db as pdb` binds only `pdb`, so writing `pkg.db.f()` there is a NameError, and its edge (localName `pdb`, path `pkg.db`) is correctly rejected. Two same-package imports stay separate — `import pkg.db` + `import pkg.cache` key `pkg.db` and `pkg.cache` independently, so neither call can land in the other's module; the shared `pkg` bucket keeps its existing ambiguity rather than gaining any. Tests: five integration rows (the issue's own repro, the three sibling spellings as controls, non-crossing two-package imports, a three-segment receiver, and dotted construction) plus a unit pin on the keying rule that asserts a Swift-shaped edge mints nothing. All five integration rows fail on the pre-fix tree; the controls pass on both, which is what makes them controls. Resolver integration suite 3024 passed / 1 skipped / 0 failed; scope-resolution unit suite 1446 passed. This changes what the resolver produces, not how it is stored — no schema or version constant applies, and an existing index needs a re-analyze to show the new edges. Co-Authored-By: Claude Opus 5 (1M context) * fix(resolution): shadow-test a dotted namespace key by its root segment (#2826) `isNamespaceNameShadowed` walks the scope chain looking for a binding, type binding, lexical name, or owned def named exactly `namespaceName`. Once a namespace key can be a dotted import path, that string never matches anything: `import pkg.db` binds `pkg`, so a local `pkg = Decoy()` shadows the import, but the guard was asked about `pkg.db` and answered "not shadowed". The consequence is not a missed edge but a wrong one. The caller treats a verified namespace as authoritative and deliberately does not fall through to the workspace-wide simple-name heuristics, so an unguarded shadowed receiver resolves construction against the imported module instead of the local value. Test the first dot-separated segment instead. Single-segment names are unaffected — their root is themselves — so every pre-existing row keeps its behaviour. This ships with the key that first routes a dotted name into the guard rather than after it: the previous commit is what makes the defect reachable. The new pin fails on the pre-fix guard (verified by reverting the four comparisons and re-running: 1 failed / 5 passed), so it discriminates rather than merely passing. Co-Authored-By: Claude Opus 5 (1M context) * test(python): pin the callee name on the dotted-construction row (#2826) The row asserted only that `builds` reached `pkg/db.py`. That module also exports `session_scope`, so a regression that resolved the construction to the wrong member of the right module would have kept the test green — it pinned the file, not the answer. Assert the exact edge set for the caller instead. Verified against the current tree with a scratch probe: `builds -> Model@pkg/db.py` is the only edge the file produces. Co-Authored-By: Claude Opus 5 (1M context) * docs(plans): include the #2826 engineering plan in the PR `.gitignore` keeps `docs/*` local because planning output is normally throwaway. Force-added here at the reviewer's request so the plan travels with the work it drove: it records the evidence chain behind the fix, the two places the plan turned out to be wrong, and the follow-ups deliberately left out of scope. Co-Authored-By: Claude Opus 5 (1M context) * refactor(resolution): make the namespace shadow guard shared (#2826) `isNamespaceNameShadowed` lived module-private in `compound-receiver.ts` with a single caller. The namespace map it guards has three consumers, and the next commit adds the guard to a second one, so it moves to `scope/walkers.ts` alongside the other scope-chain primitives rather than being duplicated. Behaviour is unchanged — this is a move plus documentation. Two notes were added because both are easy to get wrong later: - Fails closed on a missing scope or a parent cycle. For every caller, suppressing costs a missing edge while trusting a corrupt scope chain costs a wrong one, so the bias is deliberate. - It reads `scope.bindings` DIRECTLY rather than through `lookupBindingsAt`, which is the opposite of the fix #2745 applied to Rust's `headBoundLocally`. There the question was "is this name bound at all?", so missing finalize's import channels lost real bindings. Here the question is "does something LOCAL shadow the import?", and the import's own finalized binding is exactly what must not count — routing this through `lookupBindingsAt` would find every namespace import shadowing itself and suppress the lot. Verified against a target module carrying a self-named def, which still resolves. Co-Authored-By: Claude Opus 5 (1M context) * fix(python): close the three remaining namespace-receiver gaps (#2826) Three defects the first fix left behind. All three were confirmed by probe before being touched, and a fourth suspected gap was disproved the same way. ## 1. Case 1 resolved through an import a local had shadowed `namespaceTargets` is collected per FILE, but Case 1 in `receiver-bound-calls` consulted it with no lexical guard at all, so import pkg.db def f(pkg): # parameter shadows the package return pkg.db.session_scope() emitted an edge to pkg/db.py. That is a WRONG edge, and it predates the dotted key: the single-segment spelling (`import single` + `def f(single)`) failed identically. The compound-receiver construction path has applied this guard since #2770; Case 1 simply never did. Now both use the shared guard. ## 2 + 3. The root key named the leaf module, not the package These read as two gaps and are one. `import a.b.c` binds ONE name — `a` — but makes three attribute paths callable, naming three different files: a → a/__init__.py a.b → a/b/__init__.py a.b.c → a/b/c.py The map keyed only `a`, pointed at the LEAF. So `a.helper()` resolved into a/b/c.py whenever that module happened to export `helper` — silently preferring a decoy over the real definition in the package — and `a.b.mid()` resolved to nothing at all. One wrong edge and one missing edge from a single mis-keying. Fixing it needs per-language knowledge the shared collector cannot have: which prefixes are reachable, and which file each names. The `__init__.py` convention is Python's alone, and the edge shape is ambiguous across languages — Swift's `import Foo.Bar` produces an identical `localName`/`targetExportedName` pair that means the opposite thing. So the previous commit's boolean opt-in is replaced by `ScopeResolver.namespaceReceiverPaths`, which returns every spelling with the file it names; absent or declining, the shared default (bound name → own target) is unchanged for every other language. Prefix files are proposed, not asserted — `moduleFileExists` drops any the workspace never parsed, so a PEP-420 namespace package contributes no key rather than one pointing at a missing file. ## Disproved: C# was not a fourth gap The plan listed C# `using System.Collections.Generic` + `System.Collections.Generic.List` as the same class of bug. It is not: a probe shows `My.Deep.Space.Helpers.Work()` already resolves through the FQN namespace bindings in `walkers.ts`. No change made, and the claim is withdrawn rather than carried forward as a known gap. ## Testing Integration: the shadow block asserts the exact surviving edge set (an absence-only assertion would also pass if the guard over-suppressed and killed the clean rows); the prefix block asserts all three spellings land on their own file, with `helper` defined in BOTH package and leaf so a wrong edge is visible rather than merely possible. Unit: 16 rows on the keying contract, including that a Swift-shaped edge mints nothing and an alias import keys neither the path nor the root. Resolver integration 3024 passed / 1 skipped / 0 failed; scope-resolution unit 1452 passed; tsc clean in both packages. Co-Authored-By: Claude Opus 5 (1M context) * fix(python): probe both path separators when resolving a prefix package (#2826) Workspace file paths are not normalized to POSIX at ingestion — `import-target` already re-normalizes at five other comparison points, and `moduleScopeByFile` is keyed by the raw `ParsedFile.filePath`. The prefix probe built only the `/` spelling, so on Windows it would compare `a/b/__init__.py` against an `a\b\__init__.py` key, find nothing, and mint no prefix keys at all. That fails quietly, which is the worst shape for it: `a.b.mid()` simply goes back to unresolved on one platform, with no drop recorded and every test on POSIX still green. Probe both spellings and key whichever the workspace actually holds. The new row is mutation-tested — reverting to the `/`-only probe turns it red (1 failed / 10 passed), so it pins the behaviour rather than passing alongside it. Co-Authored-By: Claude Opus 5 (1M context) * fix(python): correct three defects a multi-lane review found in this PR (#2826) All three were introduced by this PR's own earlier commits, and none was found by re-reading the diff — each came from a lane attacking an angle the author had not. ## 1. The shadow guard ran BEFORE the map lookup it gates Case 1 evaluated `isNamespaceNameShadowed` unconditionally, then consulted `namespaceTargets`. So every call/read/write site with an explicit receiver, in every language, paid a scope-chain walk (a Set allocation, three Map lookups and a linear `ownedDefs` scan per level) ahead of an O(1) hash miss that was going to decline it anyway. The proof it was an oversight rather than a decision sits in this same PR: the sibling guard in `compound-receiver.ts` reads the map first and only guards on a hit. Two call sites of one shared function, opposite order. Semantics are identical either way — a miss yields `undefined` regardless — which is exactly why it survived several readings. ## 2. Prefix packages were anchored on the import spelling, not the resolved leaf `pythonNamespaceReceiverPaths` built `a/__init__.py` from the dotted path joined at the workspace root, never consulting the file the import actually resolved to. But `resolvePythonImportTarget` resolves off-root in two of its three tiers, so `import utils.db` can land on `libs/common/utils/db.py`. That produced a wrong edge where a same-named `utils/` package exists at the root, and produced NOTHING in a `src/` layout — the prefix feature was inert for the most common Python project shape, silently. Now the prefix directories are derived by walking back from the resolved leaf, which is exact for root, `src/` and off-root layouts alike. It also inherits the leaf's own separator, which subsumes the previous dual-separator probe: that probe was dead code anyway, because `filesystem-walker.ts` normalizes `\` to `/` before a path ever becomes a `ParsedFile.filePath`. Its test row is removed rather than left asserting an unreachable state. ## 3. Keying the root at `__init__.py` INSTEAD of the leaf lost re-exports `findExportedDef` accepts only a binding whose `origin === 'local'`. The canonical Python package re-exports from its submodules — `from .b.c import helper` in `__init__.py` — which is an IMPORT binding, so it is rejected. Keying the prefix solely at the package therefore turned `a.helper()` from a correct edge into no edge at all for the most common package shape. Every fixture in this PR defined its members locally in `__init__.py`, which is precisely the one layout where that mistake is invisible. The prefix now keys the package FIRST and the leaf behind it. A real definition in `__init__.py` still wins over a same-named decoy deeper in the package, and a name merely re-exported there still resolves through the leaf. Ordering is the contract, so the unit rows assert the exact arrays rather than membership. ## Testing New rows: off-root layout with a decoy `utils/` at the root, and a `src/` layout. Both mutation-tested — reverting to the spelling-anchored build turns them red. The re-export case was verified end-to-end with a scratch fixture whose `__init__.py` only re-exports (`uses -> helper@a/b/c.py`). Resolver integration 3131 passed / 1 skipped / 0 failed — unchanged from before these fixes, so they regress nothing. Scope-resolution unit 1459 passed. tsc clean in both packages. Co-Authored-By: Claude Opus 5 (1M context) * fix(resolution): stop the namespace shadow guard AT the module scope (#2826) CI caught a regression this PR introduced: `cjs-exports-assignment.test.ts` lost both of its cross-file rows — cross-file require() member call resolves expected [] to deeply equal [ 'handle' ] an `exports` parameter does not hijack the module (UMD factory) expected [] to deeply equal [ 'publicApi' ] — i.e. `const svc = require('./svc'); svc.handle()` stopped resolving in JavaScript. Cause: in CommonJS the namespace import IS a variable declaration. One statement produces both the ImportEdge and a module-scope `const` binding, so the guard, by inspecting the module scope, found the import's own name there and read it as a shadow of itself — suppressing exactly the receivers it exists to enable. The guard's own contract sentence already said the right thing: "a declaration BETWEEN the call site and its module scope". The module scope is the floor of that walk, not a rung on it. It now returns at Module without inspecting it. Nothing is lost on the suppression side: a genuine shadow is a parameter, a local, or a nested declaration, and all of those live in scopes strictly inside the module. The Python rows that pin suppression (`def f(pkg): pkg.db.f()` and its single-segment `import single` twin) still pass, because a parameter is an inner scope. Worth recording for the next reader: two independent review lanes examined this exact scenario and both REFUTED it, reasoning that `require()` yields an ImportEdge in `scope.imports` rather than a local binding. That is true for Python's `import x` and false for CommonJS, where one statement is both. My own probe used a Python fixture and so could not surface it either. Agreement between reviewers was not evidence; the test corpus was. Verified: cjs-exports-assignment 36/36, the #2826 integration rows 7/7, scope-resolution unit 126/126. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) --- ...s-plan-python-dotted-namespace-receiver.md | 473 ++++++++++++++++++ .../languages/python/import-target.ts | 73 +++ .../core/ingestion/languages/python/index.ts | 1 + .../languages/python/scope-resolver.ts | 7 + .../contract/scope-resolver.ts | 38 ++ .../passes/compound-receiver.ts | 37 +- .../passes/receiver-bound-calls.ts | 26 +- .../scope/namespace-targets.ts | 54 +- .../scope-resolution/scope/walkers.ts | 71 +++ .../test/integration/resolvers/python.test.ts | 269 ++++++++++ .../namespace-targets-import-path.test.ts | 181 +++++++ ...thon-module-namespace-construction.test.ts | 58 ++- 12 files changed, 1242 insertions(+), 46 deletions(-) create mode 100644 docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md create mode 100644 gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts diff --git a/docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md b/docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md new file mode 100644 index 000000000..bf4c175c8 --- /dev/null +++ b/docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md @@ -0,0 +1,473 @@ +# GitNexus Engineering Plan + +> Task: Emit the missing `CALLS` edge for Python's unaliased multi-segment namespace import (`import pkg.db` + `pkg.db.session_scope()`), issue #2826. +> Evidence verified at commit b2cd1c2ad637657125248c0dd2046de71ceea965; GitNexus index 13 commits behind HEAD, refresh skipped: every cited path is byte-identical between the index commit (1ef6447e) and the pinned commit — verified by blob-id comparison, so no graph claim here rests on drifted content. PDG layer absent from this index (`MATCH ()-[r:CodeRelation {type:'CDG'}]->() RETURN count(r)` → 0); `--pdg` upgrade skipped, source reads substitute at higher evidence strength. +> Evidence provenance schema 2; global dirty digest 0912a3ee3219cb75c82aefbf9f010e8dbe313150d6553768fd55d22af87a135c; cited-path manifest 13 sorted entries; exact generated plan path excluded. + +## 1. Objective + +`import pkg.db` followed by `pkg.db.session_scope()` must emit a `CALLS` edge from the caller to `session_scope`, matching the three sibling import spellings that already resolve (`from pkg.db import session_scope`, `import pkg.db as pdb`, `from pkg import db`). Two same-package imports in one file (`import pkg.a` + `import pkg.b`) must not cross-resolve, and no shared file under `gitnexus/src/core/ingestion/` may name a language (AGENTS.md §42). + +## 2. Current Behaviour + +The failure is a **key/lookup mismatch inside one map**, not a missing resolution path. + +For `import pkg.db`, `splitImportStmt` emits one match with `@import.source` = the whole `dotted_name` text `"pkg.db"` `[verified]` (`gitnexus/src/core/ingestion/languages/python/import-decomposer.ts:46-54`). `interpretPythonImport`'s `'plain'` arm then splits it `[verified]` (`gitnexus/src/core/ingestion/languages/python/interpret.ts:33-42`): + +```ts + case 'plain': { + // `import numpy` + if (sourceCap === undefined) return null; + return { + kind: 'namespace', + localName: sourceCap.text.split('.')[0]!, // `import a.b.c` exposes `a` + importedName: sourceCap.text, + targetRaw: sourceCap.text, + }; + } +``` + +`finalizeImportEdges` carries both halves onto the edge: `localName` verbatim, and `targetExportedName = parsed.importedName` for `kind === 'namespace'` `[verified]` (`gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:398-406, 434-447`). So the finalized `ImportEdge` is `{ localName: 'pkg', targetExportedName: 'pkg.db', targetFile: 'pkg/db.py', kind: 'namespace' }`. + +`collectNamespaceTargets` keys **only on `localName`** `[verified]` (`gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts:44-57`), producing `{'pkg' → ['pkg/db.py']}`. + +At the call site, Python's query binds the attribute's `object` field with a wildcard — `object: (_) @reference.receiver` `[verified]` (`gitnexus/src/core/ingestion/languages/python/query.ts:267-270`) — so for `pkg.db.session_scope()` the receiver node is the inner `attribute`, and `extractExplicitReceiver` takes its raw text `[verified]` (`gitnexus/src/core/ingestion/scope-extractor.ts:1235-1239`): `receiverName === 'pkg.db'`. + +`emitReceiverBoundCalls` then walks its cases `[verified]` (`gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts:404-421, 546-655, 831-848`): + +- **Case 0 (compound receiver)** fires because `receiverName.includes('.')` (line 563-567). It asks `resolveCompoundReceiverClass` for a **class**; `pkg.db` names a module, so it returns `undefined`, sets `compoundReceiverUnresolved = true`, and — critically — does **not** `handledSites.add`, so control falls through (lines 577, 622-655). +- **Case 1 (namespace receiver)** runs `namespaceTargets.get('pkg.db')` (line 832). The map holds `'pkg'`. Miss. +- **Case 1.5** needs `provider.resolveQualifiedReceiverMember`, implemented only by the C++ provider `[verified]` (`gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts:399-406`; `context` on that symbol shows one outgoing call to `resolveCppQualifiedNamespaceMember` and no other implementer). Python leaves it undefined, so the case is skipped. + +No later case types a module receiver, so the site drops. Reproduced on both `origin/main` and PR #2810's head; PR #2810 changes Python receiver *typing* (`languages/python/receiver-binding.ts`) and does not touch this path `[verified]` by running the repro against both trees. + +The three sibling spellings resolve because each binds a **single-segment** local name: `pdb` (alias arm), `session_scope` (named binding, not a receiver at all), and `db` (reclassified to `kind: 'namespace'` by #2770's `isNamespaceImport` hook, keying the map on `'db'`). + +## 3. Relevant Architecture + +`collectNamespaceTargets` is the shared, language-neutral bridge between finalized import edges and receiver resolution. Its contract note already states that `ImportEdge.kind === 'namespace'` is authoritative and that providers may reclassify into it — that reclassification hook (`isNamespaceImport`) is #2770's extension point `[verified]` (`gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:99-107`). + +Its output feeds three consumers, all per-file (`fileCompoundOpts`, `receiver-bound-calls.ts:405-406`): + +1. `emitReceiverBoundCalls` Case 1 — namespace-receiver member calls (`receiver-bound-calls.ts:832`); +2. `resolveConstructionExpressionClass` — namespace-qualified construction `pkg.db.Model()` (`compound-receiver.ts:245-260`); +3. `resolveCompoundReceiverClass`'s namespace-qualified-constructor disambiguation `options.namespaceTargets?.has(objExpr)` (`compound-receiver.ts:759-766`). + +AGENTS.md line 42 is the binding constraint: *"Shared code in `gitnexus/src/core/ingestion/` must not name languages — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks."* `[verified]` + +## 4. GitNexus Findings + +- `context({name: 'collectNamespaceTargets', repo: 'GitNexus'})` — `epistemic: "exact"`; incoming calls are exactly two: `emitReceiverBoundCalls` (`.../passes/receiver-bound-calls.ts`) and a test-local `build` in `test/unit/scope-resolution/python/python-module-namespace-construction.test.ts`. `[graph]` These are the d=1 dependents; the two `compound-receiver.ts` consumers reach the map by parameter rather than by call, so they do not appear here and were found by source grep `[verified]`. +- `context({name: 'resolveQualifiedReceiverMember', repo: 'GitNexus'})` — resolves to a single definition at `languages/cpp/scope-resolver.ts:399`, `outgoing.calls: [resolveCppQualifiedNamespaceMember]`, no incoming. `[graph]` Confirms the Case-1.5 hook is C++-only, matching the issue reporter's read of the published bundle. +- `cypher({statement: "MATCH ()-[r:CodeRelation {type: 'CDG'}]->() RETURN count(r)"})` — `| cdg_rows | 0 |`. `[graph]` The index carries no PDG layer; §5 is therefore empty by fact, not by omission. +- Related tests located by directory listing `[verified]`: `test/fixtures/lang-resolution/` already holds `python-module-import`, `python-bare-import`, `python-plain-import-alias`, `python-multi-segment-ancestor-import`, `python-function-local-namespace-import`, `python-class-body-namespace-import`, and #2770's `python-from-module-alias`. `test/integration/resolvers/python.test.ts` is the convention-matching home for the new assertions (#2770 added its coverage there, +38 lines). + +## 5. Statement-Level PDG Findings + +Empty by fact: the current index has zero `CDG` rows, so no statement-level slice exists to build. A `--pdg` re-index was deliberately not run — it is the largest fixed cost available to this session, the analyzer holds no writer lock against a live MCP server (#2658), and every constraint the slice would supply (which case gates the namespace lookup, whether Case 0's failure falls through) was read directly from source at higher evidence strength in §2. + +## 6. Proposed Changes + +### 6.1 `collectNamespaceTargets` — also key on the dotted access path + +- **File:** `gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts` +- **Symbol:** `collectNamespaceTargets` (source-verified) +- **Responsibility:** map every receiver spelling that names an imported module to that module's file(s). +- **Change:** inside the existing edge loop, after recording `edge.localName`, also record `edge.targetExportedName` **when it contains a dot and its first dot-separated segment equals `edge.localName`**. Same array-dedupe as the existing key. +- **Why this is language-neutral (AGENTS.md §42):** the condition names no language. It encodes one structural fact — *a namespace binding whose exported module name is a dotted path rooted at the local name is also reachable under that whole path.* Verified against every other namespace-emitting provider at the pinned commit `[verified]`: + - TypeScript `import * as X from './y'` → `localName 'X'`, `importedName './y'`; first segment `''` ≠ `'X'` → no key (`languages/typescript/interpret.ts:77-81, 118-122`). + - C# `using System.Collections.Generic` → `localName 'Generic'` (last segment), `importedName 'System.Collections.Generic'`; first segment `'System'` ≠ `'Generic'` → no key (`languages/csharp/interpret.ts:33-37, 62-66`). + - Go / Rust / Ruby → `localName === importedName`, no dot → no key (`languages/{go,rust,ruby}/interpret.ts`). + - Python `import pkg.db` → `'pkg' === 'pkg.db'.split('.')[0]` → key `'pkg.db'` added. This is the only provider the predicate admits today. +- **Constraint:** additive only. The existing `localName` key must keep its current value and ordering so no currently-resolving site changes target. +- **Two-package safety:** `import pkg.a` + `import pkg.b` in one file yields `{'pkg' → ['pkg/a.py','pkg/b.py'], 'pkg.a' → ['pkg/a.py'], 'pkg.b' → ['pkg/b.py']}`. Receiver `pkg.a` hits exactly one file; the ambiguous `'pkg'` bucket is only reachable by a receiver literally spelled `pkg`, which is unchanged from today. `[inferred]` — pinned by a test in §8. + +### 6.2 `isNamespaceNameShadowed` — test the root segment, not the dotted path + +- **File:** `gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts` +- **Symbol:** `isNamespaceNameShadowed` (source-verified, lines 152-183) and its one call site at line 250. +- **Defect this fix activates:** the guard walks the scope chain looking for a binding, type binding, lexical name, or owned def **named exactly `namespaceName`**. With 6.1 in place, `namespaceName` can be `'pkg.db'`, but Python binds only `pkg` — so a local `pkg = something` that genuinely shadows the import would fail to suppress the namespace interpretation, and the "verified namespace is authoritative" branch (line 249-259) would return a wrong class instead of declining. +- **Change:** shadow-test the first dot-separated segment of `namespaceName` (identical behaviour for the single-segment names it sees today, since root === whole name). +- **Not scope creep:** 6.1 is what first routes a dotted name into this guard; shipping 6.1 without it introduces the false positive. + +### 6.3 No change required in `receiver-bound-calls.ts` + +Case 1's lookup already uses the full dotted `receiverName` and Case 0's failure already falls through to it (`receiver-bound-calls.ts:577, 622-655, 832`) `[verified]`. Recorded here so the executor does not "fix" a path that is already correct. + +## 7. Implementation Sequence + +1. **Add the failing fixture and assertions first.** Create `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/` (files in §8) and a `describe` block in `gitnexus/test/integration/resolvers/python.test.ts` following the file's existing `writeFixtureRepo` + `mkdtempSync` convention. Confirm the dotted row fails and all three control rows pass. Delete the scratch `gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts` in this step — its content is superseded by the fixture-backed tests. +2. **Implement 6.1** in `namespace-targets.ts`, and update its header contract note to state that a namespace edge may be keyed both by its local name and by a dotted access path rooted at that name. Re-run the step-1 tests: the dotted row must flip to passing with the controls still green. +3. **Implement 6.2** in `compound-receiver.ts` with the shadowing test from §8 (a local `pkg = Decoy()` must suppress, not misresolve). +4. **Run the regression surface**: full resolver + scope-resolution integration suites, both packages' `tsc --noEmit`. +5. **Regenerate recorded baselines once, last.** Run each `--check` gate; regenerate only the baselines that actually moved (`bench/receiver-resolution/baseline.json` is the expected one — this change adds resolved edges). Per plan-template §7, this is deliberately the final step so intermediate commits do not churn and re-drift the artifacts. + +## 8. Test Strategy + +**New fixture** `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/`: + +| file | contents | +| --- | --- | +| `pkg/__init__.py` | empty | +| `pkg/db.py` | `def session_scope(): ...` | +| `pkg/cache.py` | `def session_scope(): ...` — the decoy that makes cross-resolution detectable | +| `caller_dotted.py` | `import pkg.db` + `def uses_dotted(): return pkg.db.session_scope()` | +| `caller_from.py`, `caller_alias.py`, `caller_frommod.py` | the three sibling controls from the issue | +| `caller_two_pkgs.py` | `import pkg.db` **and** `import pkg.cache`, one function calling each | +| `caller_deep.py` | `import pkg.sub.deep` + `pkg.sub.deep.f()` (3-segment) | +| `caller_shadowed.py` | module-level `import pkg.db`, then a function with a local `pkg = Decoy()` before `pkg.db.session_scope()` | + +**Scenarios** (input → action → expected): + +1. `caller_dotted.py` → run pipeline → `CALLS` edge `uses_dotted` → `pkg/db.py:session_scope`, `reason: 'import-resolved'`. **This is the issue's acceptance row.** +2. The three sibling callers → same run → all three still resolve to `pkg/db.py:session_scope`. Regression control: a run where the controls also broke would prove nothing about row 1. +3. `caller_two_pkgs.py` → `pkg.db.session_scope()` resolves **only** to `pkg/db.py` and `pkg.cache.session_scope()` **only** to `pkg/cache.py`; assert the absence of the crossed pair explicitly, not just the presence of the right one. +4. `caller_deep.py` → 3-segment receiver resolves — proves the predicate is not hard-coded to two segments. +5. `caller_shadowed.py` → **no** edge from the shadowed function to `pkg/db.py` (6.2's guard). Fails loudly if 6.2 regresses. +6. Cross-language non-regression: the existing TypeScript / C# / Go namespace-import resolver tests must stay green unchanged — that is the executable proof the new key is not minted for them. + +**Tests to update:** `gitnexus/test/integration/resolvers/python.test.ts` (add the describe block). `gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts` is a direct `collectNamespaceTargets` caller — re-run it; extend it only if its expectations enumerate map keys exhaustively. + +**Verification commands** (each verified to exist in `gitnexus/package.json` / `.github/workflows/ci-tests.yml` at the pinned commit): + +```bash +# from gitnexus/ — pretest:integration runs scripts/build.js, so the parse worker exists +GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers/python.test.ts +GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers +npm run test:unit -- test/unit/scope-resolution +npx tsc --noEmit # and the same in ../gitnexus-shared +node --import tsx bench/receiver-resolution/measure.mjs --check +node --import tsx bench/python-scope/measure.mjs --check +node --import tsx bench/python-scope/import-target-fingerprint.mjs --check +node --import tsx bench/scope-capture/measure.mjs --check +``` + +`GITNEXUS_WORKER_READY_TIMEOUT_MS=60000` is required on this host: the default 5000 ms worker-ready deadline fails as a crash-loop here (observed while reproducing the issue), which is environmental, not a code fault. + +## 9. Risk and Impact Analysis + +Accounting for every direct (d=1) dependent of the changed map: + +| d=1 dependent | risk | mitigation | +| --- | --- | --- | +| `emitReceiverBoundCalls` Case 1 (`receiver-bound-calls.ts:832`) | New keys make previously-dropped sites resolve. A wrong target would be a *new* false edge. | The predicate admits only Python's `import a.b` shape; each new key maps to exactly one file per import statement. §8 scenario 3 pins non-crossing. | +| `resolveConstructionExpressionClass` (`compound-receiver.ts:245-260`) | `pkg.db.Model()` now takes the "verified namespace is authoritative" branch, which deliberately does **not** fall through on a miss or ambiguity — so a wrong key would convert a working heuristic resolution into a silent decline. | The branch requires `namespaceFiles.length > 0`, i.e. the import genuinely resolved. Ambiguity still returns `undefined` (`namespaceMatches.length === 1` guard). Shadowing is fixed by 6.2. | +| `resolveCompoundReceiverClass` namespace-constructor disambiguation (`compound-receiver.ts:759-766`) | `namespaceTargets.has(objExpr)` now true for dotted namespaces, routing `pkg.db.Model(x).run()` into the construction interpretation. | Correct by intent — that branch exists precisely to make a namespace-qualified bare constructor safe. Behaviour change, so §8 should include a construction row if the fixture's cost is low. | +| `test/unit/scope-resolution/python/python-module-namespace-construction.test.ts:build` | May assert exact map contents. | Re-run in step 4; extend rather than weaken if it enumerates keys. | +| C++ provider | Case 1 is skipped entirely for C++ (`provider.resolveQualifiedReceiverMember !== undefined`), but the two `compound-receiver.ts` consumers are **not** provider-gated. | C++ `#include` does not produce a `kind: 'namespace'` edge with a dotted `targetExportedName` rooted at its local name; the predicate declines. Covered by the existing C++ suites plus `bench/cpp-qualified-ns/measure.mjs --check`. | + +**Recorded-artifact risk:** `bench/receiver-resolution/measure.mjs --check` gates both a shape matrix and a drop-count arm; new resolved edges are expected to move the count arm and the gate fails on drift. Regenerating in step 5 only (per §7) keeps intermediate commits clean. `bench/python-scope/*` and `bench/scope-capture/*` fingerprint captures and import-target resolution — neither is touched by this change, so a movement there is a signal to stop and investigate, not to regenerate. + +**Performance:** one extra `Map.set` per multi-segment namespace import per file; the loop is already O(module import edges). No new traversal. + +**No schema/version impact:** this changes what the resolver produces, not how it is stored. Existing indexes need a re-analyze to show the new edges — matching the note PR #2810 carried for the same reason. + +## 10. Files Expected to Change + +| File | Symbols | Reason | +| ---- | ------- | ------ | +| `gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts` | `collectNamespaceTargets` | Add the dotted-access-path key (§6.1) and update the contract note | +| `gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts` | `isNamespaceNameShadowed` | Shadow-test the root segment (§6.2) | +| `gitnexus/test/integration/resolvers/python.test.ts` | new `describe` block | Issue acceptance row + controls + regression rows | +| `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/**` | — | New fixture (§8) | +| `gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts` | — | Delete; superseded by the fixture-backed tests | +| `gitnexus/bench/receiver-resolution/baseline.json` | — | Regenerate once, final step, only if `--check` moves | + +## 11. Reusable Implementation Context + +```yaml +implementation_context: + task_summary: > + Python `import pkg.db` + `pkg.db.session_scope()` emits no CALLS edge (#2826). + Root cause: collectNamespaceTargets keys its map only on ImportEdge.localName + ('pkg'), while the receiver text is the full dotted path ('pkg.db'). Fix by + additionally keying on ImportEdge.targetExportedName when it is dotted and + rooted at localName — a predicate no other provider satisfies — plus a + root-segment fix to the shadow guard the new key first exposes. + acceptance_criteria: + - 'CALLS edge uses_dotted -> pkg/db.py:session_scope with reason import-resolved' + - 'The three sibling spellings (from-import, alias, from-module-attr) still resolve' + - 'import pkg.a + import pkg.b in one file do not cross-resolve' + - 'A local binding shadowing the package root suppresses the namespace interpretation' + - 'No shared file under gitnexus/src/core/ingestion/ names a language (AGENTS.md §42)' + + evidence_provenance: + schema_version: 2 + head_commit: 'b2cd1c2ad637657125248c0dd2046de71ceea965' + generated_plan_path: 'docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md' + global_dirty_digest: + algorithm: 'sha256' + canonicalization: 'gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records' + value: '0912a3ee3219cb75c82aefbf9f010e8dbe313150d6553768fd55d22af87a135c' + cited_path_manifest: + - path: '.github/workflows/ci-tests.yml' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff' + index_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff' + worktree_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff' + untracked_digest: 'absent' + - path: 'AGENTS.md' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd' + index_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd' + worktree_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd' + untracked_digest: 'absent' + - path: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b' + index_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b' + worktree_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b' + untracked_digest: 'absent' + - path: 'gitnexus-shared/src/scope-resolution/types.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc' + index_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc' + worktree_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/languages/python/import-decomposer.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e' + index_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e' + worktree_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/languages/python/interpret.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419' + index_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419' + worktree_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/languages/python/query.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78' + index_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78' + worktree_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/scope-extractor.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80' + index_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80' + worktree_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97' + index_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97' + worktree_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3' + index_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3' + worktree_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3' + untracked_digest: 'absent' + - path: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d' + index_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d' + worktree_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d' + untracked_digest: 'absent' + - path: 'gitnexus/test/integration/resolvers/python.test.ts' + object_kind: { head: regular, index: regular, worktree: regular, untracked: absent } + state: 'clean' + rename_from: null + rename_to: null + head_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999' + index_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999' + worktree_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999' + untracked_digest: 'absent' + - path: 'gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts' + object_kind: { head: absent, index: absent, worktree: absent, untracked: regular } + state: 'untracked' + rename_from: null + rename_to: null + head_digest: 'absent' + index_digest: 'absent' + worktree_digest: 'absent' + untracked_digest: 'sha256:6fe3a74a69db12a1a0aeceef2b32eb0c04d5e4880fc93b7b840348118e70078c' + + primary_symbols: + - symbol: 'collectNamespaceTargets' + file: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts' + lines: '39-57' + role: 'The defect site — builds the receiver-name → target-file map keyed only on localName' + - symbol: 'interpretPythonImport' + file: 'gitnexus/src/core/ingestion/languages/python/interpret.ts' + lines: '33-42' + role: 'Splits `import a.b` into localName "a" / importedName "a.b"; source of both halves' + - symbol: 'emitReceiverBoundCalls' + file: 'gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts' + lines: '404-421, 546-655, 831-848' + role: 'Case 0 declines on a module receiver and falls through; Case 1 does the failing map lookup' + - symbol: 'isNamespaceNameShadowed' + file: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts' + lines: '152-183' + role: 'Shadow guard that must test the root segment once dotted keys exist' + - symbol: 'finalizeImportEdges' + file: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts' + lines: '398-406, 434-447' + role: 'Carries importedName onto ImportEdge.targetExportedName for namespace edges' + + related_symbols: + - symbol: 'resolveQualifiedReceiverMember' + relationship: 'ScopeResolver hook, C++-only implementer' + relevance: 'Case 1.5 — deliberately NOT the fix path; implementing it for Python would duplicate what Case 1 already does' + - symbol: 'resolveConstructionExpressionClass' + relationship: 'consumes namespaceTargets by parameter' + relevance: 'Second consumer of the map; gains correct pkg.db.Model() resolution' + - symbol: 'resolveCompoundReceiverClass' + relationship: 'consumes namespaceTargets by parameter (compound-receiver.ts:759-766)' + relevance: 'Third consumer; has() now true for dotted namespaces' + - symbol: 'isNamespaceImport' + relationship: 'finalize hook added by #2770' + relevance: 'Prior art — how the from-pkg-import-db sibling was made to resolve' + - symbol: 'build' + relationship: 'test-of collectNamespaceTargets' + relevance: 'test/unit/scope-resolution/python/python-module-namespace-construction.test.ts — re-run after the change' + + execution_path: + - 'splitImportStatement emits one match per imported name; @import.source = full dotted_name text' + - 'interpretPythonImport plain arm → ParsedImport{kind:namespace, localName:first-segment, importedName:full-dotted}' + - 'finalizeImportEdges → ImportEdge{localName, targetExportedName=importedName, targetFile, kind:namespace}' + - 'collectNamespaceTargets builds Map keyed on localName only ← DEFECT' + - 'scope-extractor extractExplicitReceiver takes raw text of the attribute object → "pkg.db"' + - 'emitReceiverBoundCalls Case 0 declines (module, not class), falls through without marking handled' + - 'Case 1 map lookup on "pkg.db" misses; Case 1.5 skipped (no Python hook); site drops silently' + + pdg_constraints: [] # index has zero CDG rows; no --pdg layer to slice + + architectural_patterns: + - pattern: 'Provider reclassification at finalize instead of shared-code special-casing' + example_location: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:99-107 (isNamespaceImport, #2770)' + usage_guidance: 'Considered and rejected here: the information needed is already on the finalized edge, so no new hook is warranted' + - pattern: 'Verified namespace is authoritative — do not fall through to workspace-wide simple-name heuristics' + example_location: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts:245-259' + usage_guidance: 'Because that branch declines rather than guessing, a wrong key costs a lost edge, not a wrong one — but the shadow guard must be right' + - pattern: 'Fixture + assertions in test/integration/resolvers/python.test.ts' + example_location: 'gitnexus/test/integration/resolvers/python.test.ts:562-600 (vendored-django guard)' + usage_guidance: 'mkdtempSync + writeFixtureRepo + afterAll rmSync; assert both presence of the right edge and absence of the wrong one' + + files_to_modify: + - file: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts' + symbols: ['collectNamespaceTargets'] + intended_change: 'Additionally key the map on edge.targetExportedName when it contains a dot and its first segment equals edge.localName; keep the existing localName key unchanged; update the header contract note' + - file: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts' + symbols: ['isNamespaceNameShadowed'] + intended_change: 'Shadow-test the first dot-separated segment of namespaceName (no-op for single-segment names)' + - file: 'gitnexus/test/integration/resolvers/python.test.ts' + symbols: [] + intended_change: 'Add a describe block covering the six §8 scenarios' + - file: 'gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/' + symbols: [] + intended_change: 'New fixture per the §8 table' + - file: 'gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts' + symbols: [] + intended_change: 'Delete — superseded by the fixture-backed tests' + + tests: + - file: 'gitnexus/test/integration/resolvers/python.test.ts' + scenarios: + - 'import pkg.db + pkg.db.session_scope() → run pipeline → CALLS uses_dotted → pkg/db.py:session_scope, reason import-resolved' + - 'three sibling spellings in the same repo → run pipeline → all still resolve to pkg/db.py:session_scope (control)' + - 'import pkg.db AND import pkg.cache in one file, both defining session_scope → each call resolves only to its own module; assert the crossed pair is ABSENT' + - 'import pkg.sub.deep + pkg.sub.deep.f() → 3-segment receiver resolves' + - 'module-level import pkg.db shadowed by a function-local pkg = Decoy() → NO edge to pkg/db.py' + - 'existing TypeScript/C#/Go namespace-import resolver tests → unchanged green (no key minted for them)' + - file: 'gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts' + scenarios: + - 'Re-run unchanged; extend only if it enumerates map keys exhaustively' + + verification_commands: + - 'cd gitnexus && GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers/python.test.ts' + - 'cd gitnexus && GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers' + - 'cd gitnexus && npm run test:unit -- test/unit/scope-resolution' + - 'cd gitnexus && npx tsc --noEmit' + - 'cd gitnexus-shared && npx tsc --noEmit' + - 'cd gitnexus && node --import tsx bench/receiver-resolution/measure.mjs --check' + - 'cd gitnexus && node --import tsx bench/python-scope/measure.mjs --check' + - 'cd gitnexus && node --import tsx bench/python-scope/import-target-fingerprint.mjs --check' + - 'cd gitnexus && node --import tsx bench/scope-capture/measure.mjs --check' + + risks: + - 'New map keys reach three consumers, two of them by parameter rather than by call — the graph d=1 list alone under-reports them' + - 'compound-receiver treats a verified namespace as authoritative and declines instead of falling through, so a bad key loses edges silently' + - 'bench/receiver-resolution/baseline.json is expected to move; regenerate ONCE in the final step' + - 'Default 5000 ms worker-ready timeout crash-loops on this host; export GITNEXUS_WORKER_READY_TIMEOUT_MS=60000' + + assumptions: + - 'Every non-Python provider fails the dotted-rooted-at-localName predicate. CHECK: grep "kind: .namespace." across gitnexus/src/core/ingestion/languages/*/interpret.ts and confirm localName is never the first segment of a dotted importedName. Verified at b2cd1c2ad for typescript, csharp, go, rust, ruby.' + - 'python.test.ts is no longer gated behind REGISTRY_PRIMARY_PYTHON. CHECK: grep REGISTRY_PRIMARY in that file — zero hits at b2cd1c2ad, so it runs unconditionally.' + - 'The GitNexus index is 13 commits behind but byte-identical on every cited path. CHECK: git rev-parse 1ef6447e: vs b2cd1c2ad:.' + + open_questions: + - 'Should the misleading localName-only key for a dotted import (pkg → pkg/db.py) be removed? It can produce a false positive today: pkg.helper() resolves into pkg/db.py if db.py happens to define helper. Deferred — a separate behaviour change needing its own regression pass.' + - 'Python`s `import a.b.c` also makes `a.b` reachable. The proposed predicate keys only the exact imported path, so `a.b.f()` under `import a.b.c` alone stays unresolved. Deferred as a narrower follow-up.' + - 'C# `using System.Collections.Generic` + `System.Collections.Generic.List` is the same class of gap and is deliberately NOT addressed here (its localName is the last segment, so the predicate declines). Worth its own issue.' + + avoid: + - 'Do not repeat full repository discovery' + - 'Do not replace established patterns without evidence' + - 'Do not implement resolveQualifiedReceiverMember for Python — Case 1 already does this job; a second path would double-resolve' + - 'Do not change ImportEdge.localName for dotted imports (interpret.ts:38) — it is the deliberate `import a.b.c exposes a` semantics and other consumers depend on it' + - 'Do not name a language in gitnexus/src/core/ingestion/ shared code (AGENTS.md §42)' + - 'Do not regenerate bench baselines per step — only once, in the final step' + - 'Do not weaken an existing test to accommodate the new keys; extend it instead' +``` + +## 12. Assumptions and Open Questions + +**Assumptions** (each re-checkable cheaply by the executor): + +1. Every non-Python namespace-emitting provider fails the `dotted && first segment === localName` predicate. Verified at `b2cd1c2ad` for TypeScript, C#, Go, Rust and Ruby by reading each `interpret.ts`; JavaScript, Java and PHP emit no `kind: 'namespace'` import there. **Re-check:** grep `kind: 'namespace'` across `gitnexus/src/core/ingestion/languages/*/interpret.ts`. +2. `python.test.ts` runs unconditionally — no `REGISTRY_PRIMARY_PYTHON` gate remains at the pinned commit (zero grep hits). An older parity-leg convention no longer applies. +3. The index's 13-commit lag is harmless here because every cited path is byte-identical at the index commit and the pinned commit. + +**Open questions / explicitly deferred:** + +- **The bogus first-segment key.** For `import pkg.db`, the map still holds `'pkg' → ['pkg/db.py']`, so `pkg.helper()` would resolve into `pkg/db.py` if that file happens to define `helper` — a pre-existing false positive this plan does **not** fix. Removing it is a separate behaviour change with its own regression surface (`python-multi-segment-ancestor-import`, `python-bare-import`). Worth pinning the current behaviour in a test so it is visible rather than silent. +- **`import a.b.c` also binds `a.b`.** Python makes intermediate packages reachable; the proposed predicate keys only the exact imported path, so `a.b.f()` under `import a.b.c` alone stays unresolved. Narrower follow-up. +- **C# has the mirror-image gap.** `using System.Collections.Generic` + `System.Collections.Generic.List` fails the predicate because C# sets `localName` to the *last* segment. Deliberately out of scope; deserves its own issue. +- **Construction coverage.** §8 does not currently include a `pkg.db.Model()` row. Add one if the fixture cost is trivial — that path (`compound-receiver.ts:245-260`) changes behaviour and is otherwise untested by this plan. + +## 13. Definition of Done + +1. `CALLS` edge `uses_dotted` → `pkg/db.py:session_scope` (`reason: 'import-resolved'`) is emitted, asserted by a fixture-backed test in `python.test.ts`. +2. All three sibling control rows still resolve in the same run. +3. `import pkg.a` + `import pkg.b` in one file resolve only to their own modules; the crossed pair is asserted **absent**. +4. A 3-segment receiver resolves; a package root shadowed by a local binding does **not**. +5. The scratch `repro-2826-python-dotted-import.test.ts` is deleted. +6. No file under `gitnexus/src/core/ingestion/` names a language. +7. `npm run test:integration -- test/integration/resolvers` and `npm run test:unit -- test/unit/scope-resolution` pass; `tsc --noEmit` clean in both packages. +8. Every bench `--check` in §8 passes, with `bench/receiver-resolution/baseline.json` regenerated exactly once in the final commit if and only if it moved — and any movement in `python-scope`/`scope-capture` investigated rather than regenerated. diff --git a/gitnexus/src/core/ingestion/languages/python/import-target.ts b/gitnexus/src/core/ingestion/languages/python/import-target.ts index d30823b92..d06912cf5 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-target.ts @@ -439,3 +439,76 @@ export function isPythonImportedModule( normalizedTarget.endsWith('/' + packageFile) ); } + +/** + * The receiver spellings `import a.b.c` makes callable, and the file each one + * names (#2826). + * + * `import a.b.c` binds ONE name — `a` — but makes three attribute paths + * reachable, and they name three different files: + * + * a → a/__init__.py + * a.b → a/b/__init__.py + * a.b.c → a/b/c.py (the edge's own target) + * + * The shared default keyed `a` to the LEAF, which is wrong in both directions: + * `a.helper()` resolved into `a/b/c.py` whenever that module happened to export + * `helper`, and `a.b.mid()` resolved to nothing. + * + * Returns `undefined` — meaning "use the shared default" — for every spelling + * where the bound name is not the path's root: + * - `import single` — no dotted path to expand; + * - `import a.b as x` — binds only `x`; writing `a.b.f()` there is a + * NameError, so `a.b` must NOT become a key; + * - `from pkg import db` — reclassified to a namespace edge whose + * importPath is the bare name `db`. + * + * Prefix files are proposed, not asserted: `moduleFileExists` drops any that + * the workspace did not parse, so a PEP-420 namespace package (no + * `__init__.py`) contributes no key rather than one pointing at a missing file. + */ +export function pythonNamespaceReceiverPaths( + edge: { readonly localName: string; readonly importPath: string; readonly targetFile: string }, + moduleFileExists: (filePath: string) => boolean, +): readonly (readonly [string, string])[] | undefined { + const segments = edge.importPath.split('.'); + if (segments.length < 2) return undefined; + if (segments[0] !== edge.localName) return undefined; + + const out: (readonly [string, string])[] = [[edge.importPath, edge.targetFile]]; + + // Anchor the prefix packages on the RESOLVED leaf, never on the import + // spelling. `resolvePythonImportTarget` resolves off-root in two of its three + // tiers (suffix match and ancestor-relative), so `import utils.db` can land on + // `libs/common/utils/db.py`. Building `utils/__init__.py` from the spelling + // would then name a DIFFERENT package that merely shares the root segment — + // a wrong edge — and in a `src/` layout it would match nothing at all, + // silently making prefix keying inert for the most common Python layout. + // + // Walking back from the leaf also inherits that path's own separator, so no + // POSIX-vs-Windows probing is needed: workspace paths are not normalized at + // ingestion, and `moduleScopeByFile` is keyed by the raw `ParsedFile.filePath`. + const dirs = edge.targetFile.split('/').slice(0, -1); + // The import's leading segments name the leaf's innermost directories. + const offset = dirs.length - (segments.length - 1); + if (offset < 0) return out; + + for (let i = 1; i < segments.length; i++) { + const spelling = segments.slice(0, i).join('.'); + const packageFile = dirs.slice(0, offset + i).join('/') + '/__init__.py'; + // Package FIRST, then the leaf as a fallback — order is the whole point. + // + // `findExportedDef` only accepts a binding whose `origin === 'local'`, and + // the canonical package re-exports (`from .b.c import helper` in + // `__init__.py`) produce an IMPORT binding. Keying the prefix at the + // package alone therefore loses `a.helper()` entirely for the most common + // package shape — the fixtures here all define members locally in + // `__init__.py`, which is precisely the one layout where that mistake is + // invisible. Keeping the leaf behind the package restores that resolution + // while still letting a real definition in `__init__.py` win over a + // same-named decoy deeper in the package. + if (moduleFileExists(packageFile)) out.push([spelling, packageFile]); + out.push([spelling, edge.targetFile]); + } + return out; +} diff --git a/gitnexus/src/core/ingestion/languages/python/index.ts b/gitnexus/src/core/ingestion/languages/python/index.ts index 5cb5bda4d..dafddf357 100644 --- a/gitnexus/src/core/ingestion/languages/python/index.ts +++ b/gitnexus/src/core/ingestion/languages/python/index.ts @@ -78,6 +78,7 @@ export { pythonMergeBindings } from './merge-bindings.js'; export { pythonArityCompatibility } from './arity.js'; export { isPythonImportedModule, + pythonNamespaceReceiverPaths, resolvePythonImportTarget, type PythonResolveContext, } from './import-target.js'; diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index 12b20ded8..25bee8f96 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -21,6 +21,7 @@ import { indexOnlyElementType } from '../../type-extractors/shared.js'; import { pythonProvider } from '../python.js'; import { isPythonImportedModule, + pythonNamespaceReceiverPaths, pythonArityCompatibility, pythonMergeBindings, resolvePythonImportTarget, @@ -57,6 +58,12 @@ const pythonScopeResolver: ScopeResolver = { isNamespaceImport: (parsedImport, targetFile, fromFile) => isPythonImportedModule(parsedImport, targetFile, fromFile), + // `import a.b.c` binds only `a`, yet makes `a`, `a.b` and `a.b.c` all + // callable — each naming a different file. Without this the absolute-import + // style is invisible to the call graph, and the root key points at the leaf + // module instead of the package (#2826). + namespaceReceiverPaths: pythonNamespaceReceiverPaths, + // Python LEGB precedence: local > import/namespace/reexport > wildcard. // The per-scope id is unused by pythonMergeBindings (tier ordering // is computed purely from BindingRef.origin), so we don't need to diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 089d98364..a02f407c0 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -1084,6 +1084,44 @@ export interface ScopeResolver { callsite?: Callsite, ) => SymbolDefinition | 'ambiguous' | undefined; + /** + * Every receiver spelling under which a namespace import's target is + * reachable, and the file each spelling names (#2826). Returning + * `undefined` keeps the shared default: the local binding name alone, + * mapped to the edge's own target file. + * + * Python needs this because one `import a.b.c` statement binds THREE + * spellings at once — `a`, `a.b` and `a.b.c` — each naming a DIFFERENT + * file (`a/__init__.py`, `a/b/__init__.py`, `a/b/c.py`), while + * `ImportEdge` carries only the leaf. The default keyed `a` (its + * `localName`) to the LEAF, so `a.helper()` resolved into `a/b/c.py` + * whenever that module happened to export `helper` — a wrong edge — and + * `a.b.mid()` resolved to nothing at all. + * + * Shared code cannot derive this. Swift's `import Foo.Bar` produces an + * edge shape identical to Python's (`localName: 'Foo'`, + * `targetExportedName: 'Foo.Bar'`), yet there the FIRST segment is the + * resolved target and `Foo.Bar` names a nested TYPE; keying it would hand + * `resolveConstructionExpressionClass` an authoritative namespace — that + * branch deliberately does not fall through on a miss — and break + * `Foo.Bar(x)` construction that resolves correctly today. And the + * `__init__.py` convention that turns a dotted prefix into a file is + * Python's alone. + * + * `moduleFileExists` reports whether a path is a module the workspace + * actually parsed, so a provider can propose a prefix file and have it + * dropped when absent (a PEP-420 namespace package has no `__init__.py`) + * rather than minting a key to a file that is not there. + */ + readonly namespaceReceiverPaths?: ( + edge: { + readonly localName: string; + readonly importPath: string; + readonly targetFile: string; + }, + moduleFileExists: (filePath: string) => boolean, + ) => readonly (readonly [spelling: string, targetFile: string])[] | undefined; + /** * Optional language-specific member-lattice lookup. Runs for a resolved * simple receiver type before the generic flattened-MRO walk. Languages diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 39f98b467..72f2dcab7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -35,6 +35,7 @@ import { findExportedDefByName, findReceiverTypeBinding, isClassLike, + isNamespaceNameShadowed, } from '../scope/walkers.js'; /** Max depth for compound-receiver chain resolution (`a().b().c().d()`). @@ -156,42 +157,6 @@ function escapeForRegExp(literal: string): string { return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -/** True when a local declaration between the call site and its module scope - * shadows a file-level namespace import with the same name. Namespace targets - * are collected per file, so callers must apply this lexical guard before - * trusting them at an inner scope. */ -function isNamespaceNameShadowed( - namespaceName: string, - inScope: ScopeId, - scopes: ScopeResolutionIndexes, -): boolean { - let currentId: ScopeId | null = inScope; - const visited = new Set(); - while (currentId !== null) { - if (visited.has(currentId)) return true; - visited.add(currentId); - const scope = scopes.scopeTree.getScope(currentId); - if (scope === undefined) return true; - if ( - scope.kind !== 'Object' && - (scope.bindings.has(namespaceName) || - scope.typeBindings.has(namespaceName) || - scope.lexicalNames?.has(namespaceName) === true || - scope.ownedDefs.some((def) => { - const qualifiedName = def.qualifiedName; - if (qualifiedName === undefined) return false; - const dot = qualifiedName.lastIndexOf('.'); - return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === namespaceName; - })) - ) { - return true; - } - if (scope.kind === 'Module') return false; - currentId = scope.parent; - } - return true; -} - /** * Type of a construction expression's callee — the class it constructs. * diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 1202687a4..ce7ae7902 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -65,6 +65,7 @@ import { findReceiverTypeBinding, findValueBindingInScope, isClassLike, + isNamespaceNameShadowed, type DecorationStripper, } from '../scope/walkers.js'; import { @@ -107,6 +108,7 @@ type ReceiverBoundProviderSubset = Pick< | 'constructionSyntax' | 'stripTypePreservingDecoration' | 'resolveQualifiedReceiverMember' + | 'namespaceReceiverPaths' | 'resolveReceiverMember' | 'resolveThisViaEnclosingClass' | 'conversionRankFn' @@ -524,7 +526,10 @@ export function emitReceiverBoundCalls( }; for (const parsed of parsedFiles) { - const namespaceTargets = collectNamespaceTargets(parsed, scopes); + const namespaceTargets = collectNamespaceTargets(parsed, scopes, { + receiverPaths: provider.namespaceReceiverPaths, + moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath), + }); const fileCompoundOpts = { ...compoundOpts, namespaceTargets }; // Per-file resolved-callee-id capture context (#2227 U2). Built once per // file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge` @@ -993,7 +998,24 @@ export function emitReceiverBoundCalls( } // ── Case 1: namespace receiver ─────────────────────────────── - const targetFiles = namespaceTargets.get(receiverName); + // `namespaceTargets` is collected per FILE, so a local declaration that + // shadows the import must suppress it — `def f(pkg): pkg.db.query()` + // calls a method on the PARAMETER, and resolving it through the import + // emits a wrong edge, not a missing one. The compound-receiver + // construction path has applied this guard since #2770; Case 1 never did, + // for dotted and single-segment receivers alike. + // Map lookup FIRST: it is an O(1) miss for almost every site, and the + // guard is a scope-chain walk (a Set allocation plus a linear `ownedDefs` + // scan per level). Guarding before looking up would charge that walk to + // every explicit-receiver site in every language, for a candidate set + // that is usually empty. Mirrors the order the compound-receiver + // construction path already uses. + const namespaceCandidates = namespaceTargets.get(receiverName); + const targetFiles = + namespaceCandidates !== undefined && + !isNamespaceNameShadowed(receiverName, site.inScope, scopes) + ? namespaceCandidates + : undefined; if (targetFiles !== undefined && provider.resolveQualifiedReceiverMember === undefined) { let found = false; for (const targetFile of targetFiles) { diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts index f82a01be2..e80b7f359 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts @@ -20,6 +20,15 @@ * syntax or reclassify a named import after target resolution proves it names * a module. * + * A namespace edge may be reachable under TWO receiver spellings: the name it + * binds locally, and — for a language that opts in via + * `ScopeResolver.namespaceReceiverIncludesImportPath` — the dotted module path + * it was imported under (#2826). Python's `import a.b` binds only `a` while + * the call site writes `a.b`, so both keys are needed. The opt-in exists + * because the edge shape alone cannot tell that case from Swift's + * `import Foo.Bar`, where the same pair means the opposite thing — see the + * hook's contract note. + * * Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope` * documents that function-local and class-body imports bind to the * inner scope, which would make a module-only read incomplete. In @@ -36,23 +45,58 @@ import type { ParsedFile } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { ScopeResolver } from '../contract/scope-resolver.js'; + +export interface NamespaceTargetOptions { + /** `ScopeResolver.namespaceReceiverPaths` for the file's language. Absent + * (or returning `undefined` per edge) keeps the local-name-only default: + * the extra spellings are opt-in, never inferred from the edge shape. */ + readonly receiverPaths?: ScopeResolver['namespaceReceiverPaths']; + /** Whether a path is a module the workspace parsed. Lets a provider propose + * a prefix file and have it dropped when absent, instead of minting a key + * to a file that does not exist. Defaults to "nothing exists". */ + readonly moduleFileExists?: (filePath: string) => boolean; +} export function collectNamespaceTargets( parsed: ParsedFile, scopes: ScopeResolutionIndexes, + options?: NamespaceTargetOptions, ): Map { const out = new Map(); const moduleEdges = scopes.imports.get(parsed.moduleScope); if (moduleEdges === undefined) return out; - for (const edge of moduleEdges) { - if (edge.targetFile === null || edge.kind !== 'namespace') continue; - let targets = out.get(edge.localName); + const addTarget = (key: string, targetFile: string): void => { + let targets = out.get(key); if (targets === undefined) { targets = []; - out.set(edge.localName, targets); + out.set(key, targets); } - if (!targets.includes(edge.targetFile)) targets.push(edge.targetFile); + if (!targets.includes(targetFile)) targets.push(targetFile); + }; + + const moduleFileExists = options?.moduleFileExists ?? ((): boolean => false); + + for (const edge of moduleEdges) { + if (edge.targetFile === null || edge.kind !== 'namespace') continue; + + const spellings = options?.receiverPaths?.( + { + localName: edge.localName, + importPath: edge.targetExportedName, + targetFile: edge.targetFile, + }, + moduleFileExists, + ); + + // A provider that declines this edge — or has no hook — gets the default: + // the bound name alone, pointing at this edge's own target. + if (spellings === undefined) { + addTarget(edge.localName, edge.targetFile); + continue; + } + for (const [spelling, targetFile] of spellings) addTarget(spelling, targetFile); } return out; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index b4b8aeab9..cf9c3560e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -227,6 +227,77 @@ export function isReceiverOwnedButUnbound( return false; } +/** + * True when a declaration between the call site and its module scope shadows a + * file-level namespace import of the same name. Namespace targets are collected + * per FILE, so every consumer of that map must apply this lexical guard before + * trusting it at an inner scope — otherwise `def f(pkg): pkg.db.query()` + * resolves through the import that the parameter shadows, producing a wrong + * edge rather than a missing one. + * + * A namespace key may itself be a dotted import path (`pkg.db`, #2826), but the + * name a declaration can shadow is always the ROOT identifier — `pkg = Decoy()` + * shadows `pkg.db` too. Testing the whole dotted string would never match a + * binding, so the guard would silently stop guarding for exactly the keys it + * was extended to cover. Single-segment names are unaffected: their root is + * themselves. + * + * Fails closed (returns `true`) on a missing scope or a parent cycle: for every + * caller, suppressing a resolution costs a missing edge, while trusting a + * corrupt scope chain costs a wrong one. + * + * Reads `scope.bindings` DIRECTLY rather than through `lookupBindingsAt`, and + * that is deliberate — the opposite of the fix #2745 applied to Rust's + * `headBoundLocally`. There the question was "is this name bound at all?", so + * missing the finalized/augmented import channels lost real bindings. Here the + * question is "does something LOCAL shadow the import?", and the import's own + * finalized binding is the one thing that must NOT count: routing this through + * `lookupBindingsAt` would find the namespace import shadowing itself and + * suppress every namespace receiver in the workspace. Locals, parameters and + * lexical names all live in the scope's own tables, which is exactly the set + * this walk wants. + */ +export function isNamespaceNameShadowed( + namespaceName: string, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, +): boolean { + const firstDot = namespaceName.indexOf('.'); + const rootName = firstDot === -1 ? namespaceName : namespaceName.slice(0, firstDot); + let currentId: ScopeId | null = inScope; + const visited = new Set(); + while (currentId !== null) { + if (visited.has(currentId)) return true; + visited.add(currentId); + const scope = scopes.scopeTree.getScope(currentId); + if (scope === undefined) return true; + // Stop AT the module scope without inspecting it. In languages where a + // namespace import IS a variable declaration — CommonJS + // `const svc = require('./svc')` — the import puts its own name into the + // module scope's tables, so inspecting them reads the import as its own + // shadow and suppresses every receiver it was meant to enable (#2723). + // The contract is "a declaration BETWEEN the call site and its module + // scope", and the module scope is the floor, not a rung. + if (scope.kind === 'Module') return false; + if ( + scope.kind !== 'Object' && + (scope.bindings.has(rootName) || + scope.typeBindings.has(rootName) || + scope.lexicalNames?.has(rootName) === true || + scope.ownedDefs.some((def) => { + const qualifiedName = def.qualifiedName; + if (qualifiedName === undefined) return false; + const dot = qualifiedName.lastIndexOf('.'); + return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === rootName; + })) + ) { + return true; + } + currentId = scope.parent; + } + return true; +} + export function findReceiverTypeBinding( startScope: ScopeId, receiverName: string, diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index db2c2d155..add7b7bd0 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -3102,3 +3102,272 @@ describe('Python inline constructor receiver resolution', () => { ]); }); }); + +// --------------------------------------------------------------------------- +// #2826 — unaliased multi-segment namespace import. +// +// `import pkg.db` binds only `pkg`, but the receiver text at the call site is +// the whole dotted path `pkg.db`. Every sibling spelling binds a single-segment +// name and so already resolved; this one fell between the namespace-receiver +// case (keyed on the local binding) and the qualified-receiver hook (C++ only). +// +// The three sibling rows are controls, not decoration: a run where they also +// broke would say nothing about the row under test. +// --------------------------------------------------------------------------- + +describe('Python unaliased multi-segment namespace import (#2826)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-dotted-ns-')); + writeFixtureRepo(repoDir, { + 'pkg/__init__.py': '', + 'pkg/db.py': `class Model: + pass + + +def session_scope(): + return "db" +`, + // Same member name in a sibling module: makes a cross-resolution visible + // instead of letting the right answer and a lucky answer look identical. + 'pkg/cache.py': `def session_scope(): + return "cache" +`, + 'pkg/sub/__init__.py': '', + 'pkg/sub/deep.py': `def deep_fn(): + return "deep" +`, + 'caller_dotted.py': `import pkg.db + +def uses_dotted(): + return pkg.db.session_scope() +`, + 'caller_from.py': `from pkg.db import session_scope + +def uses_from(): + return session_scope() +`, + 'caller_alias.py': `import pkg.db as pdb + +def uses_alias(): + return pdb.session_scope() +`, + 'caller_frommod.py': `from pkg import db + +def uses_from_module_attr(): + return db.session_scope() +`, + 'caller_two_pkgs.py': `import pkg.db +import pkg.cache + +def uses_db(): + return pkg.db.session_scope() + +def uses_cache(): + return pkg.cache.session_scope() +`, + 'caller_deep.py': `import pkg.sub.deep + +def uses_deep(): + return pkg.sub.deep.deep_fn() +`, + 'caller_construct.py': `import pkg.db + +def builds(): + return pkg.db.Model() +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + const sessionScopeCallers = (targetFile: string): string[] => + getRelationships(result, 'CALLS') + .filter((c) => c.target === 'session_scope' && c.targetFilePath === targetFile) + .map((c) => c.source) + .sort(); + + it('resolves the unaliased dotted receiver to the imported module', () => { + const edge = getRelationships(result, 'CALLS').find( + (c) => c.source === 'uses_dotted' && c.target === 'session_scope', + ); + expect(edge).toMatchObject({ + source: 'uses_dotted', + target: 'session_scope', + targetFilePath: 'pkg/db.py', + sourceFilePath: 'caller_dotted.py', + }); + }); + + it('keeps the three sibling spellings resolving (control)', () => { + expect(sessionScopeCallers('pkg/db.py')).toEqual( + expect.arrayContaining(['uses_alias', 'uses_from', 'uses_from_module_attr']), + ); + }); + + it('does not cross-resolve two same-package imports in one file', () => { + // Both modules export `session_scope`, so a receiver-blind fallback would + // be invisible in a presence-only assertion. Pin the exact pairing. + const pairs = getRelationships(result, 'CALLS') + .filter((c) => c.sourceFilePath === 'caller_two_pkgs.py' && c.target === 'session_scope') + .map((c) => `${c.source}->${c.targetFilePath}`) + .sort(); + expect(pairs).toEqual(['uses_cache->pkg/cache.py', 'uses_db->pkg/db.py']); + }); + + it('resolves a three-segment dotted receiver', () => { + const edge = getRelationships(result, 'CALLS').find( + (c) => c.source === 'uses_deep' && c.target === 'deep_fn', + ); + expect(edge).toMatchObject({ target: 'deep_fn', targetFilePath: 'pkg/sub/deep.py' }); + }); + + it('resolves construction through a dotted namespace receiver', () => { + // Pin the callee NAME, not just the file: pkg/db.py also exports + // `session_scope`, so a file-only assertion would stay green if the + // construction resolved to the wrong member of the right module. + const edges = getRelationships(result, 'CALLS') + .filter((c) => c.sourceFilePath === 'caller_construct.py') + .map((c) => `${c.source}->${c.target}@${c.targetFilePath}`) + .sort(); + expect(edges).toEqual(['builds->Model@pkg/db.py']); + }); +}); + +// --------------------------------------------------------------------------- +// #2826 follow-up — a namespace receiver shadowed by a local declaration. +// +// Case 1 (namespace receiver) in `receiver-bound-calls.ts` consulted the +// per-file namespace map with no lexical guard at all, so a parameter or local +// named like the imported package still resolved through the import. That is a +// WRONG edge, not a missing one, and it predates the dotted-path key — the +// single-segment rows below fail the same way without the guard. +// --------------------------------------------------------------------------- + +describe('Python namespace receiver shadowed by a local binding (#2826)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-ns-shadow-')); + writeFixtureRepo(repoDir, { + 'pkg/__init__.py': '', + 'pkg/db.py': `def session_scope(): + return "db" +`, + 'single.py': `def session_scope(): + return "single" +`, + 'caller_dotted.py': `import pkg.db + +def clean_dotted(): + return pkg.db.session_scope() + +def param_shadow_dotted(pkg): + return pkg.db.session_scope() + +def local_shadow_dotted(): + pkg = object() + return pkg.db.session_scope() +`, + 'caller_single.py': `import single + +def clean_single(): + return single.session_scope() + +def param_shadow_single(single): + return single.session_scope() + +def local_shadow_single(): + single = object() + return single.session_scope() +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('emits an edge only from the unshadowed callers', () => { + // Exact edge set: an assertion on absence alone would also pass if the + // guard over-suppressed and killed the clean rows too. + const edges = getRelationships(result, 'CALLS') + .filter((c) => c.target === 'session_scope') + .map((c) => `${c.source}->${c.targetFilePath}`) + .sort(); + expect(edges).toEqual(['clean_dotted->pkg/db.py', 'clean_single->single.py']); + }); +}); + +// --------------------------------------------------------------------------- +// #2826 follow-up — `import a.b.c` binds THREE receiver spellings, not one. +// +// Python makes `a`, `a.b` and `a.b.c` all callable off a single import, and +// each names a DIFFERENT file. The namespace map originally keyed only the +// bound name `a`, pointed at the LEAF module — wrong in both directions: +// `a.helper()` resolved into the leaf whenever it happened to export `helper` +// (a wrong edge, preferring a decoy over the real definition), and `a.b.mid()` +// resolved to nothing. +// --------------------------------------------------------------------------- + +describe('Python dotted import binds every package prefix (#2826)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-prefix-')); + writeFixtureRepo(repoDir, { + // `helper` exists in BOTH the package and the leaf. Without the fix the + // root key points at the leaf and the decoy wins, so this pair is what + // makes the wrong edge visible rather than merely plausible. + 'a/__init__.py': `def helper(): + return "package" +`, + 'a/b/__init__.py': `def mid_fn(): + return "mid" +`, + 'a/b/c.py': `def helper(): + return "leaf-decoy" + + +def leaf_fn(): + return "leaf" +`, + 'caller.py': `import a.b.c + +def uses_leaf(): + return a.b.c.leaf_fn() + +def uses_mid(): + return a.b.mid_fn() + +def uses_root(): + return a.helper() +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('resolves each prefix to its own module', () => { + const edges = getRelationships(result, 'CALLS') + .filter((c) => c.sourceFilePath === 'caller.py') + .map((c) => `${c.source}->${c.target}@${c.targetFilePath}`) + .sort(); + expect(edges).toEqual([ + 'uses_leaf->leaf_fn@a/b/c.py', + 'uses_mid->mid_fn@a/b/__init__.py', + 'uses_root->helper@a/__init__.py', + ]); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts b/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts new file mode 100644 index 000000000..20ca10078 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/namespace-targets-import-path.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; +import type { ImportEdge, ParsedFile, ScopeId } from 'gitnexus-shared'; +import { collectNamespaceTargets } from '../../../src/core/ingestion/scope-resolution/scope/namespace-targets.js'; +import { pythonNamespaceReceiverPaths } from '../../../src/core/ingestion/languages/python/import-target.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +// `collectNamespaceTargets` reads exactly two things: the file's module scope +// and `scopes.imports`. Hand-building those keeps this test about the keying +// rule itself rather than about any one language's parser — which matters, +// because the rule's whole job is to tell otherwise identical-looking edges +// from different languages apart. + +const MODULE_SCOPE = 'mod:caller' as ScopeId; + +function edge(partial: Partial): ImportEdge { + return { + localName: 'pkg', + targetFile: 'pkg/db.py', + targetExportedName: 'pkg.db', + kind: 'namespace', + ...partial, + } as ImportEdge; +} + +/** Collect with Python's hook, against a workspace containing `files`. */ +function collectPython(edges: readonly ImportEdge[], files: readonly string[] = []) { + const parsed = { filePath: 'caller.py', moduleScope: MODULE_SCOPE } as ParsedFile; + const scopes = { imports: new Map([[MODULE_SCOPE, edges]]) } as unknown as ScopeResolutionIndexes; + const present = new Set(files); + return collectNamespaceTargets(parsed, scopes, { + receiverPaths: pythonNamespaceReceiverPaths, + moduleFileExists: (filePath) => present.has(filePath), + }); +} + +/** Collect the way a provider with no hook does. */ +function collectDefault(edges: readonly ImportEdge[]) { + const parsed = { filePath: 'caller.ts', moduleScope: MODULE_SCOPE } as ParsedFile; + const scopes = { imports: new Map([[MODULE_SCOPE, edges]]) } as unknown as ScopeResolutionIndexes; + return collectNamespaceTargets(parsed, scopes); +} + +describe('collectNamespaceTargets — namespace receiver spellings (#2826)', () => { + it('keys only the bound name when no provider hook is supplied', () => { + const targets = collectDefault([edge({})]); + expect(targets.get('pkg')).toEqual(['pkg/db.py']); + expect(targets.has('pkg.db')).toBe(false); + }); + + it('keys the dotted import path for Python', () => { + expect(collectPython([edge({})]).get('pkg.db')).toEqual(['pkg/db.py']); + }); + + // The root key is the reason this is a hook and not a flag. `import pkg.db` + // binds `pkg`, but `pkg` names the PACKAGE, not the submodule — keying it to + // pkg/db.py made `pkg.helper()` resolve into the submodule whenever that file + // happened to export `helper`, silently preferring a decoy over the real one. + it('keys the package root at its own __init__, never at the leaf module', () => { + const targets = collectPython([edge({})], ['pkg/__init__.py']); + expect(targets.get('pkg')).toEqual(['pkg/__init__.py', 'pkg/db.py']); + expect(targets.get('pkg.db')).toEqual(['pkg/db.py']); + }); + + it('omits a prefix whose package file the workspace never parsed', () => { + // PEP-420 namespace package: no __init__.py. Better no key than one + // pointing at a file that does not exist — or at the wrong file. + const targets = collectPython([edge({})], []); + expect(targets.get('pkg')).toEqual(['pkg/db.py']); + expect(targets.get('pkg.db')).toEqual(['pkg/db.py']); + }); + + it('keys every intermediate package of a deep import', () => { + const deep = edge({ + localName: 'a', + targetExportedName: 'a.b.c', + targetFile: 'a/b/c.py', + }); + const targets = collectPython([deep], ['a/__init__.py', 'a/b/__init__.py']); + expect(targets.get('a')).toEqual(['a/__init__.py', 'a/b/c.py']); + expect(targets.get('a.b')).toEqual(['a/b/__init__.py', 'a/b/c.py']); + expect(targets.get('a.b.c')).toEqual(['a/b/c.py']); + }); + + // Swift's `import Foo.Bar` produces an edge structurally identical to + // Python's `import pkg.db` — localName 'Foo', targetExportedName 'Foo.Bar'. + // Swift resolves the FIRST segment as the SPM target, so 'Foo.Bar' names a + // nested type, not the imported file. Minting a key for it would hand + // `resolveConstructionExpressionClass` an authoritative-but-wrong namespace, + // and that function deliberately does not fall through on a miss — so a + // working `Foo.Bar(x)` would start resolving to nothing. Only the provider + // opt-in keeps the two apart; a structural predicate cannot. + it('mints nothing extra for a provider without the hook, on a Swift-shaped edge', () => { + const swiftShaped = edge({ + localName: 'Foo', + targetExportedName: 'Foo.Bar', + targetFile: 'Sources/Foo/Foo.swift', + }); + const targets = collectDefault([swiftShaped]); + expect(targets.get('Foo')).toEqual(['Sources/Foo/Foo.swift']); + expect(targets.has('Foo.Bar')).toBe(false); + }); + + it('does not key an alias import under the module path it does not bind', () => { + // `import pkg.db as pdb` binds ONLY `pdb`; `pkg.db.f()` is a NameError. + const aliased = edge({ localName: 'pdb', targetExportedName: 'pkg.db' }); + const targets = collectPython([aliased], ['pkg/__init__.py']); + expect(targets.get('pdb')).toEqual(['pkg/db.py']); + expect(targets.has('pkg.db')).toBe(false); + expect(targets.has('pkg')).toBe(false); + }); + + it('keeps two same-package imports on separate keys', () => { + const targets = collectPython( + [ + edge({ targetExportedName: 'pkg.db', targetFile: 'pkg/db.py' }), + edge({ targetExportedName: 'pkg.cache', targetFile: 'pkg/cache.py' }), + ], + ['pkg/__init__.py'], + ); + expect(targets.get('pkg.db')).toEqual(['pkg/db.py']); + expect(targets.get('pkg.cache')).toEqual(['pkg/cache.py']); + // The shared root LEADS with the package itself — not with whichever + // submodule happened to be imported first — and keeps both leaves behind it + // so a name merely re-exported by `__init__.py` still resolves. + expect(targets.get('pkg')).toEqual(['pkg/__init__.py', 'pkg/db.py', 'pkg/cache.py']); + }); + + it('ignores non-namespace and unresolved edges', () => { + const targets = collectPython( + [ + edge({ kind: 'named', localName: 'db', targetExportedName: 'pkg.db' }), + edge({ targetFile: null, targetExportedName: 'pkg.gone' }), + ], + ['pkg/__init__.py'], + ); + expect(targets.size).toBe(0); + }); + + // Prefix packages are anchored on the RESOLVED leaf, not on the import + // spelling: `resolvePythonImportTarget` resolves off-root in two of its three + // tiers, so an import can land outside the workspace root. + it('anchors prefix packages on the resolved leaf, not the workspace root', () => { + const offRoot = edge({ + localName: 'utils', + targetExportedName: 'utils.db', + targetFile: 'libs/common/utils/db.py', + }); + // A DIFFERENT `utils` package exists at the root. Anchoring on the spelling + // would key `utils` to it — a module this import never named. + const targets = collectPython( + [offRoot], + ['utils/__init__.py', 'libs/common/utils/__init__.py'], + ); + expect(targets.get('utils')).toEqual([ + 'libs/common/utils/__init__.py', + 'libs/common/utils/db.py', + ]); + expect(targets.get('utils.db')).toEqual(['libs/common/utils/db.py']); + }); + + it('keys prefixes in a src/-style layout', () => { + const srcLayout = edge({ + localName: 'a', + targetExportedName: 'a.b.c', + targetFile: 'src/a/b/c.py', + }); + const targets = collectPython([srcLayout], ['src/a/__init__.py', 'src/a/b/__init__.py']); + expect(targets.get('a')).toEqual(['src/a/__init__.py', 'src/a/b/c.py']); + expect(targets.get('a.b')).toEqual(['src/a/b/__init__.py', 'src/a/b/c.py']); + expect(targets.get('a.b.c')).toEqual(['src/a/b/c.py']); + }); + + it('falls back to the default for a bare single-segment import', () => { + const bare = edge({ + localName: 'single', + targetExportedName: 'single', + targetFile: 'single.py', + }); + expect(collectPython([bare]).get('single')).toEqual(['single.py']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts b/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts index 75b3303c4..5b7d3402b 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts @@ -25,6 +25,18 @@ def shadowed(models): def locally_shadowed(): models = object() return models.User() +`, + ], + [ + 'pkg/dotted.py', + `import pkg.models + +def dotted(): + return pkg.models.User() + +def dotted_root_shadowed(): + pkg = object() + return pkg.models.User() `, ], [ @@ -66,9 +78,16 @@ function build() { }, }); const index = buildWorkspaceResolutionIndex(parsedFiles); + const namespaceTargetsFor = (file: ParsedFile) => + collectNamespaceTargets(file, scopes, { + // Mirror the production wiring in receiver-bound-calls.ts: the extra + // receiver spellings come from the provider hook, not from a default. + receiverPaths: pythonScopeResolver.namespaceReceiverPaths, + moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath), + }); const app = parsedFiles.find((file) => file.filePath === 'pkg/app.py'); if (app === undefined) throw new Error('missing app fixture'); - const namespaceTargets = collectNamespaceTargets(app, scopes); + const namespaceTargets = namespaceTargetsFor(app); const resolveIn = (functionName: string, expression: string) => { const functionScope = app.scopes.find( @@ -83,11 +102,28 @@ function build() { }); }; - return { resolveIn }; + const dotted = parsedFiles.find((file) => file.filePath === 'pkg/dotted.py'); + if (dotted === undefined) throw new Error('missing dotted fixture'); + const dottedNamespaceTargets = namespaceTargetsFor(dotted); + + const resolveDottedIn = (functionName: string, expression: string) => { + const functionScope = dotted.scopes.find( + (scope) => + scope.kind === 'Function' && + scope.ownedDefs.some((def) => def.qualifiedName === functionName), + ); + if (functionScope === undefined) throw new Error(`missing scope for ${functionName}`); + return resolveCompoundReceiverClass(expression, functionScope.id, scopes, index, { + constructionSyntax: { bare: true }, + namespaceTargets: dottedNamespaceTargets, + }); + }; + + return { resolveIn, resolveDottedIn }; } describe('Python module namespace construction', () => { - const { resolveIn } = build(); + const { resolveIn, resolveDottedIn } = build(); it('resolves an exported class from the verified module target', () => { expect(resolveIn('valid', 'models.User()')).toMatchObject({ @@ -107,4 +143,20 @@ describe('Python module namespace construction', () => { it('does not reuse a file-level namespace when a local shadows it', () => { expect(resolveIn('locally_shadowed', 'models.User()')).toBeUndefined(); }); + + // #2826: `import pkg.models` binds only `pkg`, so the namespace key is the + // dotted path `pkg.models` while the shadowable name is the root `pkg`. + it('resolves construction through a dotted import-path namespace', () => { + expect(resolveDottedIn('dotted', 'pkg.models.User()')).toMatchObject({ + filePath: 'pkg/models.py', + qualifiedName: 'User', + }); + }); + + it('does not reuse a dotted namespace when a local shadows its ROOT segment', () => { + // Fails without the root-segment fix: testing the whole `pkg.models` + // string against scope bindings never matches, so the guard would pass a + // shadowed receiver straight through to the authoritative namespace branch. + expect(resolveDottedIn('dotted_root_shadowed', 'pkg.models.User()')).toBeUndefined(); + }); }); From 905a1e191aa27e02094a2822d470072891c8dfdd Mon Sep 17 00:00:00 2001 From: Parafee41 Date: Wed, 5 Aug 2026 19:17:44 +0800 Subject: [PATCH 16/17] fix(mcp): ignore CR-only line ending diffs (#2839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gergő Magyar --- gitnexus/src/mcp/local/local-backend.ts | 37 ++++++------- gitnexus/test/unit/detect-changes-eol.test.ts | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+), 18 deletions(-) create mode 100644 gitnexus/test/unit/detect-changes-eol.test.ts diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index ec4872cdd..d4b3ea063 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -799,6 +799,21 @@ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string return repoPath; } +export function buildDetectChangesDiffArgs(scope: string, baseRef?: string): string[] | null { + const args = ['diff', '--ignore-cr-at-eol']; + switch (scope) { + case 'staged': + return [...args, '--staged', '-U0']; + case 'all': + return [...args, 'HEAD', '-U0']; + case 'compare': + return baseRef ? [...args, baseRef, '-U0'] : null; + case 'unstaged': + default: + return [...args, '-U0']; + } +} + /** * Length of the path-derived suffix appended to a colliding repo id. * Exported so tests can pin the suffix shape without re-deriving the @@ -4917,24 +4932,10 @@ export class LocalBackend { const scope = params.scope || 'unstaged'; const { execFileSync } = await import('child_process'); - // Build git diff args based on scope (using execFileSync to avoid shell injection) - let diffArgs: string[]; - switch (scope) { - case 'staged': - diffArgs = ['diff', '--staged', '-U0']; - break; - case 'all': - diffArgs = ['diff', 'HEAD', '-U0']; - break; - case 'compare': - if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' }; - diffArgs = ['diff', params.base_ref, '-U0']; - break; - case 'unstaged': - default: - diffArgs = ['diff', '-U0']; - break; - } + // Ignore CR-only EOL differences, while preserving meaningful whitespace changes. + // execFileSync receives an argv array, so refs never pass through a shell. + const diffArgs = buildDetectChangesDiffArgs(scope, params.base_ref); + if (!diffArgs) return { error: 'base_ref is required for "compare" scope' }; let diffOutput: string; try { diff --git a/gitnexus/test/unit/detect-changes-eol.test.ts b/gitnexus/test/unit/detect-changes-eol.test.ts new file mode 100644 index 000000000..52a56c35b --- /dev/null +++ b/gitnexus/test/unit/detect-changes-eol.test.ts @@ -0,0 +1,53 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { buildDetectChangesDiffArgs } from '../../src/mcp/local/local-backend.js'; + +describe('detect_changes EOL filtering', () => { + it.each([ + ['unstaged', undefined, ['diff', '--ignore-cr-at-eol', '-U0']], + ['staged', undefined, ['diff', '--ignore-cr-at-eol', '--staged', '-U0']], + ['all', undefined, ['diff', '--ignore-cr-at-eol', 'HEAD', '-U0']], + ['compare', 'main', ['diff', '--ignore-cr-at-eol', 'main', '-U0']], + ])('adds the EOL guard for %s scope', (scope, baseRef, expected) => { + expect(buildDetectChangesDiffArgs(scope, baseRef)).toEqual(expected); + }); + + it('requires a base ref for compare scope', () => { + expect(buildDetectChangesDiffArgs('compare')).toBeNull(); + }); + + it('suppresses CRLF-only changes but retains other whitespace changes', () => { + const repoDir = mkdtempSync(path.join(tmpdir(), 'gitnexus-detect-eol-')); + try { + execFileSync('git', ['init', '-q'], { cwd: repoDir }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoDir }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoDir }); + writeFileSync(path.join(repoDir, 'sample.ts'), 'const first = 1;\r\nconst second = 2;\r\n'); + execFileSync('git', ['add', 'sample.ts'], { cwd: repoDir }); + execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: repoDir }); + + writeFileSync(path.join(repoDir, 'sample.ts'), 'const first = 1;\nconst second = 2;\n'); + const diffArgs = buildDetectChangesDiffArgs('unstaged'); + if (!diffArgs) throw new Error('unstaged scope must produce git diff arguments'); + expect( + execFileSync('git', diffArgs, { + cwd: repoDir, + encoding: 'utf8', + }), + ).toBe(''); + + writeFileSync(path.join(repoDir, 'sample.ts'), 'const first = 1;\n const second = 2;\n'); + expect( + execFileSync('git', diffArgs, { + cwd: repoDir, + encoding: 'utf8', + }), + ).toContain('+ const second = 2;'); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); +}); From aaa78f959077f6980c2ad2303a552f8e93b06f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 5 Aug 2026 15:50:08 +0100 Subject: [PATCH 17/17] fix(scope-resolution): fan out interface dispatch from Case 3b receivers (#2832) (#2842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scope-resolution): fan out interface dispatch from Case 3b receivers (#2832) Case 3b (chain-typebinding) folds a receiver through the same `resolveCompoundReceiverClass` call and the same `[owner, ...mroFor(owner)]` walk Case 0 uses, but emitted its edge without calling `emitInterfaceDispatchFor`. When that fold landed on an Interface the site got one edge to the interface's bodiless declaration and none to any implementation — the defect #2813 reported for field receivers, in the half #2829 did not cover. The gap was a property of how a receiver was SPELLED rather than of what it resolved to. `d.repo.save()` contains a dot, so it took Case 0 and fanned out; binding the identical field to a local first — `const r = d.repo; r.save()` — made the receiver a bare name with a dotted typeBinding, which is Case 3b, and lost every implementation edge. `ownerDef` is the receiver's own folded type, matching Case 0's `currentClass` and Case 4's `ownerDef`, not the owner of the member the MRO walk settled on: a receiver folding to a concrete class that merely inherits an interface method must not fan out, because its runtime type is that class. The closure self-gates on `ownerDef.type !== 'Interface'`, so the call is inert for every concrete receiver and needs no language check. Confidence is the 0.85 literal this case's own primary emits, so dispatch edges never outrank the edge they hang off; Case 4's site.kind-dependent value has no counterpart here because Case 3b's primary does not vary that way. The new fixture pins the route as well as the fix. `const r = d.repo` reaches Case 3b and nothing else can take the site: Case 0 needs a `.`/`(` in the receiver name or a minted receiver chain, and `encodeReceiverChain` returns undefined for the empty step list a bare identifier produces; Case 4 excludes itself on the dot. Before the fix the primary assertion passed while the fan-out came back empty — the exact "reached Case 3b and stopped at the declaration" signature. Resolution-side only: this changes what the resolver produces, not how it is stored, so no SCHEMA_BUMP applies. An existing index must be re-analyzed to show the new edges. Follow-up from #2829. Co-Authored-By: Claude Opus 5 (1M context) * docs(scope-resolution): record Case 3b's interface-dispatch fan-out in I4 (#2832) Invariant I4 documented the fan-out as something "Cases 0 and 4 both perform" and spelled out Case 0.5's exclusion, while saying nothing about Case 3b — which is what made 3b's missing fan-out an undocumented asymmetry rather than a deliberate exclusion someone could defend or point at. With the fan-out added, Case 0.5 is the only case that folds or walks to a receiver type without dispatching to implementations, and its exclusion is gated behind `resolveThisViaEnclosingClass`. Saying so explicitly keeps the next reader from having to re-derive which cases fan out by reading the pass. Comment-only; `detect-changes --scope staged` reports no graph change. Co-Authored-By: Claude Opus 5 (1M context) * test(scope-resolution): add the concrete-implementor control for Case 3b (#2832) The Case 3b fan-out shipped with one negative control — a chain folding to PlainCache, a class that implements nothing. That proves only the weak claim: no interface anywhere near the site, no fan-out. Add the stronger negative. SqlRepo implements Repo, so an interface IS in scope and `save` is a name Repo declares, yet the receiver's folded type is the concrete class and nothing may fan out. This is the control that fails if a later change fans out from the interface a member is DECLARED in rather than from the receiver's own folded type. The comment says what the control cannot do, too: it cannot catch "member owner passed instead of folded type" in TypeScript, because an implementing class always declares the member itself, so the MRO walk never settles on the interface's bodiless declaration. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o * docs(scope-resolution): correct four overclaims the review found (#2832) A multi-lane review of this PR reproduced, against the real pipeline, that several claims in the comments and one test name assert more than the code delivers. No behavior changes here — only the text, and one test rename. 1. The test comment gave the WRONG REASON why the concrete-implementor control cannot catch "member owner passed instead of folded type". It said an implementing class always declares the member itself; `class C extends Base implements I {}` is valid TypeScript and inherits it. The real reason is that TypeScript's MRO chain never contains an implemented interface, so the walk cannot settle on an interface declaration for a concrete receiver. The mutation IS expressible where a concrete class inherits a `default` interface method (Java, Kotlin) — reproduced during review — so this is language-scoped, not inherent, and a follow-up fixture is tracked. 2. "fans out to every implementation of the folded interface" certified a completeness that does not exist. TypeScript emits heritage edges for `class_declaration` only (languages/typescript/captures.ts:749, stated in its own docstring at :732-733), so `abstract class X implements I` and `interface B extends A` produce no heritage edge and still dead-end on the bodiless declaration. Renamed to name the shape actually covered, with a KNOWN GAP note. The gap is in the capture layer and predates this fan-out. 3. Invariant I4 said Case 0.5 is the ONLY case that resolves a receiver type without fanning out. Cases 3 and 5 do too, by direct lookup rather than a fold or MRO walk. The sentence now says which distinction it means and states the reachability argument (no known language reaches Case 3 with an Interface — every one that could strips the namespace qualifier first, sending it to Case 4) instead of implying a completed audit. 4. The gate's rationale claimed the `ownerDef.type !== 'Interface'` test is right for every non-Interface receiver. An abstract-class receiver also dead-ends on a declaration-only member and does not fan out. Noted, with why widening the gate belongs to Cases 0 and 4 across all languages rather than to #2832. Also completes the module-level case ladder, which still credited the fan-out to Case 0 alone and omitted it from the Case 3b entry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o * docs(scope-resolution): name Case 2 in the I4 exclusion list too (#2832) The first pass at this correction listed Cases 3 and 5 as the other cases that resolve a receiver type without fanning out, and was itself incomplete: Case 2 also walks an MRO and its binding admits `Interface`. It is excluded for a different reason than 3 and 5 — its receiver IS the type name, so the site is static dispatch and a fan-out would be wrong, whereas 3 and 5 resolve by direct lookup rather than a fold or MRO walk. Say both rather than enumerate one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o * fix(scope-resolution): close the two gaps the #2842 review left open Both were pre-existing and reached by Cases 0 and 4 as well; the Case 3b fan-out only widened the population of sites that hit them. Researched against the real TypeScript compiler and the language service before choosing semantics, plus how comparable tools draw the same lines. 1. THE FAN-OUT COULD TARGET A STATIC MEMBER `class C implements I { static save() {} }` does not satisfy `I` — TypeScript rejects it as TS2420, "Property 'save' is missing" — so an edge from an `I`-typed receiver to a static member names a target dispatch can never produce. The closure picked targets with `pickOverload`, which applies no static filter, while the surrounding cases pick their own primary with `pickFirstNonStaticOnly`: the speculative edges were picked with weaker rules than the certain edge they hang off. A same-name static+instance pair also made `pickOverload` return OVERLOAD_AMBIGUOUS, suppressing the CORRECT edge too, so this was a false negative as well as a false positive. Every comparable tool draws this line: tsserver partitions static from instance results, clangd gates on `isVirtual()` (C++ forbids virtual statics), jdtls filters abstract-or-static, and class-hierarchy analysis expands only VIRTUAL call sites. The guard prefers `provider.isStaticOnly` where a language declares it and falls back to the graph node's `isStatic`. That order is load-bearing, not stylistic: the method extractor derives `isStatic` from the OWNER type as well as the member (`staticOwnerTypes`), and the JVM config lists `object_declaration` — so reading the flag first would delete Kotlin `object` implementations, which are singleton INSTANCES and genuinely reachable. Kotlin is the only hook implementor and marks exactly the companion-promoted set; Ruby's `singleton_class` (`def self.foo`) is correctly filtered by the fallback. 2. TYPESCRIPT HERITAGE WAS CLASS-ONLY `interface B extends A` and `abstract class X implements I` emitted no heritage edge at all, so the subtype closure had nothing to descend and both shapes dead-ended on a bodiless declaration — including the very example the closure's own docstring cites as the reason it exists. Since Case 3b's dotted-alias binding survives qualifier-stripping only in TS/JS, this was the language that actually reaches the new path. The two shapes reach their bases differently: an abstract class carries the same `class_heritage` child a concrete one does, while an interface's bases hang off `extends_type_clause` directly. That clause's `type` field is `multiple: true`, so `childForFieldName('type')` would silently drop `C` from `interface B extends A, C` — hence iterating named children. Deliberately NOT structural matching. TypeScript is structurally typed, so a class satisfies an interface without `implements`, but tsc's own navigation is declaration-only and says why: "users are typically only interested in explicit implementations... The type checker doesn't let us make the distinction between structurally compatible implementations and explicit implementations, so we must use the AST." scip-typescript reached the same design independently. gopls does match structurally, but only because Go has no `implements` keyword to prefer. Abstract declarations are still walked THROUGH rather than targeted — the rule everywhere is "does it have a body?", which is what `isDeclarationOnly` already tests. VERSIONING. The capture change is parse-time, so a v43 warm cache would serve entries missing the new matches: SCHEMA_BUMP 43 -> 44 with its pin test moved in the same commit, verified against origin/main at a857f4c5a (still 43). Rebaselined only the `typescript` scope-capture fingerprint, justified by a capture-name histogram diff over the same 145-file corpus: the only deltas are @reference.inherits 17 -> 20 and its paired @reference.name 245 -> 248, emitted together by `emitTsInheritanceBase`. Every other capture count is byte-identical and javascript is unchanged, the language having no interfaces. Tests: the fan-out now covers a static-shadowing subclass, a concrete class below an abstract intermediate, and an implementor of an extending interface. Resolvers 3176 passed; all five bench gates pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 91 ++++++++--------- .../languages/typescript/captures.ts | 29 +++++- .../contract/scope-resolver.ts | 18 +++- .../passes/receiver-bound-calls.ts | 97 +++++++++++++++++- gitnexus/src/storage/parse-cache.ts | 8 +- .../ts-chain-interface-dispatch/app.ts | 28 ++++++ .../ts-chain-interface-dispatch/deps.ts | 15 +++ .../ts-chain-interface-dispatch/hierarchy.ts | 30 ++++++ .../ts-chain-interface-dispatch/mem-repo.ts | 7 ++ .../plain-cache.ts | 5 + .../ts-chain-interface-dispatch/repository.ts | 3 + .../shadow-repo.ts | 13 +++ .../ts-chain-interface-dispatch/sql-repo.ts | 7 ++ .../integration/resolvers/typescript.test.ts | 98 +++++++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 7 +- 15 files changed, 398 insertions(+), 58 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/app.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/deps.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/hierarchy.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/mem-repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/plain-cache.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/repository.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/shadow-repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/sql-repo.ts diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 58b449287..07bc49d17 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -6,11 +6,11 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.", "_rebaselined": "#1976: F33 generic composite literal constructor inference adds generic_type captures in composite_literal patterns; fingerprint drift expected.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.", "_rebaselined_2766_go_pointer_receiver_fixture": "#2766: added test/fixtures/lang-resolution/go-pointer-receiver-field-chain/ (2 Go files) as the committed regression fixture for pointer-receiver base resolution. Go fixture_count 100 -> 102. Prior 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb -> 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is a resolution-time lookup fallback (stripTypePreservingDecoration) and cannot move capture output; go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.", - "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.", - "_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites \u2014 the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected \u2014 go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.", + "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.", + "_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites — the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected — go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.", "_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.", "_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303." }, @@ -20,7 +20,7 @@ "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: COBOL procedure-pointer callable flow facts; multi-topic extraction now consumes each grouped scope/declaration match once instead of requiring a duplicate declaration-only match. Prior 68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1 -> d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e; scaling 0.853 < 1.5.", "_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959.", - "_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace\u2192Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON ), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5." + "_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace→Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON ), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5." }, "c": { "fingerprint": "3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5", @@ -28,8 +28,8 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4 -> 3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5; scaling 1.073 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C function-pointer signatures plus direct-callee argument metadata and invocation-result suppression. Prior 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae -> 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4; scaling 1.035 < 1.5.", "_rebaselined_callable_flow": "Callable-value-flow facts for C function pointers, copies, pointer-to-pointer cells, arguments, and indirect invokes. Prior 12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5 -> 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae; measured scaling ratio 0.980 < 1.5.", - "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.", - "_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c \u2014 worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.", + "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.", + "_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.", "_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": { @@ -42,11 +42,11 @@ "_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 \u2014 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 \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 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).", + "_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).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: outermost-chain passing modes; ->* ERROR-recovery role order; member-store visibility. Prior 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb -> f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65; scaling ratio re-verified within budget.", - "_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) \u2014 removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.", + "_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) — removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.", "_rebaselined_receiver_chain_2747": "#2747: additionally adds the `cpp-receiver-chain-arrow` fixture, the behavioural proof for a `->` BASE receiver (`svc->getUser()->save()`) that the rollout fixed and that `cpp-chain-call/` could never catch because it uses the value `.` form. Prior a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1 -> 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc." + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc." }, "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.", @@ -56,23 +56,23 @@ "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.", "_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).", "_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc." }, "rust": { "fingerprint": "6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809", "scaling_budget": 1.5, - "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged \u2014 verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", + "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged — verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", "_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.", - "_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", - "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.", + "_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", + "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.", "_rebaselined_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5.", - "_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers \u2014 the impl scope binds the method by name, so fresh.validate() resolved by accident \u2014 and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.", + "_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers — the impl scope binds the method by name, so fresh.validate() resolved by accident — and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.", "_rebaselined_module_tree_2730": "#2730 + #2741 review: RUST_SCOPE_QUERY captures mod_item as @declaration.namespace (a Rust module is an item, mirroring the C++ namespace_definition capture) and tags scoped call sites with @reference.qualified-name so the written path survives to resolution. Both are additive captures: every bench fixture holding a mod block or a Foo::bar() call gains groups, and the corpus also grew by the rust-2730-* fixtures added for the fix and its review (workspace-crates, type-qualified, gaps, samename-wrapper, crate-layout). Prior 7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689 -> 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5; scaling 1.061 < 1.5; fixture_count 196. Only the rust fingerprint moves; the other 14 languages are byte-identical. The earlier revision of this note cited 655aed01... as the prior value, which was two rebaselines stale (it predates #2604 and #2714); the CI gate compares live fingerprints, not this prose, so nothing caught it.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809." }, "php": { "fingerprint": "b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c", @@ -80,9 +80,9 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618 -> 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd; scaling 1.078 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: PHP first-class callable and variable-invocation flow facts with invocation-result suppression. Prior 31c9e3f3cb7094a2bf9021cf9db859036e002f8b44605cd993b470fc600e97cb -> df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618; scaling 1.074 < 1.5.", "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04). | #2481/#2482: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).", - "_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c." + "_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class — fixture count 138→140, fingerprint drift expected.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c." }, "ruby": { "fingerprint": "1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57", @@ -90,10 +90,10 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef -> bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236; scaling 1.103 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Ruby Method/Proc callable flow facts with invocation/constructor-result suppression. Prior b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753 -> cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef; scaling 1.086 < 1.5.", "_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", - "_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.", + "_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1991: + ruby-nested-mixin-tail-collision fixture (85→86). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; scaling ratio re-verified within budget.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." }, "swift": { "fingerprint": "adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9", @@ -102,8 +102,8 @@ "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.", "_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).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5." }, "dart": { @@ -121,7 +121,7 @@ "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.", - "_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically \u2014 no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", + "_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically — no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", "_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: get/test dropped from callableProtocolMethods. Prior 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4 -> f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67; scaling ratio re-verified within budget.", "_rebaselined_2550_instance_model": "PR #2549 (#2550): anonymous class bodies emit synthesized @declaration.class/@declaration.name (Worker$N), an @reference.inherits to the constructed type, and receiver @type-binding.* captures; six new java-* fixtures joined the corpus. Prior f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67 -> d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90; scaling 1.058 < 1.5.", @@ -129,30 +129,31 @@ "_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5.", "_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.", "_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9." }, "java-local-types": { "fingerprint": "8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633", "scaling_budget": 1.5, "_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633." }, "typescript": { - "fingerprint": "248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965", + "fingerprint": "7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd -> 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78; scaling 0.975 < 1.5.", "_rebaselined_callable_flow": "Callable assignment/copy/formal/argument/invoke facts (also consumed by Vue script blocks). Prior 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd -> db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd; measured scaling ratio 0.951 < 1.5.", - "_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.", - "_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected.", + "_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures — fingerprint drift expected.", + "_note": "#1968: F44, F85, F87 — fingerprint drift expected.", "_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior 3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9 -> 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd; scaling ratio 0.987 < 1.5.", "_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object (was unscoped, then @scope.block during development). Prior e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63 -> 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4; scaling 0.981 < 1.5.", - "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.", - "_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding \u2014 `public_field_definition` with a `new_expression` value, and `this. = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5." + "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) — every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.", + "_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding — `public_field_definition` with a `new_expression` value, and `this. = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5.", + "_rebaselined_ts_heritage_2842": "#2842 review: TypeScript heritage capture now emits `@reference.inherits` for `interface_declaration` (bases on `extends_type_clause`) and `abstract_class_declaration` (bases on `class_heritage`), which were both silently skipped — so `interface B extends A` and `abstract class X implements I` produced no edge and every interface-dispatch walk dead-ended on a bodiless declaration. Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus (145 files) with and without the change: the ONLY deltas are @reference.inherits 17 -> 20 (+3) and its paired @reference.name 245 -> 248 (+3), emitted together by emitTsInheritanceBase. Every other capture count is byte-identical, so no existing capture moved. The +3 is the three `interface X extends BasePayload` declarations in typescript-generic-calls/src/{auth,admin,guest}.ts. javascript is unchanged (no interfaces in the language). Prior 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965 -> 7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949." }, "javascript": { "fingerprint": "806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594", @@ -164,9 +165,9 @@ "_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", "_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8 -> 5567dd47e7ba29821a518c4a9852adc3b774e25ef3e7a6e2b3ecb7b59ddab73c; scaling ratio 1.031 < 1.5.", "_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5.", - "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594." + "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) — every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594." }, "kotlin": { "fingerprint": "efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2", @@ -175,12 +176,12 @@ "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.", "_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.", "_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.", - "_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).", + "_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land — until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.", "_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5.", "_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.", - "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.", + "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2." } } diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 88989d369..b3d9315ee 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -729,8 +729,12 @@ export function emitTsScopeCaptures( * matching arms (member_expression for extends, nested_type_identifier plain + * generic-wrapped for implements). * - * `interface_declaration` / `abstract_class_declaration` heritage is NOT emitted - * by the synth. The EXTENDS-vs-IMPLEMENTS split is decided downstream from the + * `interface_declaration` and `abstract_class_declaration` heritage IS emitted + * (#2842 review; both were silently skipped before, so `interface B extends A` + * and `abstract class X implements I` produced no edge and every dispatch walk + * dead-ended on a bodiless declaration). They reach their bases by different + * shapes: an abstract class carries the same `class_heritage` child a concrete + * one does, while an interface's bases hang off `extends_type_clause` directly. The EXTENDS-vs-IMPLEMENTS split is decided downstream from the * resolved target's symbol kind in `preEmitInheritanceEdges` (class-extends → * EXTENDS, implements-interface / interface-target → IMPLEMENTS), so all bases * are emitted with the same `inherits` kind here. The base lookup name is @@ -746,7 +750,26 @@ function synthesizeTsInheritanceReferences(root: SyntaxNode, out: CaptureMatch[] if (child !== null) stack.push(child); } - if (node.type !== 'class_declaration') continue; + // `interface B extends A, C` hangs its bases off an `extends_type_clause` + // DIRECTLY on the interface — there is no `class_heritage` wrapper, so the + // class path below cannot reach them (#2842 review). The clause's `type` + // field is `multiple: true`, so `childForFieldName('type')` would silently + // return only `A` and drop `C`; iterate the named children instead. + if (node.type === 'interface_declaration') { + for (const child of node.namedChildren) { + if (child === null || child.type !== 'extends_type_clause') continue; + for (const base of child.namedChildren) { + emitTsInheritanceBase(base, out); + } + } + continue; + } + + // `abstract class X implements I` carries an identical `class_heritage` + // child, so the existing body handles it once the node type is admitted. + // Omitting it severed the only link between an interface and the concrete + // classes below an abstract base — a whole subtree, not a leaf. + if (node.type !== 'class_declaration' && node.type !== 'abstract_class_declaration') continue; // Find the `class_heritage` child (holds extends / implements clauses). let heritage: SyntaxNode | null = null; diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index a02f407c0..d3eb90aa6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -114,13 +114,23 @@ * languages that set `resolveThisViaEnclosingClass === true`; * it intercepts every bare-`this` call/read/write site ahead of * Case 4 and does NOT emit the interface-dispatch fan-out that - * Cases 0 and 4 both perform (Case 0 gained it in #2829), so - * enabling the toggle for a language changes that language's - * `this` dispatch semantics (see the toggle's doc below) + * Cases 0, 3b and 4 all perform (Case 0 gained it in #2829 and + * Case 3b in #2832, leaving 0.5 the only INSTANCE-receiver case + * that folds or walks to a receiver type without fanning out. + * Read that narrowly: Case 2 also walks an MRO and its binding + * admits `Interface`, but its receiver IS the type name, so the + * site is static dispatch and a fan-out would be wrong; Cases 3 + * and 5 resolve by direct lookup rather than a fold or MRO walk, + * and no language is known to reach Case 3 with an Interface — + * every one that could strips the namespace qualifier first, + * sending it to Case 4), so enabling the toggle + * for a language changes that language's `this` dispatch + * semantics (see the toggle's doc below) * 4. Case 1 namespace-receiver * 5. Case 2 class-name receiver * 6. Case 3 dotted typeBinding for namespace prefix - * 7. Case 3b chain-typebinding (compound resolver) + * 7. Case 3b chain-typebinding (compound resolver + interface-dispatch + * fan-out on an Interface fold, #2832) * 8. Case 4 simple typeBinding (MRO walk + findOwnedMember) * Reordering or merging cases changes resolution semantics. The * numbering is part of the contract — keep the comments. diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index ce7ae7902..cbe3112bb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -18,8 +18,8 @@ * toggle unset skip this case entirely; their `this` sites fall * through to Case 4 via the synthesized `this` typeBinding (which * emits the interface-dispatch fan-out that this case does not — - * as does Case 0 since #2829; Case 0.5 remains the only resolving - * case without it). + * as do Cases 0 since #2829 and 3b since #2832; Case 0.5 remains + * the only fold-or-walk case without it). * 4. **Case 1 (namespace)** — receiver in `namespaceTargets` → exported def * 5. **Case 2 (class-name / static receiver)** — receiver resolves to a * class-like binding (Class/Interface/Struct/Record/Enum/Trait) → MRO @@ -29,7 +29,9 @@ * 6. **Case 3 (dotted typeBinding for namespace prefix)** — * `typeRef.rawName` like `models.User` * 7. **Case 3b (chain-typebinding)** — `typeRef.rawName` has a dot - * but not a namespace prefix → compound resolver + * but not a namespace prefix → compound resolver. Also emits the + * interface-dispatch fan-out when the folded receiver type is an + * Interface (#2832) — same call Cases 0 and 4 make. * 8. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot → * MRO walk + `findOwnedMember` * 9. **Case 5 (value-receiver bridge)** — receiver is a `Const`/`Variable` @@ -430,6 +432,48 @@ export function emitReceiverBoundCalls( return graph.getNode(graphId)?.properties.isAbstract === true; }; + /** + * Can an INSTANCE-typed receiver reach this member? A static member cannot be, + * ever — `class C implements I { static save() {} }` does not satisfy `I` + * (TypeScript rejects it outright as TS2420, "Property 'save' is missing"), so + * an edge to it from an `I`-typed receiver names a target no dispatch can + * produce. Every comparable tool draws the same line: tsserver partitions + * static from instance results, clangd gates on `isVirtual()` (C++ forbids + * virtual statics), jdtls filters abstract-or-static, and class-hierarchy + * analysis expands only VIRTUAL call sites — a static call already has exactly + * one target and needs no fan-out. + * + * Two sources answer this, and the order matters: + * + * 1. `provider.isStaticOnly` when the language declares it. It is the + * precise answer, because a language that needs the distinction defines + * it exactly — Kotlin marks only COMPANION-promoted defs, so a Kotlin + * `object Impl : Iface { override fun handle() }` is correctly kept: an + * `object` is a singleton INSTANCE and its members really are reachable + * through an `Iface`-typed receiver. + * 2. Otherwise the graph node's `isStatic`. For every language that does not + * declare the hook, that flag comes from the member's own modifier (or, + * for Ruby, from `singleton_class` — `def self.foo`, which is likewise + * unreachable through an instance), so it means what we need here. + * + * Getting that order wrong is a live regression, not a hypothetical: the + * method extractor derives `isStatic` from the OWNER type as well as the + * member (`method-extractors/generic.ts`, `staticOwnerTypes`), and the JVM + * config lists `object_declaration`. Reading the flag first would delete + * Kotlin object implementations from the fan-out. A language that needs + * precision declares the hook; that is the upgrade path. + * + * Unresolvable defs fail open (treated as reachable), matching + * `isDeclarationOnly` and the rest of this pass. + */ + const isUnreachableByInstanceDispatch = (def: SymbolDefinition): boolean => { + const staticOnly = provider.isStaticOnly; + if (staticOnly !== undefined) return staticOnly(def) === true; + const graphId = resolveDefGraphId(def.filePath, def, nodeLookup); + if (graphId === undefined) return false; + return graph.getNode(graphId)?.properties.isStatic === true; + }; + /** * Emit secondary CALLS edges with reason='interface-dispatch' when the primary * receiver-typed edge targeted an Interface's method. @@ -486,6 +530,9 @@ export function emitReceiverBoundCalls( // A re-declared interface method or an `abstract` override is not an // implementation — keep descending past it rather than emitting to it. if (isDeclarationOnly(implMember)) continue; + // Nor is a static member: no instance-typed receiver can reach one, so + // an edge to it is a target dispatch cannot produce (#2842 review). + if (isUnreachableByInstanceDispatch(implMember)) continue; targets.push(implMember); } } @@ -1363,6 +1410,50 @@ export function emitReceiverBoundCalls( calleeCapture, ); if (ok) emitted++; + // Interface dispatch, exactly as Cases 0 and 4 do it (#2832). Case + // 3b folds a chain to a receiver type through the SAME + // `resolveCompoundReceiverClass` call and the same MRO walk Case 0 + // uses, so when that fold lands on an Interface the primary edge + // above names the interface's own bodiless DECLARATION and nothing + // reaches the implementations. + // + // Leaving 3b out made the fan-out a property of how the receiver + // was SPELLED rather than of what it resolved to: `d.repo.save()` + // took Case 0 and fanned out, while binding the identical field to + // a local first (`const r = d.repo; r.save()`) took Case 3b and + // did not. #2829 closed that gap for Case 0 and left this half of + // it open (#2832). + // + // `ownerDef` is the receiver's own folded type — matching Case 0's + // `currentClass` and Case 4's `ownerDef` — NOT the owner of the + // member the MRO walk settled on. That distinction matters: a + // receiver that folds to a concrete class merely INHERITING an + // interface method must not fan out, because its runtime type is + // that class. `emitInterfaceDispatchFor` self-gates on + // `ownerDef.type !== 'Interface'`, so this is inert for every + // concrete receiver and needs no language check of its own. + // + // That gate is deliberately narrower than "the primary landed on + // something bodiless": a chain folding to an ABSTRACT class also + // dead-ends on a declaration-only member and does NOT fan out + // here. Widening it to `|| isDeclarationOnly(memberDef)` would + // cover that, but it changes Cases 0 and 4 identically and for + // every language, so it is not #2832's to make. + // + // Confidence mirrors THIS case's own primary emit above — the 0.85 + // literal — so a site's dispatch edges never claim more certainty + // than the edge they hang off. Case 4 passes a site.kind-dependent + // value instead because ITS primary varies that way; Case 3b's + // primary, like Case 0's, does not, so there is no 1.0 arm here to + // mirror. + emitted += emitInterfaceDispatchFor( + ownerDef, + memberName, + memberDef, + site, + 0.85, + calleeCapture, + ); // Always mark handled when the site was resolved, even // if the edge was deduplicated (collapse mode), so // `emitReferencesViaLookup` doesn't re-emit from the diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index a5688b77f..16fab259f 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -222,7 +222,13 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // value is unchanged by the rebase — the reason it was chosen is simply now // visible in the history above. RE-CHECK AGAINST origin/main IMMEDIATELY // BEFORE MERGING; this file records eight prior collisions, two EXACT. -const SCHEMA_BUMP = 43; +// Moved 43 -> 44 for #2842's TypeScript heritage capture, which now emits +// `@reference.inherits` for `interface_declaration` and +// `abstract_class_declaration`. That is PARSE-TIME emission, so a v43 warm +// cache would serve entries that are missing those matches entirely — the +// exact failure a bump exists to prevent. Verified against origin/main at +// a857f4c5a, which is still on 43, so 44 is free. RE-CHECK BEFORE MERGE. +const SCHEMA_BUMP = 44; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/app.ts new file mode 100644 index 000000000..602db29ea --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/app.ts @@ -0,0 +1,28 @@ +import { Deps } from './deps'; + +// `r` is a bare-name receiver whose type binding is the member expression +// `d.repo` — the chain-typebinding shape that reaches Case 3b. The fold lands +// on the Repo INTERFACE, so the primary edge targets Repo.save and the +// implementations are reachable only through the interface-dispatch fan-out. +export function runSave(d: Deps): void { + const r = d.repo; + r.save('row'); +} + +// Same shape, concrete owner: Case 3b resolves PlainCache, which is not an +// Interface, so the fan-out must stay inert. +export function runCache(d: Deps): void { + const c = d.cache; + c.run(); +} + +// Stronger negative than runCache: SqlRepo IMPLEMENTS Repo, so an interface is +// in scope at this site and `save` is a name Repo also declares. The fan-out +// must still stay inert, because the fold produced the concrete class — the +// receiver's runtime type is SqlRepo, not "any Repo". This is what fails if a +// later change fans out from the interface a member is DECLARED in rather than +// from the receiver's own folded type. +export function runConcrete(d: Deps): void { + const s = d.sql; + s.save('row'); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/deps.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/deps.ts new file mode 100644 index 000000000..4560a5a96 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/deps.ts @@ -0,0 +1,15 @@ +import { Repo } from './repository'; +import { PlainCache } from './plain-cache'; +import { SqlRepo } from './sql-repo'; +import { ShadowRepo } from './shadow-repo'; + +/** + * Declared field types only. No initializer and no constructor assignment, so + * the field's type comes from the annotation rather than from an inferred RHS. + */ +export class Deps { + repo!: Repo; + cache!: PlainCache; + sql!: SqlRepo; + shadow!: ShadowRepo; +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/hierarchy.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/hierarchy.ts new file mode 100644 index 000000000..886ab6ecc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/hierarchy.ts @@ -0,0 +1,30 @@ +import { Repo } from './repository'; + +// The two shapes the dispatch closure exists to handle, and which TypeScript +// heritage capture silently dropped before #2842's review. + +// (1) Abstract intermediate: the interface reaches DiskRepo only through +// BaseRepo. The abstract declaration itself is bodiless and must be walked +// THROUGH, not emitted to — tsc's own Go-to-Implementation does the same. +export abstract class BaseRepo implements Repo { + abstract save(entity: string): boolean; +} + +export class DiskRepo extends BaseRepo { + save(entity: string): boolean { + return entity.length > 1; + } +} + +// (2) Interface extension: `Archiving extends Repo` means an implementor of +// Archiving is an implementor of Repo, two hops from the receiver's type. +export interface Archiving extends Repo { + archive(): void; +} + +export class ColdRepo implements Archiving { + save(entity: string): boolean { + return entity !== 'cold'; + } + archive(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/mem-repo.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/mem-repo.ts new file mode 100644 index 000000000..2eb0a98af --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/mem-repo.ts @@ -0,0 +1,7 @@ +import { Repo } from './repository'; + +export class MemRepo implements Repo { + save(entity: string): boolean { + return entity !== ''; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/plain-cache.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/plain-cache.ts new file mode 100644 index 000000000..2847204e8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/plain-cache.ts @@ -0,0 +1,5 @@ +export class PlainCache { + run(): void { + // No interface in sight — the negative control for the fan-out gate. + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/repository.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/repository.ts new file mode 100644 index 000000000..43edc8792 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/repository.ts @@ -0,0 +1,3 @@ +export interface Repo { + save(entity: string): boolean; +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/shadow-repo.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/shadow-repo.ts new file mode 100644 index 000000000..8c79dd209 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/shadow-repo.ts @@ -0,0 +1,13 @@ +import { SqlRepo } from './sql-repo'; + +// A subclass adding a STATIC helper that shares its name with the instance +// method it inherits — an ordinary shape (`static create` / `static serialize` +// mirroring an instance method). A `Repo`-typed receiver can hold a ShadowRepo, +// but dispatches to the INHERITED SqlRepo.save; the static below is reachable +// only as `ShadowRepo.save(...)`, never through an instance. It must never +// appear in the interface-dispatch fan-out. +export class ShadowRepo extends SqlRepo { + static save(entity: string): boolean { + return entity === ''; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/sql-repo.ts b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/sql-repo.ts new file mode 100644 index 000000000..28fcf3d10 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-chain-interface-dispatch/sql-repo.ts @@ -0,0 +1,7 @@ +import { Repo } from './repository'; + +export class SqlRepo implements Repo { + save(entity: string): boolean { + return entity.length > 0; + } +} diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 6fa9f3213..df9041fba 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -3562,3 +3562,101 @@ export function alreadyWorked(svc: Service): void { ).toHaveLength(1); }); }); + +// --------------------------------------------------------------------------- +// Case 3b (chain-typebinding) interface dispatch (#2832). `const r = d.repo` +// binds `r` to the member expression `d.repo`, so the receiver is a bare name +// with a DOTTED typeBinding rawName — Case 3b's entry condition. Case 0 cannot +// take the site (`r` has no `.`/`(`, and no receiver chain is minted for a +// bare identifier), and Case 4 excludes itself on the dot. The fold lands on +// an Interface, so the implementations are reachable only via the fan-out. +// --------------------------------------------------------------------------- + +describe('TypeScript chain-typed receiver folding to an interface (Case 3b, #2832)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-chain-interface-dispatch'), + () => {}, + ); + }, 60000); + + const saveCalls = () => + getRelationships(result, 'CALLS').filter((e) => e.source === 'runSave' && e.target === 'save'); + const fanout = () => saveCalls().filter((e) => e.rel.reason === 'interface-dispatch'); + const basename = (p: string) => p.slice(p.lastIndexOf('/') + 1); + + it('emits the primary edge to the interface declaration', () => { + const primary = saveCalls().filter((e) => e.rel.reason !== 'interface-dispatch'); + expect(primary.map((e) => basename(e.targetFilePath))).toEqual(['repository.ts']); + }); + + // A static member can never be reached through an instance-typed receiver — + // TypeScript rejects `class C implements I { static save() {} }` as TS2420, + // "Property 'save' is missing". ShadowRepo inherits the real SqlRepo.save and + // adds a same-named static; only the inherited instance method is a dispatch + // target. Before the guard this emitted an edge to the static one. + it('never fans out to a static member', () => { + const targets = fanout().map((e) => `${basename(e.targetFilePath)}`); + expect(targets).not.toContain('shadow-repo.ts'); + }); + + // Covers all three hierarchy shapes, now that TypeScript emits heritage for + // interfaces and abstract classes too (#2842 review): a direct implementor + // (sql-repo, mem-repo), a concrete class below an ABSTRACT intermediate + // (hierarchy.ts DiskRepo, reachable only through BaseRepo), and an + // implementor of an EXTENDING interface (hierarchy.ts ColdRepo, two hops from + // the receiver's type). Exact-set, so a target appearing OR vanishing fails. + // Two hierarchy.ts entries because that file holds two of the four targets. + it('fans out through abstract intermediates and interface extension', () => { + expect( + fanout() + .map((e) => basename(e.targetFilePath)) + .sort(), + ).toEqual(['hierarchy.ts', 'hierarchy.ts', 'mem-repo.ts', 'sql-repo.ts']); + }); + + // The abstract declaration is bodiless: it must be WALKED THROUGH to reach + // DiskRepo, never emitted to. tsc's own Go-to-Implementation behaves the same + // way — the rule everywhere is "does it have a body?", not "is it in a class?". + it('walks through the abstract declaration without targeting it', () => { + const names = fanout() + .filter((e) => basename(e.targetFilePath) === 'hierarchy.ts') + .map((e) => e.target); + expect(names).toEqual(['save', 'save']); + }); + + it('never targets the interface declaration in the fan-out', () => { + expect(fanout().map((e) => basename(e.targetFilePath))).not.toContain('repository.ts'); + }); + + // Stronger negative than the PlainCache case below: here an interface IS in + // scope (SqlRepo implements Repo) and `save` is a name Repo declares, yet the + // receiver's folded type is the concrete class, so nothing may fan out. + // + // This does NOT catch "member owner passed instead of folded type". The + // reason is a property of TypeScript, not of the control: TS's MRO chain + // never contains an implemented interface, so the walk cannot settle on an + // interface declaration for a concrete receiver and the two values coincide. + // (Not, as an earlier draft of this comment claimed, because an implementing + // class always declares the member itself — `class C extends Base implements + // I {}` is valid TS and inherits it.) The mutation IS expressible where a + // concrete class inherits a `default` interface method — Java or Kotlin — + // and is tracked for a follow-up fixture there. + it('emits no fan-out when the chain folds to a concrete implementor', () => { + const concrete = getRelationships(result, 'CALLS').filter( + (e) => e.source === 'runConcrete' && e.target === 'save', + ); + expect(concrete.map((e) => e.rel.reason).filter((r) => r === 'interface-dispatch')).toEqual([]); + expect(concrete.map((e) => basename(e.targetFilePath))).toEqual(['sql-repo.ts']); + }); + + it('emits no fan-out when the chain folds to a concrete class', () => { + const runCalls = getRelationships(result, 'CALLS').filter( + (e) => e.source === 'runCache' && e.target === 'run', + ); + expect(runCalls.map((e) => e.rel.reason).filter((r) => r === 'interface-dispatch')).toEqual([]); + expect(runCalls.map((e) => basename(e.targetFilePath))).toEqual(['plain-cache.ts']); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index ec4224b21..c253070fb 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -120,8 +120,11 @@ describe('PARSE_CACHE_VERSION', () => { // against origin/main immediately before merge, not at review time. // Moved 42 -> 43 for #2813's `@reference.embedded-pointer` capture, which is // parse-time emission and so cannot be served from a v42 warm cache. - it('pins SCHEMA_BUMP to 43 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(43); + // Moved 43 -> 44 for #2842's TypeScript heritage capture (interface and + // abstract-class `@reference.inherits`), which is parse-time emission and so + // cannot be served from a v43 warm cache. + it('pins SCHEMA_BUMP to 44 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(44); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {