refactor: split HAS_METHOD into HAS_METHOD + HAS_PROPERTY edge types

Property nodes now use HAS_PROPERTY edges instead of HAS_METHOD, giving
the graph schema proper semantic separation between methods and fields.

- HAS_METHOD: Method, Constructor, Function (when inside a class)
- HAS_PROPERTY: Property nodes (class fields, struct fields, attributes)

MRO processor only reads HAS_METHOD — properties correctly excluded from
method resolution order. Impact analysis accepts both edge types.

Updated 12 files: graph types, schema, tools docs, parse-worker,
parsing-processor, call-processor, and 6 test files.
This commit is contained in:
Gergo Magyar 2026-03-18 10:03:44 +00:00
parent 9681fa20da
commit 523178cefe
12 changed files with 61 additions and 56 deletions

View file

@ -80,6 +80,7 @@ export type RelationshipType =
| 'IMPLEMENTS'
| 'EXTENDS'
| 'HAS_METHOD'
| 'HAS_PROPERTY'
| 'MEMBER_OF'
| 'STEP_IN_PROCESS'

View file

@ -234,9 +234,9 @@ export const processCalls = async (
});
if (propEnclosingClassId) {
graph.addRelationship({
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId, targetId: nodeId,
type: 'HAS_METHOD', confidence: 1.0, reason: '',
type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
});
}
}

View file

