From 56d6a463f0398c82c5709fb089c02e1b9d3dbc30 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 20 Apr 2026 12:20:35 +0100 Subject: [PATCH] feat(python-scope): write ACCESSES edges for attribute assignments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that together produce ACCESSES (write) edges for \`obj.field = value\` assignments: - New \`@reference.write.member\` capture in query.ts and scopes.scm matching \`(assignment left: (attribute object: ... attribute: ...))\`. Reuses the existing receiver/name capture shape so the receiver-bound emit pass can resolve obj's class and look up the field. - \`populateMethodOwnerIds\` now sets ownerId on class-body fields too, not only on methods. Previously it only walked Function scopes whose parent was Class; class-body annotations like \`name: str\` live directly in the Class scope's ownedDefs and were missed, so \`findOwnedMember(User, "name")\` returned undefined. - \`emit-core isLinkableLabel\` extends to Variable and Property so field nodes appear in the graph-node lookup (the legacy parser emits both kinds for class-body annotations). - Case 4 in receiver-bound pass now uses the kind word as the edge reason for read/write sites — matches the legacy DAG convention the test asserts on. Verification: - Flag-off: 191/191 (identical baseline). - Flag-on: 3 fail / 188 pass (was 4/187; +1 — write-ACCESSES test). - tsc --noEmit clean. --- .../ingestion/emit-core/graph-node-lookup.ts | 7 ++- .../core/ingestion/languages/python/query.ts | 9 ++++ .../ingestion/languages/python/scopes.scm | 7 +++ .../src/core/ingestion/python-scope-emit.ts | 50 ++++++++++++------- 4 files changed, 54 insertions(+), 19 deletions(-) diff --git a/gitnexus/src/core/ingestion/emit-core/graph-node-lookup.ts b/gitnexus/src/core/ingestion/emit-core/graph-node-lookup.ts index cddeaec37..f84733a5b 100644 --- a/gitnexus/src/core/ingestion/emit-core/graph-node-lookup.ts +++ b/gitnexus/src/core/ingestion/emit-core/graph-node-lookup.ts @@ -44,6 +44,11 @@ export function isLinkableLabel(label: NodeLabel): boolean { label === 'Class' || label === 'Interface' || label === 'Struct' || - label === 'Enum' + label === 'Enum' || + // Variable / Property are linkable too — receiver-bound write/read + // ACCESSES edges target field nodes (e.g. `user.name = "x"` → + // ACCESSES edge to User's `name` Variable/Property node). + label === 'Variable' || + label === 'Property' ); } diff --git a/gitnexus/src/core/ingestion/languages/python/query.ts b/gitnexus/src/core/ingestion/languages/python/query.ts index bd4f73a30..92fd53717 100644 --- a/gitnexus/src/core/ingestion/languages/python/query.ts +++ b/gitnexus/src/core/ingestion/languages/python/query.ts @@ -235,6 +235,15 @@ export const PYTHON_SCOPE_QUERY = ` function: (attribute object: (_) @reference.receiver attribute: (identifier) @reference.name)) @reference.call.member + +;; References — attribute writes: \`obj.name = "x"\` emits a write +;; ACCESSES edge from the enclosing function to the field on obj's +;; class. The receiver-bound emit pass resolves obj → its class and +;; \`name\` → the field def via the existing typeref-receiver path. +(assignment + left: (attribute + object: (_) @reference.receiver + attribute: (identifier) @reference.name)) @reference.write.member `; let _parser: Parser | null = null; diff --git a/gitnexus/src/core/ingestion/languages/python/scopes.scm b/gitnexus/src/core/ingestion/languages/python/scopes.scm index c57ccb67f..3c3fbf78d 100644 --- a/gitnexus/src/core/ingestion/languages/python/scopes.scm +++ b/gitnexus/src/core/ingestion/languages/python/scopes.scm @@ -246,3 +246,10 @@ function: (attribute object: (_) @reference.receiver attribute: (identifier) @reference.name)) @reference.call.member + +; Attribute write: `obj.name = "x"` — emits ACCESSES (write) edge from +; the enclosing function to the field on obj's class. +(assignment + left: (attribute + object: (_) @reference.receiver + attribute: (identifier) @reference.name)) @reference.write.member diff --git a/gitnexus/src/core/ingestion/python-scope-emit.ts b/gitnexus/src/core/ingestion/python-scope-emit.ts index bb502b42c..e739bc997 100644 --- a/gitnexus/src/core/ingestion/python-scope-emit.ts +++ b/gitnexus/src/core/ingestion/python-scope-emit.ts @@ -533,15 +533,14 @@ function emitReceiverBoundCalls( if (memberDef !== undefined) break; } if (memberDef !== undefined) { - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - 'python-scope: typeref-receiver', - seen, - ); + // For read/write ACCESSES, mirror the legacy DAG's reason + // convention (just the kind word) so consumers asserting + // `reason === 'write'` keep working. + const reason = + site.kind === 'write' || site.kind === 'read' + ? site.kind + : 'python-scope: typeref-receiver'; + const ok = tryEmitEdge(graph, scopes, nodeLookup, site, memberDef, reason, seen); if (ok) { emitted++; handledSites.add(siteKey); @@ -958,15 +957,30 @@ function populateMethodOwnerIds(parsed: ParsedFile): void { for (const scope of parsed.scopes) scopesById.set(scope.id, scope); for (const scope of parsed.scopes) { - if (scope.parent === null) continue; - const parentScope = scopesById.get(scope.parent); - if (parentScope === undefined || parentScope.kind !== 'Class') continue; - - const classDef = parentScope.ownedDefs.find((d) => d.type === 'Class'); - if (classDef === undefined) continue; - - for (const def of scope.ownedDefs) { - (def as { ownerId?: string }).ownerId = classDef.nodeId; + // Methods (Function scopes whose PARENT is Class): set ownerId + // on every def in the function's own scope. + if (scope.parent !== null) { + const parentScope = scopesById.get(scope.parent); + if (parentScope !== undefined && parentScope.kind === 'Class') { + const classDef = parentScope.ownedDefs.find((d) => d.type === 'Class'); + if (classDef !== undefined) { + for (const def of scope.ownedDefs) { + (def as { ownerId?: string }).ownerId = classDef.nodeId; + } + } + } + } + // Class-body fields (defs structurally owned by the Class scope + // itself — class-body annotations like `name: str`): set ownerId + // on every def except the class itself. + if (scope.kind === 'Class') { + const classDef = scope.ownedDefs.find((d) => d.type === 'Class'); + if (classDef !== undefined) { + for (const def of scope.ownedDefs) { + if (def === classDef) continue; + (def as { ownerId?: string }).ownerId = classDef.nodeId; + } + } } } }