diff --git a/package-lock.json b/package-lock.json index c18df39a0..300229805 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "openai": "^6.45.0", "tree-sitter": "^0.21.1", "tree-sitter-go": "^0.23.4", + "tree-sitter-hack": "npm:@mmissey/tree-sitter-hacklang@^0.1.1", "tree-sitter-java": "^0.23.5", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-php": "^0.23.4", @@ -973,6 +974,7 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1075,6 +1077,7 @@ "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0" @@ -1112,6 +1115,27 @@ } } }, + "node_modules/tree-sitter-hack": { + "name": "@mmissey/tree-sitter-hacklang", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@mmissey/tree-sitter-hacklang/-/tree-sitter-hacklang-0.1.1.tgz", + "integrity": "sha512-IV5HHxkGOYoKVl/pOB3VvB0LbwExgkUp29/iER+QKK597iSxPL1xeTpq+4Rhyj5TNrvW1vFw9f7lNp2vdloRAQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "engines": { + "node": ">=14.7.0" + } + }, + "node_modules/tree-sitter-hack/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/tree-sitter-java": { "version": "0.23.5", "resolved": "https://registry.npmjs.org/tree-sitter-java/-/tree-sitter-java-0.23.5.tgz", diff --git a/package.json b/package.json index 03fa2b876..4e9816346 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "openai": "^6.45.0", "tree-sitter": "^0.21.1", "tree-sitter-go": "^0.23.4", + "tree-sitter-hack": "npm:@mmissey/tree-sitter-hacklang@^0.1.1", "tree-sitter-java": "^0.23.5", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-php": "^0.23.4", @@ -88,6 +89,7 @@ "tree-sitter-typescript@0.23.2": true, "tree-sitter-python@0.21.0": true, "tree-sitter-go@0.23.4": true, + "@mmissey/tree-sitter-hacklang@0.1.1": true, "tree-sitter-javascript@0.23.1": true, "tree-sitter-java@0.23.5": true, "esbuild@0.28.2": true, diff --git a/src/graph/bindings.ts b/src/graph/bindings.ts index 96d01089b..ce158bb9c 100644 --- a/src/graph/bindings.ts +++ b/src/graph/bindings.ts @@ -77,6 +77,22 @@ export function defName(node: Parser.SyntaxNode, lang: Language): string | null if (node.type === "anonymous_class") return "{anonymous}"; return null; } + if (lang === "hack") { + // Mirrors extract.ts's HACK_KINDS — the exact node types that push a scope + // segment there, so a typed parameter bound inside a method is keyed under + // the same scope path resolveRecvType later looks it up by. + const hackDefTypes = new Set([ + "class_declaration", + "interface_declaration", + "trait_declaration", + "enum_declaration", + "enum_class_declaration", + "method_declaration", + "function_declaration", + ]); + if (hackDefTypes.has(node.type)) return node.childForFieldName("name")?.text ?? null; + return null; + } const defTypes = lang === "python" ? new Set(["class_definition", "function_definition"]) @@ -211,9 +227,9 @@ export function resolveRecvType( undefined ); } - // PHP static call `Foo::bar()`: the scope operand is a class name, so it *is* - // the receiver type. (Member calls pass a `$var` receiver, filtered by the `$`.) - if (ctx.lang === "php" && !receiver.startsWith("$")) return receiver; + // PHP/Hack static call `Foo::bar()`: the scope operand is a class name, so it + // *is* the receiver type. (Member calls pass a `$var` receiver, filtered by `$`.) + if ((ctx.lang === "php" || ctx.lang === "hack") && !receiver.startsWith("$")) return receiver; return ( (ctx.lang === "go" && receiver === ctx.goReceiverVar ? ctx.enclosingClass : undefined) ?? ctx.bindings.lookup(ctx.scope, receiver) ?? @@ -307,6 +323,7 @@ function visit( else if (lang === "java") handleJava(node, scope, classScope, bindings); else if (lang === "swift") handleSwift(node, scope, classScope, bindings); else if (lang === "php") handlePhp(node, scope, bindings); + else if (lang === "hack") handleHack(node, scope, bindings); else handleTs(node, scope, classScope, bindings, aliases); const name = defName(node, lang); @@ -469,6 +486,56 @@ function phpNewType(node: Parser.SyntaxNode): string | null { return cls ? cls.text.replace(/^.*\\/, "") : null; } +/** Hack variable->type bindings from the same two syntax-local clues PHP uses, + * but on Hack's node shapes: a typed `parameter` (`function f(Foo $x)`) and a + * `new` assignment (`$x = new Foo()`, a `binary_expression` with `=` operator). + * Keyed by the `$var` text so a `$var->method()` site resolves through + * resolveRecvType's bindings lookup, exactly like handlePhp. */ +function handleHack(node: Parser.SyntaxNode, scope: string[], bindings: FileBindings): void { + if (node.type === "parameter") { + const type = hackTypeName(node.childForFieldName("type")); + const name = node.childForFieldName("name"); + if (type && name?.type === "variable") bindings.set(scope.join("."), name.text, type); + return; + } + if (node.type === "binary_expression") { + const op = node.childForFieldName("operator"); + const left = node.childForFieldName("left"); + const right = node.childForFieldName("right"); + if (op?.text === "=" && left?.type === "variable" && right?.type === "new_expression") { + const type = hackConstructedName(right); + if (type) bindings.set(scope.join("."), left.text, type); + } + } +} + +/** A Hack `type_specifier`'s bare class name — the trailing segment of the + * qualified name it wraps. Null for primitives (`int`/`void`, whose children are + * anonymous keyword tokens) and for shape/tuple/generic-only specifiers. */ +function hackTypeName(node: Parser.SyntaxNode | null): string | null { + if (!node || node.type !== "type_specifier") return null; + return hackTrailingIdent(node.namedChildren.find((c) => c.type === "qualified_identifier" || c.type === "identifier")); +} + +/** The type constructed by a Hack `new_expression` (`new Foo()` → `Foo`), + * de-qualified; null when it wraps no qualified name. */ +function hackConstructedName(node: Parser.SyntaxNode): string | null { + return hackTrailingIdent(node.namedChildren.find((c) => c.type === "qualified_identifier" || c.type === "identifier")); +} + +/** Trailing identifier of a Hack qualified name, or a bare identifier's own text; + * null otherwise. Duplicated here (not imported from extract.ts) per this file's + * no-value-import rule. */ +function hackTrailingIdent(node: Parser.SyntaxNode | null | undefined): string | null { + if (!node) return null; + if (node.type === "identifier") return node.text; + if (node.type === "qualified_identifier") { + const ids = node.namedChildren.filter((c) => c.type === "identifier"); + return ids.length ? ids[ids.length - 1]!.text : null; + } + return null; +} + function handlePy( node: Parser.SyntaxNode, scope: string[], diff --git a/src/graph/extract.ts b/src/graph/extract.ts index d0f9dff19..659b54f09 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -15,12 +15,23 @@ import Java from "tree-sitter-java"; import Kotlin from "tree-sitter-kotlin"; import Swift from "tree-sitter-swift"; import PHP from "tree-sitter-php"; +import Hack from "tree-sitter-hack"; import { basename } from "node:path"; import { contentHash } from "../util/id.js"; import { collectBindings, goReceiverVarOf, resolveRecvType, type FileBindings } from "./bindings.js"; import type { Kind, NodeV1, Relation } from "./types.js"; -export type Language = "typescript" | "tsx" | "python" | "go" | "java" | "kotlin" | "swift" | "php" | "r"; +export type Language = + | "typescript" + | "tsx" + | "python" + | "go" + | "java" + | "kotlin" + | "swift" + | "php" + | "r" + | "hack"; /** * Extension → the tree-sitter grammar that parses it, and the label a human expects @@ -54,6 +65,8 @@ const EXTENSIONS: ReadonlyArray<{ ext: string; grammar: Language; label: string { ext: ".kts", grammar: "kotlin", label: "kotlin" }, { ext: ".swift", grammar: "swift", label: "swift" }, { ext: ".php", grammar: "php", label: "php" }, + { ext: ".hack", grammar: "hack", label: "hack" }, + { ext: ".hhi", grammar: "hack", label: "hack" }, // `entryFor` lower-cases the path before matching, so this one entry covers // both `.R` (the conventional case in real R codebases) and `.r`. { ext: ".r", grammar: "r", label: "r" }, @@ -263,6 +276,22 @@ const PHP_KINDS: Record = { enum_declaration: "enum", }; +// Hack (Hacklang): like PHP, every definition node type is distinct — but a +// top-level function is `function_declaration`, not php's `function_definition`, +// and a class body's methods are `method_declaration`. `trait_declaration` maps +// to the PHP-shared `trait` kind. +const HACK_KINDS: Record = { + function_declaration: "function", + method_declaration: "method", + class_declaration: "class", + interface_declaration: "interface", + trait_declaration: "trait", + enum_declaration: "enum", + // `enum class Foo: Bar {...}` — a Hack-only construct (111 files in webapp), + // a distinct node type from a plain `enum`. Mapped to the same `enum` kind. + enum_class_declaration: "enum", +}; + const KINDS_BY_LANG: Record> = { typescript: TS_KINDS, tsx: TS_KINDS, @@ -273,6 +302,7 @@ const KINDS_BY_LANG: Record> = { kotlin: KOTLIN_KINDS, swift: SWIFT_KINDS, php: PHP_KINDS, + hack: HACK_KINDS, }; /** @@ -299,6 +329,9 @@ const CALL_TYPES: Record> = { "scoped_call_expression", ]), r: new Set(["call"]), + // Hack: an ordinary call is a single `call_expression` (member/static/free all + // share it, unlike PHP); `new_expression` is the constructor-call shape. + hack: new Set(["call_expression", "new_expression"]), }; const FUNCTION_VALUE_TYPES = new Set([ @@ -321,6 +354,7 @@ const GRAMMARS: Record = { kotlin: Kotlin, swift: Swift, php: PHP.php, + hack: Hack.hack, }; export interface WalkCtx { @@ -600,7 +634,7 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd ? kotlinExported(node) : ctx.lang === "swift" ? swiftExported(node) - : ctx.lang === "php" + : ctx.lang === "php" || ctx.lang === "hack" ? phpExported(node) : tsExported(node), origin: "ast", @@ -620,13 +654,18 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd const javaTypeDecl = ctx.lang === "java" && JAVA_TYPE_KINDS.has(desc.kind); const kotlinTypeDecl = ctx.lang === "kotlin" && KOTLIN_TYPE_KINDS.has(desc.kind); const swiftTypeDecl = ctx.lang === "swift" && SWIFT_TYPE_KINDS.has(desc.kind); - if (desc.kind === "class" || javaTypeDecl || kotlinTypeDecl || swiftTypeDecl) + // Hack: a class, interface, or trait is a heritage site (an interface may + // `extends` other interfaces; a class `extends` one + `implements` many) and + // owns `$this`/`self::` member calls in its (and a trait's) method bodies. + const hackTypeDecl = + ctx.lang === "hack" && (desc.kind === "class" || desc.kind === "interface" || desc.kind === "trait"); + if (desc.kind === "class" || javaTypeDecl || kotlinTypeDecl || swiftTypeDecl || hackTypeDecl) edges.push(...heritageEdges(node, id, ctx)); if (ctx.lang === "php") edges.push(...phpAttributeReferenceEdges(node, id, ctx)); if (ctx.lang === "java") edges.push(...javaAnnotationReferenceEdges(node, id, ctx)); const enclosingClass = - desc.kind === "class" || javaTypeDecl || kotlinTypeDecl || swiftTypeDecl + desc.kind === "class" || javaTypeDecl || kotlinTypeDecl || swiftTypeDecl || hackTypeDecl ? desc.name : isGoMethod ? goReceiverType(node) @@ -779,6 +818,17 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd } } return; + } else if (ctx.lang === "hack" && node.type === "trait_use_clause") { + // Trait composition inside a class body (`use TA, TB;`). Modelled as + // `implements`, exactly like PHP's `use_declaration` above — a trait is a + // contract of behaviour the class mixes in (Graft's Relation set has no `uses`). + for (const t of node.namedChildren) { + if (t.type === "type_specifier") { + const name = hackTypeSpecifierName(t); + if (name) edges.push({ source: ctx.parentId, relation: "implements", name, file: ctx.rel }); + } + } + return; } else if ( node.type === "identifier" && !isDirectCallee(node, callTypes) && @@ -2111,6 +2161,24 @@ function heritageEdges(node: Parser.SyntaxNode, classId: string, ctx: WalkCtx): } return edges; } + if (ctx.lang === "hack") { + // `class C extends B implements I, J` / `interface I extends A, B` — unlike + // PHP the clauses are DIRECT children of the declaration (no wrapping + // `class_heritage`/`base_clause`), each holding one `type_specifier` per + // supertype. An interface's `extends` also lands here as an `extends` edge. + for (const clause of node.namedChildren) { + const relation: Relation | null = + clause.type === "extends_clause" ? "extends" : clause.type === "implements_clause" ? "implements" : null; + if (!relation) continue; + for (const t of clause.namedChildren) { + if (t.type === "type_specifier") { + const name = hackTypeSpecifierName(t); + if (name) edges.push({ source: classId, relation, name, file: ctx.rel }); + } + } + } + return edges; + } const heritage = node.namedChildren.find((c) => c.type === "class_heritage"); for (const clause of heritage?.namedChildren ?? []) { const relation: Relation | null = @@ -2267,6 +2335,7 @@ if (lang === "kotlin") { } if (lang === "php") return phpCallee(node); + if (lang === "hack") return hackCallee(node); const fn = node.childForFieldName("function"); if (!fn) return null; @@ -2449,6 +2518,66 @@ function phpScopeReceiver(scope: Parser.SyntaxNode | null): string | undefined { return undefined; } +/** Trailing identifier of a Hack qualified name (`Slack\Util\log` → `log`), or a + * bare identifier's own text. Null for anything else. */ +function hackLastIdent(node: Parser.SyntaxNode | null | undefined): string | null { + if (!node) return null; + if (node.type === "identifier") return node.text; + if (node.type === "qualified_identifier") { + const ids = node.namedChildren.filter((c) => c.type === "identifier"); + return ids.length ? ids[ids.length - 1]!.text : null; + } + return null; +} + +/** The bare supertype / trait name a Hack `type_specifier` names — its qualified + * name's trailing segment, generic arguments and namespace qualifier discarded + * (`Ns\J` → `J`). Null when it wraps no qualified name (a shape/tuple/etc.). */ +function hackTypeSpecifierName(ts: Parser.SyntaxNode): string | null { + return hackLastIdent(ts.namedChildren.find((c) => c.type === "qualified_identifier" || c.type === "identifier")); +} + +/** + * Hack call shapes, all under one `call_expression` (unlike PHP's four node + * types): a free/namespaced `foo()` / `Ns\foo()` (function = qualified_identifier), + * a member `$obj->m()` (selection_expression), and a static `Cls::m()` / + * `self::m()` (scoped_identifier). Plus `new Foo()` (new_expression), whose + * constructed type is the call target. The receiver, when locally knowable + * (`$this`, `self`/`static`/`parent`, or a class name), feeds receiver-typed + * resolution the same way PHP's does. + */ +function hackCallee(node: Parser.SyntaxNode): { name: string; viaMember: boolean; receiver?: string } | null { + if (node.type === "new_expression") { + const name = hackTypeSpecifierName(node); + return name ? { name, viaMember: false } : null; + } + const fn = node.childForFieldName("function"); + if (!fn) return null; + if (fn.type === "qualified_identifier" || fn.type === "identifier") { + const name = hackLastIdent(fn); + return name ? { name, viaMember: false } : null; + } + if (fn.type === "selection_expression") { + // `$obj->method()` — no field names; children are [receiver, name]. + const name = hackLastIdent(fn.namedChildren[fn.namedChildren.length - 1]); + if (!name) return null; + const recv = fn.namedChildren[0]; + const receiver = recv?.type === "variable" ? (recv.text === "$this" ? "this" : recv.text) : undefined; + return { name, viaMember: true, receiver }; + } + if (fn.type === "scoped_identifier") { + // `Cls::m()` / `self::m()` — no field names; children are [scope, name]. + const nameNode = fn.namedChildren[fn.namedChildren.length - 1]; + if (nameNode?.type !== "identifier") return null; + const scope = fn.namedChildren[0]; + // `self`/`static`/`parent` (a `scope_identifier`) → the enclosing class; a + // real class name resolves to itself via resolveRecvType's hack branch. + const receiver = scope?.type === "scope_identifier" ? "self" : (hackLastIdent(scope) ?? undefined); + return { name: nameNode.text, viaMember: true, receiver }; + } + return null; +} + /** ts `member_expression` node's receiver text: `this`, `this.x`, or a bare identifier. */ function tsReceiver(fn: Parser.SyntaxNode): string | undefined { const obj = fn.childForFieldName("object"); @@ -2484,6 +2613,9 @@ if (lang === "kotlin") return node.type === "import_header"; // PHP: one edge per imported symbol — the clause leaf inside a (possibly // grouped) `use A\B, C\D;` / `use A\{B, C};` declaration. if (lang === "php") return node.type === "namespace_use_clause"; + // Hack: `use A\B;` / `use type A\B;` / `use function f;` — one edge per + // `use_clause` inside the `use_statement`. + if (lang === "hack") return node.type === "use_clause"; return node.type === "import_statement" || node.type === "import_from_statement"; } @@ -2493,6 +2625,12 @@ function importSpecifier(node: Parser.SyntaxNode, lang: Language): string | null const q = node.namedChildren.find((c) => c.type === "qualified_name" || c.type === "name"); return q ? q.text.replace(/^\\/, "") : null; } + if (lang === "hack") { + // use_clause → its `qualified_identifier`, e.g. `Slack\Widgets\Widget`. A + // `use_type` keyword (`type`/`namespace`/`function`) is a separate child. + const q = node.namedChildren.find((c) => c.type === "qualified_identifier"); + return q ? q.text.replace(/^\\/, "") : null; + } if (lang === "python") { const m = node.childForFieldName("module_name") ?? diff --git a/src/graph/resolve.ts b/src/graph/resolve.ts index c78a61e6c..fd4de09f4 100644 --- a/src/graph/resolve.ts +++ b/src/graph/resolve.ts @@ -181,12 +181,14 @@ export function resolveEdges( push(classParents, ownName, e.name); } - // classTraits: class name → trait names from raw `implements` edges in PHP files. - // PHP models `use SomeTrait;` as implements; trait methods live on the trait owner, - // not the using class, so resolveTypedMember walks these after the class lookup fails. + // classTraits: class name → trait names from raw `implements` edges in PHP and + // Hack files. Both model `use SomeTrait;` as implements; trait methods live on the + // trait owner, not the using class, so resolveTypedMember walks these after the + // class lookup fails. const classTraits = new Map(); for (const e of rawEdges) { - if (e.relation !== "implements" || !e.name || !e.file.endsWith(".php")) continue; + const isTraitLang = e.file.endsWith(".php") || e.file.endsWith(".hack") || e.file.endsWith(".hhi"); + if (e.relation !== "implements" || !e.name || !isTraitLang) continue; const ownName = byId.get(e.source)?.name; if (!ownName) continue; push(classTraits, ownName, e.name); diff --git a/test/graph-hack.test.ts b/test/graph-hack.test.ts new file mode 100644 index 000000000..173c1877c --- /dev/null +++ b/test/graph-hack.test.ts @@ -0,0 +1,227 @@ +/** + * Tests for Hack (Hacklang) extraction in the Tier-1 code graph. Hack is PHP-like + * but has its own tree-sitter grammar (vendored `tree-sitter-hack`) with distinct + * node types — a top-level `function_declaration` (not php's `function_definition`), + * class-body `method_declaration`s, single `call_expression` for every call shape, + * and `extends_clause`/`implements_clause` as direct children of the declaration. + * Builds a small tagless-`.hack` project in a temp dir and asserts the emitted + * nodes and edges match the AST walk in extract.ts. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildGraph } from "../src/graph/build.js"; +import { extractFile } from "../src/graph/extract.js"; +import { readGraph, wiringPath } from "../src/graph/write.js"; +import type { GraphV1, NodeV1 } from "../src/graph/types.js"; + +// Tagless `.hack` — the native grammar parses it without a `log(); + return $this->helper(); + } + + private function helper(): int { + return Base::seed(); + } + + public function act(Base $b): string { + return $b->label(); + } +} + +interface Runnable {} + +trait Loggable { + protected function log(): void {} +} + +enum Color: string { + RED = 'r'; +} + +function topLevel(): int { + return 1; +} +`; + +const BASE_HACK = `namespace App\\Support; + +class Base { + public static function seed(): int { + return 0; + } + + public function label(): string { + return 'b'; + } +} +`; + +function makeFixture(): string { + const dir = mkdtempSync(join(tmpdir(), "graft-hack-")); + writeFileSync(join(dir, "app.hack"), APP_HACK); + writeFileSync(join(dir, "base.hack"), BASE_HACK); + return dir; +} + +function nodeById(graph: GraphV1, id: string): NodeV1 | undefined { + return graph.nodes.find((n) => n.id === id); +} + +test("Hack extraction: classes, methods, interface, trait, enum, function", async () => { + const dir = makeFixture(); + try { + const result = await buildGraph(dir); // $0, Tier-1 only + assert.ok(result.languages.includes("hack"), "languages should include hack"); + + const graph = readGraph(wiringPath(join(dir, "graft"))); + assert.ok(graph, "wiring graph should be written"); + + assert.equal(nodeById(graph!, "app.hack#Widget")?.kind, "class"); + assert.equal(nodeById(graph!, "base.hack#Base")?.kind, "class"); + + // methods — file-scoped under their class; visibility drives `exported` + const run = nodeById(graph!, "app.hack#Widget.run"); + assert.equal(run?.kind, "method"); + assert.equal(run?.exported, true, "public method is exported"); + assert.equal(nodeById(graph!, "app.hack#Widget.helper")?.exported, false, "private method is not exported"); + + // language-specific kinds + assert.equal(nodeById(graph!, "app.hack#Runnable")?.kind, "interface"); + assert.equal(nodeById(graph!, "app.hack#Loggable")?.kind, "trait"); + assert.equal(nodeById(graph!, "app.hack#Color")?.kind, "enum"); + assert.equal(nodeById(graph!, "app.hack#topLevel")?.kind, "function"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Hack extraction: call, extends, and implements edges", async () => { + const dir = makeFixture(); + try { + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + + // `class Widget extends Base` resolves cross-file by class name + assert.ok( + graph.edges.some( + (e) => e.relation === "extends" && e.source === "app.hack#Widget" && e.target === "base.hack#Base", + ), + "Widget should have a resolved extends edge to Base", + ); + + // `implements Runnable` resolves to the same-file interface + assert.ok( + graph.edges.some( + (e) => e.relation === "implements" && e.source === "app.hack#Widget" && e.target === "app.hack#Runnable", + ), + "Widget should have a resolved implements edge to Runnable", + ); + + // `$this->log()` resolves to the used trait's method (trait-use → implements edge) + assert.ok( + graph.edges.some( + (e) => e.relation === "calls" && e.source === "app.hack#Widget.run" && e.target === "app.hack#Loggable.log", + ), + "run should have a resolved calls edge to Loggable.log via trait use", + ); + + // `$this->helper()` resolves to the receiver method (this → enclosing class) + assert.ok( + graph.edges.some( + (e) => e.relation === "calls" && e.source === "app.hack#Widget.run" && e.target === "app.hack#Widget.helper", + ), + "run should have a resolved calls edge to helper", + ); + + // `Base::seed()` static call resolves to the target class method by name + assert.ok( + graph.edges.some( + (e) => e.relation === "calls" && e.source === "app.hack#Widget.helper" && e.target === "base.hack#Base.seed", + ), + "helper should have a resolved calls edge to Base.seed", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Hack extraction: trait use and typed-parameter receiver binding", async () => { + const dir = makeFixture(); + try { + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + + // `use Loggable;` inside the class body resolves to the trait (modelled as implements) + assert.ok( + graph.edges.some( + (e) => e.relation === "implements" && e.source === "app.hack#Widget" && e.target === "app.hack#Loggable", + ), + "Widget should have a resolved trait-use (implements) edge to Loggable", + ); + + // `act(Base $b)` -> `$b->label()` resolves via the typed-parameter binding + // ($b : Base), not by bare method name — Base.label is the only match anyway, + // but the binding is what makes this correct when several classes share a name. + assert.ok( + graph.edges.some( + (e) => e.relation === "calls" && e.source === "app.hack#Widget.act" && e.target === "base.hack#Base.label", + ), + "act should resolve $b->label() to Base.label through the parameter binding", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Hack extraction: `new` assignment binds the constructed type for member calls", () => { + // `$b = new Base(); $b->label();` — the binding pass records $b : Base from the + // `new` expression, so the member call resolves by receiver type, not bare name. + const src = `namespace App; + +use App\\Support\\Base; + +function build(): string { + $b = new Base(); + return $b->label(); +} +`; + const { rawEdges } = extractFile("build.hack", src, "hack"); + const call = rawEdges.find((e) => e.relation === "calls" && e.name === "label"); + assert.ok(call, "the $b->label() call edge should be emitted"); + assert.equal(call!.recvType, "Base", "$b->label() should carry recvType Base from the new-expression binding"); +}); + +test("Hack extraction: `enum class` is captured as an enum node", () => { + // Hack's `enum class Foo: Bar {...}` is a distinct node type from a plain + // `enum` (111 files in webapp use it) — it maps to the same `enum` kind. + const src = `namespace App; + +enum class Refs: Reference { + Foo Bar = new Reference(); +} +`; + const { nodes } = extractFile("refs.hack", src, "hack"); + assert.equal(nodes.find((n) => n.id === "refs.hack#Refs")?.kind, "enum"); +}); + +test("Hack extraction: `.hhi` interface files are indexed like `.hack`", () => { + const src = `namespace App; + +interface Greeter { + public function greet(): string; +} +`; + const { nodes } = extractFile("greeter.hhi", src, "hack"); + assert.equal(nodes.find((n) => n.id === "greeter.hhi#Greeter")?.kind, "interface"); +});