feat(python-scope): write ACCESSES edges for attribute assignments

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.
This commit is contained in:
Gergo Magyar 2026-04-20 12:20:35 +01:00
parent b059dce252
commit 56d6a463f0
4 changed files with 54 additions and 19 deletions

View file

@ -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'
);
}

View file

@ -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;

View file

@ -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

View file

@ -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;
}
}
}
}
}