diff --git a/__tests__/php-phpdoc.test.ts b/__tests__/php-phpdoc.test.ts new file mode 100644 index 000000000..a9405b432 --- /dev/null +++ b/__tests__/php-phpdoc.test.ts @@ -0,0 +1,938 @@ +/** + * Tests for PHP @property PHPDoc synthesis. + * + * Unit tests cover phpPhpdocResolver.detect(), extract(), resolve(), and + * claimsReference(). End-to-end tests use a real CodeGraph instance with + * temporary PHP fixture projects to verify: + * - @property → references (heuristic) edges from the synthesizer + * - PHP interface override → calls (heuristic) edges from IFACE_OVERRIDE_LANGS + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; +import { phpPhpdocResolver } from '../src/resolution/frameworks/php-phpdoc'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; +import type { Node } from '../src/types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeContext( + overrides: Partial = {}, +): ResolutionContext { + return { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/project', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + ...overrides, + }; +} + +function makeRef(name: string, overrides: Partial = {}): UnresolvedRef { + return { + fromNodeId: 'class:abc123', + referenceName: name, + referenceKind: 'references', + line: 1, + column: 0, + filePath: 'test.php', + language: 'php', + ...overrides, + }; +} + +function makeNode(name: string, kind: Node['kind'] = 'class', language = 'php'): Node { + return { + id: `${kind}:${name.toLowerCase()}`, + kind, + name, + qualifiedName: `test.php::${name}`, + filePath: 'test.php', + language: language as any, + startLine: 1, + endLine: 10, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; +} + +// --------------------------------------------------------------------------- +// phpPhpdocResolver.detect() +// --------------------------------------------------------------------------- + +describe('phpPhpdocResolver.detect', () => { + it('returns true when a PHP file contains @property', () => { + const ctx = makeContext({ + getAllFiles: () => ['app/models/ctx.php'], + readFile: (f) => + f === 'app/models/ctx.php' + ? ' { + const ctx = makeContext({ + getAllFiles: () => ['app/models/user.php'], + readFile: (f) => + f === 'app/models/user.php' + ? ' { + const ctx = makeContext({ + getAllFiles: () => ['README.md'], + readFile: () => '## @property annotations are documented here', + }); + expect(phpPhpdocResolver.detect(ctx)).toBe(false); + }); + + it('returns false on an empty project', () => { + const ctx = makeContext({ getAllFiles: () => [] }); + expect(phpPhpdocResolver.detect(ctx)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// phpPhpdocResolver.claimsReference() +// --------------------------------------------------------------------------- + +describe('phpPhpdocResolver.claimsReference', () => { + it('claims phpdoc-property: prefixed names', () => { + expect(phpPhpdocResolver.claimsReference!('phpdoc-property:User_Factory')).toBe(true); + }); + + it('does not claim non-prefixed names', () => { + expect(phpPhpdocResolver.claimsReference!('User_Factory')).toBe(false); + expect(phpPhpdocResolver.claimsReference!('findByUid')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// phpPhpdocResolver.extract() +// --------------------------------------------------------------------------- + +describe('phpPhpdocResolver.extract', () => { + it('extracts @property references from a class docblock', () => { + const content = ` { + const content = ` { + const content = ` { + const content = ` { + const content = ` { + const content = ` { + const content = ` { + const { nodes, references } = phpPhpdocResolver.extract!('readme.md', '# @property docs'); + expect(nodes).toEqual([]); + expect(references).toEqual([]); + }); + + it('returns empty for PHP files without @property', () => { + const { references } = phpPhpdocResolver.extract!( + 'plain.php', + ' { + const content = ` { + const content = ` r.referenceName); + expect(names).toContain('phpdoc-property:TypeA'); + expect(names).toContain('phpdoc-property:TypeB'); + expect(names).toContain('phpdoc-property:TypeC'); + }); +}); + +// --------------------------------------------------------------------------- +// phpPhpdocResolver.resolve() +// --------------------------------------------------------------------------- + +describe('phpPhpdocResolver.resolve', () => { + it('resolves phpdoc-property:TypeName to a PHP class node', () => { + const target = makeNode('User_Factory', 'class'); + const ctx = makeContext({ + getNodesByName: (name) => (name === 'User_Factory' ? [target] : []), + }); + const ref = makeRef('phpdoc-property:User_Factory'); + const result = phpPhpdocResolver.resolve(ref, ctx); + expect(result).not.toBeNull(); + expect(result!.targetNodeId).toBe(target.id); + expect(result!.confidence).toBe(0.85); + expect(result!.resolvedBy).toBe('framework'); + }); + + it('resolves to interface or trait nodes', () => { + const iface = makeNode('Cacheable', 'interface'); + const ctx = makeContext({ + getNodesByName: (name) => (name === 'Cacheable' ? [iface] : []), + }); + const result = phpPhpdocResolver.resolve(makeRef('phpdoc-property:Cacheable'), ctx); + expect(result).not.toBeNull(); + expect(result!.targetNodeId).toBe(iface.id); + }); + + it('returns null for non-phpdoc-property references', () => { + const ctx = makeContext(); + expect(phpPhpdocResolver.resolve(makeRef('findByUid'), ctx)).toBeNull(); + expect(phpPhpdocResolver.resolve(makeRef('User_Factory'), ctx)).toBeNull(); + }); + + it('returns null when target type is not found', () => { + const ctx = makeContext({ getNodesByName: () => [] }); + expect(phpPhpdocResolver.resolve(makeRef('phpdoc-property:Unknown_Type'), ctx)).toBeNull(); + }); + + it('ignores non-PHP nodes with the same name', () => { + const jsNode = makeNode('Logger', 'class', 'javascript'); + const ctx = makeContext({ + getNodesByName: () => [jsNode], + }); + expect(phpPhpdocResolver.resolve(makeRef('phpdoc-property:Logger'), ctx)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end: phpPhpdocPropertyEdges synthesizer +// --------------------------------------------------------------------------- + +describe('PHP @property PHPDoc synthesizer (end-to-end)', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'php-phpdoc-fixture-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('synthesizes references edges from @property annotations to target classes', async () => { + fs.writeFileSync( + path.join(dir, 'ctx.php'), + `services[$name]; + } +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'user_factory.php'), + ` r.target_name)); + expect(targetNames).toContain('User_Factory'); + expect(targetNames).toContain('Order_Service'); + + for (const row of rows as any[]) { + expect(row.source_name).toBe('Ctx'); + expect(row.source_kind).toBe('class'); + expect(row.edge_kind).toBe('references'); + expect(row.provenance).toBe('heuristic'); + expect(row.via).toMatch(/@property/); + } + }); + + it('skips primitive types and handles @property-read', async () => { + fs.writeFileSync( + path.join(dir, 'config.php'), + ` { + fs.writeFileSync( + path.join(dir, 'orphan.php'), + ` { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'php-phpdoc-calls-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('synthesizes calls edges when a method calls ->propName->method()', async () => { + fs.writeFileSync( + path.join(dir, 'ctx.php'), + `services[$name]; + } +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'user_factory.php'), + `ctx->user_factory->findByUid($uid); + return $user; + } + public function listOrders($uid) { + return $this->ctx->order_service->getOrders($uid); + } +} +`, + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + + const db = (cg as any).db.db; + const callsRows = db + .prepare( + `SELECT s.name source_name, s.kind source_kind, t.name target_name, t.kind target_kind, + e.kind edge_kind, e.provenance, + json_extract(e.metadata,'$.synthesizedBy') synthesizedBy, + json_extract(e.metadata,'$.via') via + FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'php-phpdoc-property' + AND e.kind = 'calls'`, + ) + .all(); + cg.close?.(); + + expect(callsRows.length).toBeGreaterThanOrEqual(2); + + const callPairs = callsRows.map((r: any) => `${r.source_name}->${r.target_name}`); + expect(callPairs).toContain('show->findByUid'); + expect(callPairs).toContain('listOrders->getOrders'); + + for (const row of callsRows as any[]) { + expect(row.source_kind).toBe('method'); + expect(row.target_kind).toBe('method'); + expect(row.edge_kind).toBe('calls'); + expect(row.provenance).toBe('heuristic'); + expect(row.via).toMatch(/@property/); + } + }); + + it('synthesizes calls edges for chained property access ($this->ctx->factory->method())', async () => { + fs.writeFileSync( + path.join(dir, 'pay.php'), + `ctx->pay->firstcharge->showFirstCharge($uid); + } +} +`, + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + + const db = (cg as any).db.db; + const rows = db + .prepare( + `SELECT s.name source_name, t.name target_name, e.kind edge_kind, + json_extract(e.metadata,'$.synthesizedBy') synthesizedBy + FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'php-phpdoc-property' + AND e.kind = 'calls'`, + ) + .all(); + cg.close?.(); + + expect(rows.length).toBeGreaterThanOrEqual(1); + const match = (rows as any[]).find( + (r) => r.source_name === 'charge' && r.target_name === 'showFirstCharge', + ); + expect(match).toBeTruthy(); + expect(match.edge_kind).toBe('calls'); + }); + + it('synthesizes calls edges for variable-dereference pattern ($var = ...->propName; $var->method())', async () => { + fs.writeFileSync( + path.join(dir, 'ctx.php'), + `ctx->user_factory; + $user = $factory->findByUid($uid); + return $user; + } +} +`, + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + + const db = (cg as any).db.db; + const rows = db + .prepare( + `SELECT s.name source_name, t.name target_name + FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'php-phpdoc-property' + AND e.kind = 'calls'`, + ) + .all(); + cg.close?.(); + + const match = (rows as any[]).find( + (r) => r.source_name === 'show' && r.target_name === 'findByUid', + ); + expect(match).toBeTruthy(); + }); + + it('does not match @property calls inside comments or strings', async () => { + fs.writeFileSync( + path.join(dir, 'ctx.php'), + `user_factory->findByUid() is deprecated + $note = "use \\$this->user_factory->findByUid() instead"; + return null; + } +} +`, + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + + const db = (cg as any).db.db; + const rows = db + .prepare( + `SELECT COUNT(*) cnt FROM edges e + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'php-phpdoc-property' + AND e.kind = 'calls'`, + ) + .all(); + cg.close?.(); + + expect((rows[0] as any).cnt).toBe(0); + }); + + it('does not create calls edges when method name does not exist on target class', async () => { + fs.writeFileSync( + path.join(dir, 'ctx.php'), + `cache->nonExistentMethod(); + } +} +`, + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + + const db = (cg as any).db.db; + const rows = db + .prepare( + `SELECT COUNT(*) cnt FROM edges e + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'php-phpdoc-property' + AND e.kind = 'calls'`, + ) + .all(); + cg.close?.(); + + expect((rows[0] as any).cnt).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end: PHP interface override bridging (IFACE_OVERRIDE_LANGS) +// --------------------------------------------------------------------------- + +describe('PHP interface override bridging (end-to-end)', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'php-iface-fixture-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('synthesizes calls edges from interface methods to implementing class methods', async () => { + fs.writeFileSync( + path.join(dir, 'user_repo_interface.php'), + ` [r.via, r])); + expect(bridged.has('findByUid')).toBe(true); + expect(bridged.has('save')).toBe(true); + + for (const row of rows as any[]) { + expect(row.source_kind).toBe('method'); + expect(row.target_kind).toBe('method'); + expect(row.provenance).toBe('heuristic'); + } + }); + + it('bridges abstract class methods to concrete subclass methods', async () => { + fs.writeFileSync( + path.join(dir, 'base_service.php'), + `send($params); + } + public function validate($params) { + return parent::validate($params); + } + private function send($params) {} +} +`, + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + + const db = (cg as any).db.db; + const rows = db + .prepare( + `SELECT s.name source_name, t.name target_name, + json_extract(e.metadata,'$.synthesizedBy') synthesizedBy, + json_extract(e.metadata,'$.via') via + FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'interface-impl' + AND s.language = 'php'`, + ) + .all(); + cg.close?.(); + + const viaNames = new Set(rows.map((r: any) => r.via)); + expect(viaNames.has('execute')).toBe(true); + expect(viaNames.has('validate')).toBe(true); + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 43e7bf0ba..b68a2b202 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -347,7 +347,7 @@ function cppOverrideEdges(queries: QueryBuilder): Edge[] { // and are added below; their concrete-side nodes can be a `struct` (Swift) // or an `object` (Scala) so the loop also iterates those kinds. const IFACE_OVERRIDE_LANGS = new Set([ - 'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala', + 'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala', 'php', ]); function interfaceOverrideEdges(queries: QueryBuilder): Edge[] { const edges: Edge[] = []; @@ -1085,11 +1085,245 @@ function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionContext): return edges; } +/** + * PHP @property PHPDoc → reference + calls edges. Classes annotated with + * `@property TypeName $propName` declare dynamic properties resolved + * through `__get()`. This is the standard PHP mechanism for service + * locators, DI containers, and ORMs (e.g. MPF Ctx, Doctrine entities). + * + * Phase 1: emit `references` edges from the annotated class to the + * declared @property types (class→class dependency). + * + * Phase 2: scan all PHP method bodies for chained property calls + * (`->propName->methodName(`) and variable-dereference patterns + * (`$var = ...->propName; $var->method(`), then synthesize method→method + * `calls` edges through the @property mapping. This bridges the __get() + * magic-method indirection that tree-sitter cannot statically resolve. + * + * Precision gates: propName must exist in the @property map, methodName + * must exist on the target type, comments/strings stripped before matching. + * + * Known limitation: no receiver-type verification — a non-@property field + * with the same name as an @property would also match. The propMap lookup + * makes this unlikely in practice (requires same propName + same methodName + * on the target type), and `heuristic` provenance lets downstream consumers + * distinguish these edges from static ones. + */ +const PHP_PROPERTY_RE = /(@property(?:-read|-write)?)\s+(\\?[A-Za-z_][\w\\|]*)\s+\$(\w+)/g; +const PHP_PRIMITIVE_TYPES = new Set([ + 'string', 'int', 'integer', 'float', 'double', 'bool', 'boolean', + 'array', 'null', 'void', 'mixed', 'object', 'callable', 'iterable', + 'self', 'static', 'parent', 'never', 'true', 'false', 'resource', +]); + +const PHP_PROP_CALL_RE = /->(\w+)\s*->\s*(\w+)\s*\(/g; +// Assignment: `$var = ...->propName;` (property access, not method call) +const PHP_PROP_ASSIGN_RE = /\$(\w+)\s*=\s*[^;]*->(\w+)\s*;/g; +// Variable method call: `$var->method(` +const PHP_VAR_CALL_RE = /\$(\w+)->(\w+)\s*\(/g; + +/** Blank PHP string interiors so regex matching doesn't produce false hits. */ +function blankPhpStrings(src: string): string { + const out = src.split(''); + let i = 0; + const n = src.length; + while (i < n) { + if (src[i] === '"' || src[i] === "'") { + const quote = src[i]!; + i++; // skip opening quote + while (i < n && src[i] !== quote) { + if (src[i] === '\\' && i + 1 < n) { out[i] = ' '; out[i + 1] = ' '; i += 2; continue; } + if (src[i] === '\n') break; + out[i] = ' '; + i++; + } + if (i < n && src[i] === quote) i++; // skip closing quote + } else { + i++; + } + } + return out.join(''); +} + +function phpPhpdocPropertyEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] { + const edges: Edge[] = []; + const seen = new Set(); + + // Phase 1: collect @property declarations → references edges + build prop map for Phase 2. + // propMap: propertyName → [{ targetTypeName, annotation }] + const propMap = new Map>(); + + const classKinds: Array<'class' | 'interface' | 'trait'> = ['class', 'interface', 'trait']; + for (const kind of classKinds) { + for (const cls of queries.getNodesByKind(kind)) { + if (cls.language !== 'php') continue; + const content = ctx.readFile(cls.filePath); + if (!content) continue; + + const lines = content.split('\n'); + let docblock = ''; + for (let i = (cls.startLine ?? 1) - 2; i >= 0; i--) { + const line = lines[i]!.trim(); + if (line === '' && docblock === '') continue; + if (line.startsWith('*') || line.startsWith('/**') || line === '*/') { + docblock = lines[i]! + '\n' + docblock; + if (line.startsWith('/**')) break; + } else { + break; + } + } + if (!docblock) continue; + + PHP_PROPERTY_RE.lastIndex = 0; + let m: RegExpExecArray | null; + let added = 0; + while ((m = PHP_PROPERTY_RE.exec(docblock))) { + if (added >= MAX_CALLBACKS_PER_CHANNEL) break; + const annotation = m[1]!; + const rawType = m[2]!; + const propName = m[3]!; + const simpleName = rawType.split('|')[0]!.split('\\').pop()!; + if (!simpleName || PHP_PRIMITIVE_TYPES.has(simpleName.toLowerCase())) continue; + + const via = `${annotation} ${rawType} $${propName}`; + const entries = propMap.get(propName) ?? []; + entries.push({ targetTypeName: simpleName, annotation: via }); + propMap.set(propName, entries); + + const targets = ctx.getNodesByName(simpleName).filter( + (n) => (n.kind === 'class' || n.kind === 'interface' || n.kind === 'trait') && n.language === 'php', + ); + for (const target of targets) { + if (target.id === cls.id) continue; + const key = `${cls.id}>${target.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: cls.id, + target: target.id, + kind: 'references', + line: cls.startLine, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'php-phpdoc-property', + via, + }, + }); + added++; + } + } + } + } + + // Phase 2: scan all PHP methods for property-chain calls and variable- + // dereference patterns, synthesize method→method calls edges. + if (propMap.size > 0) { + const propNames = new Set(propMap.keys()); + + // Pre-index target class methods by (targetTypeName, methodName) for O(1) lookup. + const targetMethodIndex = new Map(); + for (const entries of propMap.values()) { + for (const { targetTypeName } of entries) { + if (targetMethodIndex.has(targetTypeName)) continue; + const targetClasses = ctx.getNodesByName(targetTypeName).filter( + (n) => (n.kind === 'class' || n.kind === 'interface' || n.kind === 'trait') && n.language === 'php', + ); + for (const tc of targetClasses) { + const methods = queries.getOutgoingEdges(tc.id, ['contains']) + .map((e) => queries.getNodeById(e.target)) + .filter((n): n is Node => !!n && n.kind === 'method'); + for (const method of methods) { + const mKey = `${targetTypeName}::${method.name}`; + const arr = targetMethodIndex.get(mKey); + if (arr) arr.push(method); else targetMethodIndex.set(mKey, [method]); + } + } + } + } + + const addCallEdge = (method: Node, calledMethod: string, propEntries: Array<{ targetTypeName: string; annotation: string }>) => { + for (const { targetTypeName, annotation } of propEntries) { + const targets = targetMethodIndex.get(`${targetTypeName}::${calledMethod}`); + if (!targets) continue; + for (const target of targets) { + if (target.id === method.id) continue; + const key = `${method.id}>${target.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: method.id, + target: target.id, + kind: 'calls', + line: method.startLine, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'php-phpdoc-property', + via: `${annotation} → ${calledMethod}`, + }, + }); + } + } + }; + + for (const file of ctx.getAllFiles()) { + if (!file.endsWith('.php')) continue; + const content = ctx.readFile(file); + if (!content || !content.includes('->')) continue; + const nodesInFile = ctx.getNodesInFile(file); + const methods = nodesInFile.filter((n) => n.kind === 'method' && n.language === 'php'); + + for (const method of methods) { + const rawSrc = sliceLines(content, method.startLine, method.endLine); + if (!rawSrc) continue; + + // P3: skip methods that don't mention any known @property name. + let hasProp = false; + for (const name of propNames) { + if (rawSrc.includes(name)) { hasProp = true; break; } + } + if (!hasProp) continue; + + // P0: strip comments and string interiors to avoid false matches. + const src = blankPhpStrings(stripCommentsForRegex(rawSrc, 'php')); + + // Pattern A: direct chain `->propName->methodName(` + PHP_PROP_CALL_RE.lastIndex = 0; + let cm: RegExpExecArray | null; + while ((cm = PHP_PROP_CALL_RE.exec(src))) { + const propEntries = propMap.get(cm[1]!); + if (propEntries) addCallEdge(method, cm[2]!, propEntries); + } + + // Pattern B: variable dereference `$var = ...->propName; ... $var->method(` + const varToProp = new Map(); + PHP_PROP_ASSIGN_RE.lastIndex = 0; + let am: RegExpExecArray | null; + while ((am = PHP_PROP_ASSIGN_RE.exec(src))) { + if (propNames.has(am[2]!)) varToProp.set(am[1]!, am[2]!); + } + if (varToProp.size > 0) { + PHP_VAR_CALL_RE.lastIndex = 0; + let vm: RegExpExecArray | null; + while ((vm = PHP_VAR_CALL_RE.exec(src))) { + const propName = varToProp.get(vm[1]!); + if (!propName) continue; + const propEntries = propMap.get(propName); + if (propEntries) addCallEdge(method, vm[2]!, propEntries); + } + } + } + } + } + + return edges; +} + /** * Synthesize dispatcher→callback edges (field observers + EventEmitters + * React re-render + JSX children + Vue templates + RN event channel + - * Fabric native-impl + MyBatis Java↔XML + Gin middleware chain). Returns the - * count added. Never throws into indexing — callers wrap in try/catch. + * Fabric native-impl + MyBatis Java↔XML + Gin middleware chain + + * PHP @property PHPDoc). Returns the count added. Never throws into + * indexing — callers wrap in try/catch. */ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number { const fieldEdges = fieldChannelEdges(queries, ctx); @@ -1105,6 +1339,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo const fabricNativeEdges = fabricNativeImplEdges(ctx); const mybatisEdges = mybatisJavaXmlEdges(queries); const ginEdges = ginMiddlewareChainEdges(queries, ctx); + const phpPhpdocEdges = phpPhpdocPropertyEdges(queries, ctx); const merged: Edge[] = []; const seen = new Set(); @@ -1122,6 +1357,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo ...fabricNativeEdges, ...mybatisEdges, ...ginEdges, + ...phpPhpdocEdges, ]) { const key = `${e.source}>${e.target}`; if (seen.has(key)) continue; diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 88bf205e6..f88947bd8 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -25,6 +25,7 @@ import { swiftObjcBridgeResolver } from './swift-objc'; import { reactNativeBridgeResolver } from './react-native'; import { expoModulesResolver } from './expo-modules'; import { fabricViewResolver } from './fabric'; +import { phpPhpdocResolver } from './php-phpdoc'; /** * All registered framework resolvers @@ -33,6 +34,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ // PHP laravelResolver, drupalResolver, + phpPhpdocResolver, // JavaScript/TypeScript expressResolver, nestjsResolver, @@ -140,3 +142,4 @@ export { swiftObjcBridgeResolver } from './swift-objc'; export { reactNativeBridgeResolver } from './react-native'; export { expoModulesResolver } from './expo-modules'; export { fabricViewResolver } from './fabric'; +export { phpPhpdocResolver } from './php-phpdoc'; diff --git a/src/resolution/frameworks/php-phpdoc.ts b/src/resolution/frameworks/php-phpdoc.ts new file mode 100644 index 000000000..a7e2a6b70 --- /dev/null +++ b/src/resolution/frameworks/php-phpdoc.ts @@ -0,0 +1,128 @@ +/** + * PHP @property PHPDoc Framework Resolver + * + * Resolves dynamic property access through PHP magic methods (__get/__call) + * by leveraging @property PHPDoc annotations. Common patterns: + * + * - Service locator (MPF Ctx): $this->ctx->user_factory->findByUid() + * - MOA RPC proxy: $this->ctx->moa->UserService->getUser() + * - DI containers with __get dispatching + * + * The resolver creates `references` edges from the class using @property + * annotations to the declared type, and resolves method calls on those + * types when possible. + */ + +import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext, FrameworkExtractionResult } from '../types'; + +const PROPERTY_RE = /(@property(?:-read|-write)?)\s+(\\?[A-Za-z_][\w\\|]*)\s+\$(\w+)/g; +const PRIMITIVE_TYPES = new Set([ + 'string', 'int', 'integer', 'float', 'double', 'bool', 'boolean', + 'array', 'null', 'void', 'mixed', 'object', 'callable', 'iterable', + 'self', 'static', 'parent', 'never', 'true', 'false', 'resource', +]); + +/** + * Parse @property annotations from a PHPDoc block. + * Returns tuples of [typeName, propertyName]. + */ +function parsePropertyAnnotations(docblock: string): Array<{ type: string; prop: string; annotation: string }> { + const results: Array<{ type: string; prop: string; annotation: string }> = []; + PROPERTY_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = PROPERTY_RE.exec(docblock))) { + const annotation = m[1]!; + const rawType = m[2]!; + const simpleName = rawType.split('|')[0]!.split('\\').pop()!; + if (simpleName && !PRIMITIVE_TYPES.has(simpleName.toLowerCase())) { + results.push({ type: simpleName, prop: m[3]!, annotation }); + } + } + return results; +} + +/** + * Extract the PHPDoc block immediately preceding a class/interface declaration. + */ +function extractPrecedingDocblock(content: string, classStartLine: number): string { + const lines = content.split('\n'); + let docblock = ''; + for (let i = classStartLine - 2; i >= 0; i--) { + const line = lines[i]!.trim(); + if (line === '' && docblock === '') continue; + if (line.startsWith('*') || line.startsWith('/**') || line === '*/') { + docblock = lines[i]! + '\n' + docblock; + if (line.startsWith('/**')) break; + } else { + break; + } + } + return docblock; +} + +export const phpPhpdocResolver: FrameworkResolver = { + name: 'php-phpdoc', + languages: ['php'], + + detect(context: ResolutionContext): boolean { + for (const file of context.getAllFiles()) { + if (!file.endsWith('.php')) continue; + const content = context.readFile(file); + if (content && content.includes('@property')) return true; + } + return false; + }, + + claimsReference(name: string): boolean { + return name.startsWith('phpdoc-property:'); + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (!ref.referenceName.startsWith('phpdoc-property:')) return null; + + const typeName = ref.referenceName.slice('phpdoc-property:'.length); + const candidates = context.getNodesByName(typeName).filter( + (n) => (n.kind === 'class' || n.kind === 'interface' || n.kind === 'trait') && n.language === 'php', + ); + + if (candidates.length === 0) return null; + + return { + original: ref, + targetNodeId: candidates[0]!.id, + confidence: 0.85, + resolvedBy: 'framework', + }; + }, + + extract(filePath: string, content: string): FrameworkExtractionResult { + if (!filePath.endsWith('.php') || !content.includes('@property')) { + return { nodes: [], references: [] }; + } + + const references: UnresolvedRef[] = []; + const classRe = /^\s*(?:abstract\s+|final\s+)?(?:class|interface|trait)\s+(\w+)/gm; + let classMatch: RegExpExecArray | null; + + while ((classMatch = classRe.exec(content))) { + const classLine = content.slice(0, classMatch.index).split('\n').length; + const docblock = extractPrecedingDocblock(content, classLine); + if (!docblock) continue; + + const props = parsePropertyAnnotations(docblock); + for (const { type } of props) { + references.push({ + fromNodeId: '', // will be matched by class name during resolution + referenceName: `phpdoc-property:${type}`, + referenceKind: 'references', + line: classLine, + column: 0, + filePath, + language: 'php', + }); + } + } + + return { nodes: [], references }; + }, +};