@ -329,13 +329,14 @@ const processParsingSequential = async (
graph.addRelationship(relationship);
// ── HAS_METHOD: link method/constructor/property to enclosing class ──
// ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ──
if (enclosingClassId) {
const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD';
graph.addRelationship({
id: generateId('HAS_METHOD', `${enclosingClassId}->${nodeId}`),
id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`),
sourceId: enclosingClassId,
targetId: nodeId,
type: 'HAS_METHOD',
type: memberEdgeType,
confidence: 1.0,
reason: '',
});

View file

@ -135,7 +135,7 @@ interface ParsedRelationship {
id: string;
sourceId: string;
targetId: string;
type: 'DEFINES' | 'HAS_METHOD';
type: 'DEFINES' | 'HAS_METHOD' | 'HAS_PROPERTY';
confidence: number;
reason: string;
}
@ -1045,10 +1045,10 @@ const processFileGroup = (
});
if (propEnclosingClassId) {
result.relationships.push({
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId,
targetId: nodeId,
type: 'HAS_METHOD',
type: 'HAS_PROPERTY',
confidence: 1.0,
reason: '',
});
@ -1286,13 +1286,14 @@ const processFileGroup = (
reason: '',
});
// ── HAS_METHOD: link method/constructor/property to enclosing class ──
// ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ──
if (enclosingClassId) {
const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD';
result.relationships.push({
id: generateId('HAS_METHOD', `${enclosingClassId}->${nodeId}`),
id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`),
sourceId: enclosingClassId,
targetId: nodeId,
type: 'HAS_METHOD',
type: memberEdgeType,
confidence: 1.0,
reason: '',
});

View file

@ -26,7 +26,7 @@ export type NodeTableName = typeof NODE_TABLES[number];
export const REL_TABLE_NAME = 'CodeRelation';
// Valid relation types
export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const;
export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const;
export type RelType = typeof REL_TYPES[number];
// ============================================================================

View file

@ -47,7 +47,7 @@ export const VALID_NODE_LABELS = new Set([
]);
/** Valid relation types for impact analysis filtering */
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES']);
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES']);
/** Regex to detect write operations in user-supplied Cypher queries */
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;

View file

@ -78,7 +78,7 @@ SCHEMA:
- Nodes: File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process
- Multi-language nodes (use backticks): \`Struct\`, \`Enum\`, \`Trait\`, \`Impl\`, etc.
- All edges via single CodeRelation table with 'type' property
- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES, MEMBER_OF, STEP_IN_PROCESS
- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES, MEMBER_OF, STEP_IN_PROCESS
- Edge properties: type (STRING), confidence (DOUBLE), reason (STRING), step (INT32)
EXAMPLES:
@ -94,6 +94,9 @@ EXAMPLES:
• Find all methods of a class:
MATCH (c:Class {name: "UserService"})-[r:CodeRelation {type: 'HAS_METHOD'}]->(m:Method) RETURN m.name, m.parameterCount, m.returnType
• Find all properties of a class:
MATCH (c:Class {name: "User"})-[r:CodeRelation {type: 'HAS_PROPERTY'}]->(p:Property) RETURN p.name, p.declaredType
• Find method overrides (MRO resolution):
MATCH (winner:Method)-[r:CodeRelation {type: 'OVERRIDES'}]->(loser:Method) RETURN winner.name, winner.filePath, loser.filePath, r.reason
@ -200,7 +203,7 @@ Depth groups:
- d=2: LIKELY AFFECTED (indirect)
- d=3: MAY NEED TESTING (transitive)
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES
Confidence: 1.0 = certain, <0.8 = fuzzy match`,
inputSchema: {
type: 'object',
@ -208,7 +211,7 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match`,
target: { type: 'string', description: 'Name of function, class, or file to analyze' },
direction: { type: 'string', description: 'upstream (what depends on this) or downstream (what this depends on)' },
maxDepth: { type: 'number', description: 'Max relationship depth (default: 3)', default: 3 },
relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES (default: usage-based)' },
relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES (default: usage-based)' },
includeTests: { type: 'boolean', description: 'Include test files (default: false)' },
minConfidence: { type: 'number', description: 'Minimum confidence 0-1 (default: 0.7)' },
repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' },

View file

@ -4,7 +4,7 @@
*
* Per-language fixtures test:
* 1. Property nodes are extracted with correct ownerId linkage
* 2. HAS_METHOD edges link properties to their owning classes
* 2. HAS_PROPERTY edges link properties to their owning classes
* 3. Field-access chain resolution resolves user.address.save() → Address#save
*/
import { describe, it, expect, beforeAll } from 'vitest';
@ -39,9 +39,8 @@ describe('Field type resolution (TypeScript)', () => {
expect(properties).toContain('city');
});
it('emits HAS_METHOD edges linking properties to classes', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('User → name');
@ -82,9 +81,8 @@ describe('Field type resolution (Java)', () => {
expect(properties).toContain('city');
});
it('emits HAS_METHOD edges linking properties to classes', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('User → name');
@ -127,9 +125,8 @@ describe('Field type resolution (C#)', () => {
expect(properties).toContain('City');
});
it('emits HAS_METHOD edges linking properties to classes', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
expect(edgeSet(propEdges)).toContain('User → Address');
expect(edgeSet(propEdges)).toContain('User → Name');
@ -171,9 +168,8 @@ describe('Field type resolution (Go)', () => {
expect(properties).toContain('City');
});
it('emits HAS_METHOD edges linking struct fields to structs', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
it('emits HAS_PROPERTY edges linking struct fields to structs', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(2);
});
@ -213,9 +209,8 @@ describe('Field type resolution (Kotlin)', () => {
expect(properties).toContain('city');
});
it('emits HAS_METHOD edges linking properties to classes', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('User → name');
@ -258,9 +253,8 @@ describe('Field type resolution (PHP)', () => {
expect(properties).toContain('city');
});
it('emits HAS_METHOD edges linking properties to classes', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
it('emits HAS_PROPERTY edges linking properties to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
});

View file

@ -124,15 +124,15 @@ describe('Ruby require_relative, heritage & property resolution', () => {
expect(props).toContain('email');
});
it('emits HAS_METHOD from User to attr_reader :name', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const edge = hasMethod.find(e => e.source === 'User' && e.target === 'name');
it('emits HAS_PROPERTY from User to attr_reader :name', () => {
const hasProperty = getRelationships(result, 'HAS_PROPERTY');
const edge = hasProperty.find(e => e.source === 'User' && e.target === 'name');
expect(edge).toBeDefined();
});
it('emits HAS_METHOD from BaseModel to attr_accessor :id', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const edge = hasMethod.find(e => e.source === 'BaseModel' && e.target === 'id');
it('emits HAS_PROPERTY from BaseModel to attr_accessor :id', () => {
const hasProperty = getRelationships(result, 'HAS_PROPERTY');
const edge = hasProperty.find(e => e.source === 'BaseModel' && e.target === 'id');
expect(edge).toBeDefined();
});

View file

@ -53,18 +53,23 @@ describe('TypeScript heritage resolution', () => {
]);
});
it('emits HAS_METHOD edges linking methods and properties to classes', () => {
it('emits HAS_METHOD edges linking methods to classes', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
expect(hasMethod.length).toBe(5);
expect(hasMethod.length).toBe(4);
expect(edgeSet(hasMethod)).toEqual([
'BaseService → getName',
'BaseService → name',
'ConsoleLogger → log',
'UserService → getUsers',
'UserService → log',
]);
});
it('emits HAS_PROPERTY edge for class fields', () => {
const hasProperty = getRelationships(result, 'HAS_PROPERTY');
expect(hasProperty.length).toBe(1);
expect(edgeSet(hasProperty)).toEqual(['BaseService → name']);
});
it('no OVERRIDES edges target Property nodes', () => {
const overrides = getRelationships(result, 'OVERRIDES');
for (const edge of overrides) {

View file

@ -292,19 +292,19 @@ describe('computeMRO', () => {
addExtends(graph, 'Child', 'ParentA');
addExtends(graph, 'Child', 'ParentB');
// Add Property nodes (same name 'name') to both parents via HAS_METHOD
// Add Property nodes (same name 'name') to both parents via HAS_PROPERTY
const propA = generateId('Property', 'ParentA.name');
graph.addNode({ id: propA, label: 'Property', properties: { name: 'name', filePath: 'src/ParentA.ts' } });
graph.addRelationship({
id: generateId('HAS_METHOD', `${parentA}->${propA}`),
sourceId: parentA, targetId: propA, type: 'HAS_METHOD', confidence: 1.0, reason: '',
id: generateId('HAS_PROPERTY', `${parentA}->${propA}`),
sourceId: parentA, targetId: propA, type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
});
const propB = generateId('Property', 'ParentB.name');
graph.addNode({ id: propB, label: 'Property', properties: { name: 'name', filePath: 'src/ParentB.ts' } });
graph.addRelationship({
id: generateId('HAS_METHOD', `${parentB}->${propB}`),
sourceId: parentB, targetId: propB, type: 'HAS_METHOD', confidence: 1.0, reason: '',
id: generateId('HAS_PROPERTY', `${parentB}->${propB}`),
sourceId: parentB, targetId: propB, type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
});
const result = computeMRO(graph);
@ -328,19 +328,19 @@ describe('computeMRO', () => {
const methodA = addMethod(graph, 'PA', 'doWork');
addMethod(graph, 'PB', 'doWork');
// Property collision (should NOT trigger OVERRIDES)
// Property collision (should NOT trigger OVERRIDES — properties use HAS_PROPERTY, not HAS_METHOD)
const propA = generateId('Property', 'PA.id');
graph.addNode({ id: propA, label: 'Property', properties: { name: 'id', filePath: 'src/PA.ts' } });
graph.addRelationship({
id: generateId('HAS_METHOD', `${parentA}->${propA}`),
sourceId: parentA, targetId: propA, type: 'HAS_METHOD', confidence: 1.0, reason: '',
id: generateId('HAS_PROPERTY', `${parentA}->${propA}`),
sourceId: parentA, targetId: propA, type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
});
const propB = generateId('Property', 'PB.id');
graph.addNode({ id: propB, label: 'Property', properties: { name: 'id', filePath: 'src/PB.ts' } });
graph.addRelationship({
id: generateId('HAS_METHOD', `${parentB}->${propB}`),
sourceId: parentB, targetId: propB, type: 'HAS_METHOD', confidence: 1.0, reason: '',
id: generateId('HAS_PROPERTY', `${parentB}->${propB}`),
sourceId: parentB, targetId: propB, type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
});
const result = computeMRO(graph);

View file

@ -122,11 +122,11 @@ describe('LadybugDB Schema', () => {
it('has all FROM/TO pairs needed for HAS_METHOD edges', () => {
// HAS_METHOD sources: Class, Interface, Struct, Trait, Impl, Record
// HAS_METHOD targets: Method, Constructor, Property
// HAS_METHOD targets: Method, Constructor (Property is now HAS_PROPERTY)
const sources = ['Class', 'Interface'];
const backtickSources = ['Struct', 'Trait', 'Impl', 'Record'];
const targets = ['Method'];
const backtickTargets = ['Constructor', 'Property'];
const backtickTargets = ['Constructor'];
// Non-backtick source → non-backtick target
for (const src of sources) {