diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index 4969a17a9..e8337a6d9 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -9803b81f0c3738ecd276aba187436482be47b5f5f62e5e85a983524129713b7d +06687dff942d531c4d453b5906a8666c90db4867eb43ed18304aa59a8a93ef9d diff --git a/gitnexus/src/core/ingestion/languages/python/query.ts b/gitnexus/src/core/ingestion/languages/python/query.ts index b22573a91..5b999649f 100644 --- a/gitnexus/src/core/ingestion/languages/python/query.ts +++ b/gitnexus/src/core/ingestion/languages/python/query.ts @@ -13,11 +13,35 @@ const PYTHON_SCOPE_QUERY = ` (module) @scope.module (class_definition) @scope.class (function_definition) @scope.function +(lambda) @scope.function ;; Declarations (class_definition name: (identifier) @declaration.name) @declaration.class +;; Heritage — bare identifier +;; NOTE: captures.ts on main already synthesizes @reference.inherits for +;; qualified bases via #1951/#1956. These @heritage.* patterns are redundant +;; with that synthesis but kept as documentation and a safety net for the +;; generic heritage extractor path. They produce topicOf edges that the +;; resolution pipeline ignores when the synthesis path wins. +(class_definition + name: (identifier) @heritage.class + superclasses: (argument_list + (identifier) @heritage.extends)) @heritage + +;; Heritage — qualified base (module.Class) +(class_definition + name: (identifier) @heritage.class + superclasses: (argument_list + (attribute) @heritage.extends)) @heritage + +;; Heritage — subscripted/generic base (Generic[T]) +(class_definition + name: (identifier) @heritage.class + superclasses: (argument_list + (subscript) @heritage.extends)) @heritage + (function_definition name: (identifier) @declaration.name) @declaration.function @@ -234,6 +258,22 @@ const PYTHON_SCOPE_QUERY = ` name: (identifier) @type-binding.name return_type: (type) @type-binding.type) @type-binding.return +;; Decorators — simple @decorator +(decorator + (identifier) @reference.name) @reference.call.free + +;; Decorators — @obj.decorator (single attribute, identifier receiver) +(decorator + (attribute + object: (identifier) @reference.receiver + attribute: (identifier) @reference.name)) @reference.call.member + +;; Decorators — @a.b.decorator (nested attributes) +(decorator + (attribute + object: (attribute) @reference.receiver + attribute: (identifier) @reference.name)) @reference.call.member + ;; References — calls (call function: (identifier) @reference.name) @reference.call.free diff --git a/gitnexus/test/fixtures/lang-resolution/python-parsing-coverage/heritage.py b/gitnexus/test/fixtures/lang-resolution/python-parsing-coverage/heritage.py new file mode 100644 index 000000000..2f8b22a3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-parsing-coverage/heritage.py @@ -0,0 +1,19 @@ +"""Heritage fixture — bare, qualified, and subscripted bases.""" +from typing import Generic, TypeVar + +T = TypeVar('T') + +class BaseModel: + pass + +class Bare(BaseModel): + pass + +class Qualified(mod.BaseModel): + pass + +class Subscripted(Generic[T]): + pass + +class Both(mod.BaseModel, Generic[T]): + pass diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index 3f1189813..1a133a2f7 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -4,12 +4,12 @@ "digest": "8662c17b0f21fcfa650065abd62f0c9b7e1c65bf8a1f7dce6f2a16bba9df759f" }, "python-abstract-dispatch/base.py": { - "captureGroups": 16, - "digest": "2d25dcc17cb5b31cea26c15776d3a8cae290790d92a7adc6d6155be534fdd75b" + "captureGroups": 19, + "digest": "892a2e6ad60f7fc6e206bcc42f9c8905dfbab27e84b4334a3a4ba353078a879a" }, "python-abstract-dispatch/impl.py": { - "captureGroups": 15, - "digest": "6c27015f13d32024ce06c1515ab29ca0436df7a4864bbb62188d5f79d685cc31" + "captureGroups": 16, + "digest": "5f785934a573d11499ccea29ae87daaaf817390884acd1328a803ec0ce828297" }, "python-alias-imports/app.py": { "captureGroups": 13, @@ -40,8 +40,8 @@ "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, "python-ambiguous/services/user_handler.py": { - "captureGroups": 8, - "digest": "5a4bb82d0e6a6a53fe012f197e42c572ac1169ff38e84db9c6281399f6739021" + "captureGroups": 9, + "digest": "b0ee065813f8d113ad5f6cc413a36bc645e05fe9b533c3a40cd0b62fe9eb77ca" }, "python-ancestor-import/a/b/c/deep.py": { "captureGroups": 5, @@ -128,8 +128,8 @@ "digest": "0f60d5cd521b0073524b0993e82d5291f86badd5cbefb986cefdf7b0bed64157" }, "python-child-extends-parent/child.py": { - "captureGroups": 5, - "digest": "d118691eb76c9432841743efee8556f1e7a1d136e9b403a12fd512f91d73ca61" + "captureGroups": 6, + "digest": "48c1f798021986fbe074bb37fdde4763791bfa9a18d1172510a7f4fe4ed676d5" }, "python-child-extends-parent/parent.py": { "captureGroups": 7, @@ -208,16 +208,16 @@ "digest": "392b15be747e2b5cbd3ac5a9e61a7677ffa6ba52e49d3631681e43c557373b5f" }, "python-django-app-imports/accounts/apps.py": { - "captureGroups": 6, - "digest": "784cba903ad9534337ed820b085c8ecc352964e797bde1d4dda9e700960366a0" + "captureGroups": 7, + "digest": "d7ef23ddaa13aa398f642580fd19bfff9207e88d0015aeb23a04bbd3140c7bb4" }, "python-django-app-imports/accounts/migrations/__init__.py": { "captureGroups": 0, "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, "python-django-app-imports/accounts/models.py": { - "captureGroups": 8, - "digest": "b240f4ea2135824ee47fd5f2d9a4788ff0374cafb5070b295fc710a523f19873" + "captureGroups": 9, + "digest": "a513607782f1a3d33af594aff2674ff3fa72e38588bf3590e9581e8ca0dcf47a" }, "python-django-app-imports/accounts/tests.py": { "captureGroups": 2, @@ -236,16 +236,16 @@ "digest": "392b15be747e2b5cbd3ac5a9e61a7677ffa6ba52e49d3631681e43c557373b5f" }, "python-django-app-imports/billing/apps.py": { - "captureGroups": 6, - "digest": "0ff487476397cc85c2ce5ec0afe59eb82d83f97bcb3d4518b5040f52790e1833" + "captureGroups": 7, + "digest": "64644bea82f36aef4f7f2784405a630b6b5f5b87f32f837372190642b8af3557" }, "python-django-app-imports/billing/migrations/__init__.py": { "captureGroups": 0, "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, "python-django-app-imports/billing/models.py": { - "captureGroups": 12, - "digest": "eedb2e1992f85a6743555947118784db9c78f3c113d2d598e53dd54cb80a0629" + "captureGroups": 13, + "digest": "94396c0755b2fab7ef73c06f92a237893097b57bf83637b83d39f6d0e0f6e0d9" }, "python-django-app-imports/billing/tests.py": { "captureGroups": 2, @@ -348,12 +348,12 @@ "digest": "5879a42d6655248623c9aee193fedcae51daa1887d8e597fdd944ad93af053a4" }, "python-grandparent-resolution/models/b.py": { - "captureGroups": 5, - "digest": "be5c28ebfb06cd90c7cd453d612f0cacdffdac3e57d7ce794c0a353a9b057597" + "captureGroups": 6, + "digest": "58660d2049990cebed4ea917b45eb32d21120ee88633c97fa19d91277b25cd6a" }, "python-grandparent-resolution/models/c.py": { - "captureGroups": 5, - "digest": "2ad9124422ca018e59855c55a054d5c37d15b448a90027842fc56dd6f61e4593" + "captureGroups": 6, + "digest": "a04777b65ef018c652b96aa8978a67d14ee94905a5d3328c00a4c83ed1fb486a" }, "python-grandparent-resolution/models/greeting.py": { "captureGroups": 7, @@ -472,8 +472,8 @@ "digest": "88dfd417951b8083184f83da8c1e0c2b19700cfbf1f1ff203a904ebc00f50b30" }, "python-method-enrichment/models.py": { - "captureGroups": 25, - "digest": "14c45aebe0fc6ad1a9328bda3da8cdf7eaa6b9d84641cd2d63c1a105aa18cd42" + "captureGroups": 29, + "digest": "83ebd37c527396004fdb9175beeeb09ed03645a04bc6a4eaa25b1260ac123ad1" }, "python-module-export-vs-method-collision/app.py": { "captureGroups": 14, @@ -500,16 +500,16 @@ "digest": "98bcec072e85a50303be141212b835322f5f9e53f7fe5d77b23d8ee524623e84" }, "python-multi-level-mro/child.py": { - "captureGroups": 5, - "digest": "d118691eb76c9432841743efee8556f1e7a1d136e9b403a12fd512f91d73ca61" + "captureGroups": 6, + "digest": "48c1f798021986fbe074bb37fdde4763791bfa9a18d1172510a7f4fe4ed676d5" }, "python-multi-level-mro/grandparent.py": { "captureGroups": 7, "digest": "f9f81d3a37c55b3e23bec3774405920afa29c5793c46860a98a06c5d0c7f0980" }, "python-multi-level-mro/parent.py": { - "captureGroups": 5, - "digest": "b68bfb8fdedb8f725c609264e604ccb674a5a775c0d87c008a9990234c70bde3" + "captureGroups": 6, + "digest": "884be03e3640693bc087e297d018ce37d92cfd7cc5742cbca61471cd4a3c9e8c" }, "python-multi-segment-ancestor-import/backend/auth_utils.py": { "captureGroups": 6, @@ -592,16 +592,20 @@ "digest": "f0384bd6ecb7d1a9ad2306358917b7295c71ea8f1bcea818fd39c56d2c28c7e9" }, "python-parent-resolution/models/user.py": { - "captureGroups": 9, - "digest": "b06a66a108097eec9427a028dec38bbab284918ac39344e86e58bba89533c530" + "captureGroups": 10, + "digest": "897b68f06f59fd0a488d0ac5d694aa655a82c09f8846202697a61def1f32c488" + }, + "python-parsing-coverage/heritage.py": { + "captureGroups": 26, + "digest": "3ceaf5293361ca76d52aa6941bd82674a16d1bd002a1fa8bf0b2f84cd00120c2" }, "python-pkg/models/base.py": { "captureGroups": 9, "digest": "4984ee01b7a9fefe622195f0e4925823c0e62a1714ca2dda5cd8250e5e45fa7c" }, "python-pkg/models/user.py": { - "captureGroups": 8, - "digest": "9ee707b36f42a635fdb867ce20359b5f51ac4e13550cde801359dc8314e01a77" + "captureGroups": 9, + "digest": "7b6984be334f50334e6ebe4a895cf61e2d3773edd1c03ed41440b76abcd44f01" }, "python-pkg/services/auth.py": { "captureGroups": 9, @@ -636,8 +640,8 @@ "digest": "7fc34dae23f54cdee030a2d4a1d80c0bf226ea3b37335ecf48551c4707d32e20" }, "python-qualified-base/service.py": { - "captureGroups": 16, - "digest": "adecbd613fe97656cd5797c4dbef9f3c32bcd1b9faa5e765227af5d2001da427" + "captureGroups": 20, + "digest": "d1f66f8587c7c0e8284e877d2846685acb8bb165f1cfc96deea4688a4cf26c23" }, "python-qualified-constructor/main.py": { "captureGroups": 9, @@ -712,8 +716,8 @@ "digest": "b397708c05d101d0d3978313f65184c9f181a179b803d242b1e585ac2605fe4a" }, "python-static-class-methods/service.py": { - "captureGroups": 27, - "digest": "9539dd5884b6b5ef3ad14b9d06837a495b4d3908462ec37464524c259a2cbd75" + "captureGroups": 32, + "digest": "91730faa59b93ee5a06c8f7050831d32f39ad89c624cb665a4eeb38449aa2c3e" }, "python-super-resolution/models/__init__.py": { "captureGroups": 0, @@ -728,8 +732,8 @@ "digest": "1d6eb1cdc661f2463d8e1a499eaa5bfcd9367324f6090c44fe4d59ed02151215" }, "python-super-resolution/models/user.py": { - "captureGroups": 11, - "digest": "8a66f8962fcf960b66c1106d1d67da21f7f6bac323b961e5fc0623b3c93dbc38" + "captureGroups": 12, + "digest": "8faf7b3a238f654f3ae2231cd5a7d968b2604481581bc2e9cb07897e5027011f" }, "python-variadic-resolution/app.py": { "captureGroups": 5, @@ -768,7 +772,7 @@ "digest": "0aa940e3428c35b1324544e9bcc75c166732b63b3e65e6faf460bc3d3a9571ec" }, "synthetic:dao-20": { - "captureGroups": 473, - "digest": "27a7e0ea629f7ed002f1bd254126c819125900b27e0818d879e8fdaa8690aab9" + "captureGroups": 493, + "digest": "3ef7932339801c2cfa24609b741b472bed2f1037b413edafaadf8b8c2edd8f7c" } } diff --git a/gitnexus/test/integration/resolvers/python-parsing-coverage.test.ts b/gitnexus/test/integration/resolvers/python-parsing-coverage.test.ts new file mode 100644 index 000000000..30a00c051 --- /dev/null +++ b/gitnexus/test/integration/resolvers/python-parsing-coverage.test.ts @@ -0,0 +1,164 @@ +/** + * Regression tests for Python scope-resolution coverage gaps (issue #1932). + * + * Each fixture FAILS on main and PASSES on the fix branch. + */ +import { describe, it, expect } from 'vitest'; +import { emitPythonScopeCaptures } from '../../../src/core/ingestion/languages/python/index.js'; +import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; +import { pythonProvider } from '../../../src/core/ingestion/languages/python.js'; +import type { CaptureMatch } from 'gitnexus-shared'; + +/** + * Count matches whose capture-key set satisfies `predicate`. + */ +function countCaptures(src: string, predicate: (tags: string[]) => boolean): number { + const matches = emitPythonScopeCaptures(src, 'test.py') as CaptureMatch[]; + return matches.filter((m) => predicate(Object.keys(m))).length; +} + +// --------------------------------------------------------------------------- +// F57 — Heritage: qualified/subscripted bases +// --------------------------------------------------------------------------- + +describe('F57 — Python heritage (qualified / subscripted bases)', () => { + it('bare identifier base class emits @heritage.class + @heritage.extends', () => { + const src = ` +class Base: + pass + +class Child(Base): + pass +`; + const matches = emitPythonScopeCaptures(src, 'test.py') as CaptureMatch[]; + const heritageMatches = matches.filter((m) => m['@heritage.class']); + expect(heritageMatches.length).toBe(1); + expect(heritageMatches[0]['@heritage.class'].text).toBe('Child'); + expect(heritageMatches[0]['@heritage.extends'].text).toBe('Base'); + }); + + it('qualified base (mod.Class) emits @heritage.extends for attribute', () => { + const src = ` +class A(mod.Base): + pass +`; + const matches = emitPythonScopeCaptures(src, 'test.py') as CaptureMatch[]; + const heritageMatches = matches.filter((m) => m['@heritage.class']); + expect(heritageMatches.length).toBe(1); + expect(heritageMatches[0]['@heritage.class'].text).toBe('A'); + expect(heritageMatches[0]['@heritage.extends'].text).toBe('mod.Base'); + }); + + it('subscripted base (Generic[T]) emits @heritage.extends for subscript', () => { + const src = ` +from typing import Generic, TypeVar +T = TypeVar('T') + +class B(Generic[T]): + pass +`; + const matches = emitPythonScopeCaptures(src, 'test.py') as CaptureMatch[]; + const heritageMatches = matches.filter((m) => m['@heritage.class']); + expect(heritageMatches.length).toBe(1); + expect(heritageMatches[0]['@heritage.class'].text).toBe('B'); + expect(heritageMatches[0]['@heritage.extends'].text).toBe('Generic[T]'); + }); + + it('multiple patterns coexist with bare-identifier heritage', () => { + const src = ` +class C(types.Type): + pass +`; + const matches = emitPythonScopeCaptures(src, 'test.py') as CaptureMatch[]; + const heritageMatches = matches.filter((m) => m['@heritage.class']); + expect(heritageMatches.length).toBe(1); + expect(heritageMatches[0]['@heritage.class'].text).toBe('C'); + expect(heritageMatches[0]['@heritage.extends'].text).toBe('types.Type'); + }); +}); + +// --------------------------------------------------------------------------- +// F58 — Decorator captures +// --------------------------------------------------------------------------- + +describe('F58 — Python decorator captures', () => { + it('simple @app.route decorator emits @reference.call.member', () => { + const src = ` +@app.route("/") +def index(): + return "ok" +`; + const matches = emitPythonScopeCaptures(src, 'test.py') as CaptureMatch[]; + const decoratorMatches = matches.filter((m) => m['@reference.call.member']); + expect(decoratorMatches.length).toBe(1); + expect(decoratorMatches[0]['@reference.name']?.text).toBe('route'); + }); + + it('nested attribute decorator @api.v1.endpoint emits @reference.call.member', () => { + const src = ` +@api.v1.endpoint +def handler(): + pass +`; + const matches = emitPythonScopeCaptures(src, 'test.py') as CaptureMatch[]; + const decoratorMatches = matches.filter((m) => m['@reference.call.member']); + expect(decoratorMatches.length).toBe(1); + expect(decoratorMatches[0]['@reference.name']?.text).toBe('endpoint'); + }); + + it('simple @decorator (bare identifier) emits @reference.call.free', () => { + const src = ` +@login_required +def protected_view(): + pass +`; + const matches = emitPythonScopeCaptures(src, 'test.py') as CaptureMatch[]; + const decoratorMatches = matches.filter((m) => m['@reference.call.free']); + expect(decoratorMatches.length).toBe(1); + expect(decoratorMatches[0]['@reference.name']?.text).toBe('login_required'); + }); +}); + +// --------------------------------------------------------------------------- +// F58 — End-to-end: extractParsedFile produces referenceSites +// --------------------------------------------------------------------------- + +describe('F58 — decorator produces referenceSites in extractParsedFile', () => { + it('@login_required produces a referenceSite entry', () => { + const src = `@login_required\ndef foo():\n pass\n`; + const parsedFile = extractParsedFile(pythonProvider, src, 'app.py', () => {}); + expect(parsedFile).not.toBeNull(); + expect(parsedFile!.referenceSites.length).toBeGreaterThanOrEqual(1); + const hasLoginRef = parsedFile!.referenceSites.some((r) => r.name === 'login_required'); + expect(hasLoginRef).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// F61 — Lambda scope +// --------------------------------------------------------------------------- + +describe('F61 — Python lambda scope', () => { + it('bare lambda emits @scope.function', () => { + const src = `handler = lambda x: x + 1\n`; + const scopeFnCount = countCaptures(src, (tags) => tags.includes('@scope.function')); + expect(scopeFnCount).toBe(1); + }); + + it('multiple lambdas each get their own @scope.function', () => { + const src = `double = lambda x: x * 2\ntriple = lambda x: x * 3\n`; + const scopeFnCount = countCaptures(src, (tags) => tags.includes('@scope.function')); + expect(scopeFnCount).toBe(2); + }); + + it('lambda coexists with function_definition scopes', () => { + const src = ` +def normal(x): + return x + 1 + +handler = lambda x: x * 2 +`; + const scopeFnCount = countCaptures(src, (tags) => tags.includes('@scope.function')); + expect(scopeFnCount).toBe(2); + }); +});