From d616787abb333d60bba22eb29ffb2fb25e80d54d Mon Sep 17 00:00:00 2001 From: abhigyanpatwari Date: Sun, 7 Jun 2026 18:20:15 +0530 Subject: [PATCH] =?UTF-8?q?fix(ingestion):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20Python=20class-const=20prune=20+=20golden=20regen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the two blocking review findings on #2065: - Python class-level constants/attributes were silently pruned. tree-sitter-python models a class body as a `block` node, so `determineScope` classified class attributes as function-locals (scope='block'). Value labels get no owner edge, so they carried only the structural File->DEFINES edge and the prune pass deleted any unreferenced one. `determineScope` now skips a `block` whose parent is a class/type declaration, so class members resolve to their true enclosing scope and survive. Other languages use dedicated class-body node types and were already safe. - Regenerated the stale `pipeline-graph-golden` snapshot for the 4 pruned block-local Const (symbols 37->33, DEFINES 20->16) — the drift that turned CI red. Regenerated from the git-tracked fixture only. Non-blocking review items: - Thread `keepLocalValueSymbols` through `PipelineOptions` so long-running hosts can opt out per-call without mutating `process.env`. - Drop the always-false `isFileDefinesEdge` call in the pruner's candidate-as-source branch (any outgoing edge => keep) and simplify the helper now that it is only called for incoming edges. - Declare `pruneLocalSymbols` explicitly on `communities`/`processes` so they always read the trimmed graph even if `mro` is ever skipped. - Document the new phase + env var/option in ARCHITECTURE.md and README; note pruning still runs under `skipGraphPhases`. - Tests: Python class-constant survival (unit determineScope + pipeline integration), candidate-as-source, unset-scope, and the option hatch. Co-Authored-By: Claude Opus 4.8 (1M context) --- ARCHITECTURE.md | 15 +++--- gitnexus/README.md | 10 ++++ .../src/core/ingestion/local-symbol-pruner.ts | 20 +++---- .../ingestion/pipeline-phases/communities.ts | 7 ++- .../ingestion/pipeline-phases/processes.ts | 8 +-- .../pipeline-phases/prune-local-symbols.ts | 4 +- gitnexus/src/core/ingestion/pipeline.ts | 15 +++++- .../ingestion/variable-extractors/generic.ts | 30 ++++++++++- .../mini-repo/expected-graph.json | 9 ++-- .../local-symbol-pruner-pipeline.test.ts | 40 +++++++++++++- .../test/unit/local-symbol-pruner.test.ts | 52 +++++++++++++++++++ .../test/unit/variable-extraction.test.ts | 23 ++++++++ 12 files changed, 201 insertions(+), 32 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f65c175f9..d90d132f6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -77,11 +77,11 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`). ## Pipeline Phase DAG -12 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output. +14 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output. ``` scan → structure → [markdown, cobol] → parse → [routes, tools, orm] - → crossFile → mro → communities → processes + → crossFile → scopeResolution → pruneLocalSymbols → mro → communities → processes ``` | Phase | File | Deps | Output | @@ -95,9 +95,11 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm] | `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges | | `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) | | `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order | -| `mro` | `mro.ts` | `crossFile`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges | -| `communities` | `communities.ts` | `mro`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) | -| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `structure` | Process nodes + STEP_IN_PROCESS edges | +| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator | +| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution | +| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges | +| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) | +| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges | **Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `orm-extraction.ts` (sequential ORM fallback), `types.ts`, `runner.ts`, `index.ts`. @@ -119,7 +121,8 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm] - **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output. - **Typed phase access** — `getPhaseOutput(deps, 'name')` for type-safe upstream results. - **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership. -- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests). `skipWorkers` forces sequential parsing. +- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` forces sequential parsing. +- **Local-symbol pruning** — `pruneLocalSymbols` removes inert block-local value symbols after scope resolution has consumed them. Opt out per-call with `PipelineOptions.keepLocalValueSymbols` or globally with the `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` env var. ### How to add a new phase diff --git a/gitnexus/README.md b/gitnexus/README.md index 54c0f7c81..7c84087ea 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -423,6 +423,16 @@ Three env vars expose the pool's resilience layers (respawn budget, cumulative-t | `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. | | `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. | +### Graph cleanup tuning + +After scope resolution, analyze prunes inert block-local value symbols (a function-local `const`/`let`/`var` that ends up with only its structural `File→DEFINES` edge) to keep the graph focused on cross-symbol relationships. Module/file-scope symbols, class members, and any local with a real edge are always kept. + +| Variable | Default | Effect | +| ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------- | +| `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` | unset | Set to `1`/`true` to keep inert block-local value symbols instead of pruning them. | + +Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions` instead of setting the env var. + ## Privacy - All processing happens locally on your machine diff --git a/gitnexus/src/core/ingestion/local-symbol-pruner.ts b/gitnexus/src/core/ingestion/local-symbol-pruner.ts index b94253397..3ff876b44 100644 --- a/gitnexus/src/core/ingestion/local-symbol-pruner.ts +++ b/gitnexus/src/core/ingestion/local-symbol-pruner.ts @@ -27,13 +27,11 @@ const isLocalValueCandidate = (node: GraphNode): boolean => { return node.properties.scope === 'block'; }; -const isFileDefinesEdgeToCandidate = ( - graph: KnowledgeGraph, - rel: GraphRelationship, - candidateId: string, -): boolean => { +// True when `rel` is the structural `File -> DEFINES -> candidate` edge. Callers +// guard on the candidate already being the edge target, so only the source label +// needs checking here. +const isFileDefinesEdge = (graph: KnowledgeGraph, rel: GraphRelationship): boolean => { if (rel.type !== 'DEFINES') return false; - if (rel.targetId !== candidateId) return false; return graph.getNode(rel.sourceId)?.label === 'File'; }; @@ -54,14 +52,16 @@ export const pruneLocalValueSymbols = ( const candidatesWithSemanticEdges = new Set(); for (const rel of graph.iterRelationships()) { + // Any outgoing edge from a candidate is a semantic edge: the only structural + // edge a block-local value symbol carries is the incoming File -> DEFINES, on + // which the candidate is the target, never the source. if (candidateIds.has(rel.sourceId)) { - if (!isFileDefinesEdgeToCandidate(graph, rel, rel.sourceId)) { - candidatesWithSemanticEdges.add(rel.sourceId); - } + candidatesWithSemanticEdges.add(rel.sourceId); } + // An incoming edge is semantic unless it is the structural File -> DEFINES. if (candidateIds.has(rel.targetId)) { - if (!isFileDefinesEdgeToCandidate(graph, rel, rel.targetId)) { + if (!isFileDefinesEdge(graph, rel)) { candidatesWithSemanticEdges.add(rel.targetId); } } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/communities.ts b/gitnexus/src/core/ingestion/pipeline-phases/communities.ts index 51986458a..a7d7a8d52 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/communities.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/communities.ts @@ -4,7 +4,7 @@ * Detects code communities via Leiden algorithm and creates * Community nodes + MEMBER_OF edges. * - * @deps mro + * @deps mro, pruneLocalSymbols * @reads graph (all nodes and relationships) * @writes graph (Community nodes, MEMBER_OF edges) */ @@ -22,7 +22,10 @@ export interface CommunitiesOutput { export const communitiesPhase: PipelinePhase = { name: 'communities', - deps: ['mro', 'structure'], + // `pruneLocalSymbols` is declared explicitly (not just transitively via `mro`) + // so community detection always reads the trimmed graph even if a future option + // drops `mro` from the phase list. + deps: ['mro', 'pruneLocalSymbols', 'structure'], async execute( ctx: PipelineContext, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts index c72309568..d04332a73 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts @@ -4,7 +4,7 @@ * Detects execution flows (processes) and creates Process nodes + * STEP_IN_PROCESS edges. Also links Route/Tool nodes to processes. * - * @deps communities, routes, tools + * @deps communities, routes, tools, pruneLocalSymbols * @reads graph (all nodes and relationships), communityResult, routeRegistry, toolDefs * @writes graph (Process nodes, STEP_IN_PROCESS edges, ENTRY_POINT_OF edges) */ @@ -27,8 +27,10 @@ export interface ProcessesOutput { export const processesPhase: PipelinePhase = { name: 'processes', // `structure` supplies `totalFiles` (progress counter) without the spurious - // structural data dependency on `parse`. - deps: ['communities', 'routes', 'tools', 'structure'], + // structural data dependency on `parse`. `pruneLocalSymbols` is declared + // explicitly so process extraction always reads the trimmed graph even if a + // future option drops the intervening `mro`/`communities` phases. + deps: ['communities', 'routes', 'tools', 'pruneLocalSymbols', 'structure'], async execute( ctx: PipelineContext, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/prune-local-symbols.ts b/gitnexus/src/core/ingestion/pipeline-phases/prune-local-symbols.ts index 6f07a6f4f..621f68bf3 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/prune-local-symbols.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/prune-local-symbols.ts @@ -21,7 +21,9 @@ export const pruneLocalSymbolsPhase: PipelinePhase = { deps: ['scopeResolution'], async execute(ctx: PipelineContext): Promise { - const stats = pruneLocalValueSymbols(ctx.graph); + const stats = pruneLocalValueSymbols(ctx.graph, { + keepLocalValueSymbols: ctx.options?.keepLocalValueSymbols, + }); if (isDev && !stats.skippedByEnv && stats.prunedNodes > 0) { logger.info(`Pruned ${stats.prunedNodes}/${stats.candidateNodes} inert local value symbols`); diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 3c18c0b13..611b7ac7a 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -42,7 +42,12 @@ import { } from './pipeline-phases/index.js'; export interface PipelineOptions { - /** Skip MRO, community detection, and process extraction for faster test runs. */ + /** + * Skip MRO, community detection, and process extraction for faster test runs. + * The `pruneLocalSymbols` phase still runs — it is graph construction (it cleans + * up inert local symbols), not graph analysis — so set `keepLocalValueSymbols` + * to retain those nodes under `skipGraphPhases`. + */ skipGraphPhases?: boolean; /** * Request parsing with the worker pool disabled. The sequential parser was @@ -115,6 +120,14 @@ export interface PipelineOptions { * without leaking `process.env` state across invocations. */ chunkByteBudget?: number; + /** + * Keep inert block-local value symbols (Const/Variable/Static) that the + * `pruneLocalSymbols` phase would otherwise drop. Mirrors the + * `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` env var, but threaded per-call so + * long-running hosts (eval-server, MCP daemon) can opt out without leaking + * `process.env` state across invocations. When undefined, the env var decides. + */ + keepLocalValueSymbols?: boolean; } // ── Phase registry ───────────────────────────────────────────────────────── diff --git a/gitnexus/src/core/ingestion/variable-extractors/generic.ts b/gitnexus/src/core/ingestion/variable-extractors/generic.ts index e3eefeae6..ae1c5091a 100644 --- a/gitnexus/src/core/ingestion/variable-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/variable-extractors/generic.ts @@ -18,6 +18,26 @@ import type { VariableScope, } from '../variable-types.js'; +/** + * Type-declaration node types whose body can be a bare `block`. tree-sitter-python + * models a class body as a `block` node — the same node type used for function and + * control-flow bodies — so a class attribute would otherwise look block-scoped. Most + * other grammars give class bodies dedicated node types (`class_body`, + * `declaration_list`, `body_statement`), which are not in the block-scope list below, + * so this guard is a no-op for them but keeps the rule language-agnostic. + */ +const CLASS_LIKE_CONTAINERS = new Set([ + 'class_definition', + 'class_declaration', + 'class_specifier', + 'struct_item', + 'impl_item', + 'trait_item', + 'interface_declaration', + 'enum_declaration', + 'object_declaration', +]); + /** * Create a VariableExtractor from a declarative config. */ @@ -48,7 +68,7 @@ export function createVariableExtractor(config: VariableExtractionConfig): Varia ) { return 'module'; } - // Function/method/block boundaries indicate block scope + // Function/method boundaries indicate block scope if ( t === 'function_declaration' || t === 'function_definition' || @@ -58,12 +78,18 @@ export function createVariableExtractor(config: VariableExtractionConfig): Varia t === 'arrow_function' || t === 'function_expression' || t === 'lambda' || - t === 'block' || t === 'function_body' || t === 'compound_statement' ) { return 'block'; } + // A bare `block` is block scope UNLESS it is a class body. A class member + // (e.g. Python `class C: MAX = 100`) is not an inert function-local — keep + // walking so it resolves to its true enclosing scope ('module' for a + // top-level class) instead of being misclassified and pruned. + if (t === 'block' && !(current.parent && CLASS_LIKE_CONTAINERS.has(current.parent.type))) { + return 'block'; + } current = current.parent; } return 'file'; diff --git a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json index 69d14b191..67bb7e888 100644 --- a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json +++ b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json @@ -2,13 +2,12 @@ "capture": "initial capture (U8, post-U1–U7)", "fixture": "mini-repo", "totalFileCount": 7, - "symbols": 37, - "relationships": 73, + "symbols": 33, + "relationships": 69, "processes": 4, "byType": { "Class": 1, "Community": 4, - "Const": 4, "File": 7, "Folder": 1, "Function": 12, @@ -19,11 +18,11 @@ "byRelType": { "CALLS": 9, "CONTAINS": 7, - "DEFINES": 20, + "DEFINES": 16, "HAS_METHOD": 1, "IMPORTS": 12, "MEMBER_OF": 12, "STEP_IN_PROCESS": 12 }, - "edgeDigest": "196198eb9a900721aec892400d141189aa1e874722917843c7f1206805d817f1" + "edgeDigest": "1e80aba78cb1784276d387e9debd006ae4e82e60ce33c59e338aac4886d98f61" } diff --git a/gitnexus/test/integration/local-symbol-pruner-pipeline.test.ts b/gitnexus/test/integration/local-symbol-pruner-pipeline.test.ts index d660b9c0f..454904660 100644 --- a/gitnexus/test/integration/local-symbol-pruner-pipeline.test.ts +++ b/gitnexus/test/integration/local-symbol-pruner-pipeline.test.ts @@ -9,10 +9,10 @@ const describeIfWorkerBuilt = distWorkerExists() ? describe : describe.skip; let tmpDirs: string[] = []; -const makeRepo = (source: string): string => { +const makeRepo = (source: string, filename = 'sample.ts'): string => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-local-prune-')); tmpDirs.push(dir); - fs.writeFileSync(path.join(dir, 'sample.ts'), source, 'utf-8'); + fs.writeFileSync(path.join(dir, filename), source, 'utf-8'); return dir; }; @@ -68,4 +68,40 @@ class Client { }); expect(keepsResolvedClientCall).toBe(true); }); + + it('keeps Python class-level constants while pruning function-locals', async () => { + // Regression: tree-sitter-python models the class body as a `block` node, so + // `determineScope` classified an untyped class attribute as block-scope. Python + // emits such assignments as `Variable` (the `@definition.variable` capture), and + // value labels get no owner edge (needsOwner excludes them) — only File->DEFINES. + // The prune pass would therefore silently delete unreferenced class attributes. + // A class-level symbol is NOT a function-local and must survive. + const repo = makeRepo( + `MODULE_CONST = 1 + + +class Settings: + MAX_SIZE = 100 + + +def run(): + boring = 1 + return boring +`, + 'sample.py', + ); + + const result = await runPipelineFromRepo(repo, () => {}, { + skipGraphPhases: true, + workerPoolSize: 1, + workerUrlForTest: DIST_WORKER_URL, + }); + + // Unreferenced class-level attribute must survive the prune. + expect(findNode(result, 'Variable', 'MAX_SIZE')).toBeDefined(); + // Module-level symbol is preserved as before. + expect(findNode(result, 'Variable', 'MODULE_CONST')).toBeDefined(); + // Genuine function-local is still pruned. + expect(findNode(result, 'Variable', 'boring')).toBeUndefined(); + }); }); diff --git a/gitnexus/test/unit/local-symbol-pruner.test.ts b/gitnexus/test/unit/local-symbol-pruner.test.ts index d1d6e5f88..7c4ac3702 100644 --- a/gitnexus/test/unit/local-symbol-pruner.test.ts +++ b/gitnexus/test/unit/local-symbol-pruner.test.ts @@ -106,6 +106,36 @@ describe('pruneLocalValueSymbols', () => { expect(graph.relationshipCount).toBe(2); }); + it('keeps block-scope value symbols that are the source of an outgoing edge', () => { + const graph = createKnowledgeGraph(); + graph.addNode(fileNode()); + graph.addNode(node('Element:thing', 'CodeElement')); + graph.addNode(node('Const:config', 'Const', { scope: 'block' })); + graph.addRelationship(rel('rel:file-def', 'file:src/app.ts', 'Const:config')); + // Candidate is the SOURCE of an outgoing DEFINES edge — any outgoing edge is + // semantic, so the node must be kept (guards the source-branch simplification). + graph.addRelationship(rel('rel:out', 'Const:config', 'Element:thing', 'DEFINES')); + + const stats = pruneLocalValueSymbols(graph); + + expect(stats.prunedNodes).toBe(0); + expect(stats.keptWithSemanticEdges).toBe(1); + expect(graph.getNode('Const:config')).toBeDefined(); + }); + + it('does not treat value symbols without a scope property as candidates', () => { + const graph = createKnowledgeGraph(); + graph.addNode(fileNode()); + graph.addNode(node('Const:noScope', 'Const')); + graph.addRelationship(rel('rel:def', 'file:src/app.ts', 'Const:noScope')); + + const stats = pruneLocalValueSymbols(graph); + + expect(stats.candidateNodes).toBe(0); + expect(stats.prunedNodes).toBe(0); + expect(graph.getNode('Const:noScope')).toBeDefined(); + }); + it('prunes block-scope value symbols even when parser metadata marks them exported', () => { const graph = createKnowledgeGraph(); graph.addNode(fileNode()); @@ -184,4 +214,26 @@ describe('pruneLocalSymbolsPhase', () => { expect(stats.prunedNodes).toBe(1); expect(graph.getNode('Const:tmp')).toBeUndefined(); }); + + it('honors the keepLocalValueSymbols option without reading process.env', async () => { + const graph = createKnowledgeGraph(); + graph.addNode(fileNode()); + graph.addNode(node('Const:tmp', 'Const', { scope: 'block' })); + graph.addRelationship(rel('rel:def', 'file:src/app.ts', 'Const:tmp')); + + const stats = await pruneLocalSymbolsPhase.execute( + { + repoPath: '/repo', + graph, + onProgress: () => {}, + pipelineStart: Date.now(), + options: { keepLocalValueSymbols: true }, + }, + new Map(), + ); + + expect(stats.skippedByEnv).toBe(true); + expect(stats.prunedNodes).toBe(0); + expect(graph.getNode('Const:tmp')).toBeDefined(); + }); }); diff --git a/gitnexus/test/unit/variable-extraction.test.ts b/gitnexus/test/unit/variable-extraction.test.ts index 62a5eab05..e7a8be1ce 100644 --- a/gitnexus/test/unit/variable-extraction.test.ts +++ b/gitnexus/test/unit/variable-extraction.test.ts @@ -686,6 +686,29 @@ describe('VariableExtractor — block-scoped declarations', () => { expect(info!.scope).toBe('block'); }); + it('Python: class-body attribute is not block-scoped (class body is a `block` node)', () => { + // Regression: tree-sitter-python models the class body as a `block` node, the + // same node type as a function body. A class attribute must NOT be classified + // as a function-local block, or the pruner would silently drop it. + const extractor = createVariableExtractor(pythonVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.py', + language: SupportedLanguages.Python, + }; + parser.setLanguage(Python); + const tree = parser.parse('class Settings:\n MAX_SIZE = 100'); + // module > class_definition > block > expression_statement > assignment + const classDef = tree.rootNode.namedChildren.find((c) => c.type === 'class_definition')!; + const body = classDef.childForFieldName('body')!; + const exprStmt = body.namedChildren.find((c) => c.type === 'expression_statement'); + expect(exprStmt).toBeDefined(); + const info = extractor.extract(exprStmt!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MAX_SIZE'); + expect(info!.scope).not.toBe('block'); + expect(info!.scope).toBe('module'); + }); + it('Python: rejects non-assignment expression statements (e.g. function calls)', () => { const extractor = createVariableExtractor(pythonVariableConfig); const ctx: VariableExtractorContext = {