From 118a9f5e778cc4a5254923750008811474e34177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Thu, 24 Sep 2026 08:53:29 +0200 Subject: [PATCH 1/7] test(graph): add type hierarchy reference fixtures Co-Authored-By: Claude Opus 5.5 --- .../codegraph/type-hierarchy-facts.json | 1598 +++++++++++++++++ .../codegraph/type-hierarchy-source/go.mod | 3 + .../type-hierarchy-source/src/clock.go | 18 + .../type-hierarchy-source/src/plugins.ts | 39 + .../type-hierarchy-source/src/shapes.ts | 22 + 5 files changed, 1680 insertions(+) create mode 100644 test/fixtures/codegraph/type-hierarchy-facts.json create mode 100644 test/fixtures/codegraph/type-hierarchy-source/go.mod create mode 100644 test/fixtures/codegraph/type-hierarchy-source/src/clock.go create mode 100644 test/fixtures/codegraph/type-hierarchy-source/src/plugins.ts create mode 100644 test/fixtures/codegraph/type-hierarchy-source/src/shapes.ts diff --git a/test/fixtures/codegraph/type-hierarchy-facts.json b/test/fixtures/codegraph/type-hierarchy-facts.json new file mode 100644 index 00000000..0bb7242f --- /dev/null +++ b/test/fixtures/codegraph/type-hierarchy-facts.json @@ -0,0 +1,1598 @@ +{ + "schemas": { + "nodes": [ + "id", + "kind", + "name", + "qualified_name", + "file_path", + "language", + "start_line", + "end_line", + "start_column", + "end_column", + "docstring", + "signature", + "visibility", + "is_exported", + "is_async", + "is_static", + "is_abstract", + "decorators", + "type_parameters", + "return_type", + "updated_at" + ], + "edges": [ + "id", + "source", + "target", + "kind", + "metadata", + "line", + "col", + "provenance" + ], + "files": [ + "path", + "content_hash", + "language", + "size", + "modified_at", + "indexed_at", + "node_count", + "errors", + "generated" + ], + "unresolved_refs": [ + "id", + "from_node_id", + "reference_name", + "reference_kind", + "line", + "col", + "candidates", + "file_path", + "language", + "status", + "name_tail" + ], + "project_metadata": [ + "key", + "value", + "updated_at" + ], + "schema_versions": [ + "version", + "applied_at", + "description" + ] + }, + "nodes": [ + { + "id": "file:src/clock.go", + "kind": "file", + "name": "clock.go", + "qualified_name": "src/clock.go", + "file_path": "src/clock.go", + "language": "go", + "start_line": 1, + "end_line": 19, + "start_column": 0, + "end_column": 0, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566854 + }, + { + "id": "import:6efab0afebb09a263e373416641f8c1f", + "kind": "import", + "name": "time", + "qualified_name": "time", + "file_path": "src/clock.go", + "language": "go", + "start_line": 3, + "end_line": 3, + "start_column": 7, + "end_column": 13, + "docstring": null, + "signature": "\"time\"", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566855 + }, + { + "id": "interface:51774c4756182e9feb0479676126a6fd", + "kind": "interface", + "name": "Clock", + "qualified_name": "Clock", + "file_path": "src/clock.go", + "language": "go", + "start_line": 6, + "end_line": 8, + "start_column": 5, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566856 + }, + { + "id": "method:0416990a5a93fdf2e18b5dfb7399c930", + "kind": "method", + "name": "Now", + "qualified_name": "Clock::Now", + "file_path": "src/clock.go", + "language": "go", + "start_line": 7, + "end_line": 7, + "start_column": 1, + "end_column": 16, + "docstring": null, + "signature": "() time.Time", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566856 + }, + { + "id": "struct:67d2cf6e28291ecfbb5027925a2eb283", + "kind": "struct", + "name": "System", + "qualified_name": "System", + "file_path": "src/clock.go", + "language": "go", + "start_line": 11, + "end_line": 11, + "start_column": 5, + "end_column": 20, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566856 + }, + { + "id": "method:9af4c9ba003be80faaac09eecd0ae295", + "kind": "method", + "name": "Now", + "qualified_name": "System::Now", + "file_path": "src/clock.go", + "language": "go", + "start_line": 13, + "end_line": 13, + "start_column": 0, + "end_column": 57, + "docstring": null, + "signature": "() time.Time", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": "Time", + "updated_at": 1788880566856 + }, + { + "id": "struct:7b16c615e24f11b54bcf6e340818171c", + "kind": "struct", + "name": "Fixed", + "qualified_name": "Fixed", + "file_path": "src/clock.go", + "language": "go", + "start_line": 16, + "end_line": 16, + "start_column": 5, + "end_column": 33, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + }, + { + "id": "method:e8e483d6fdd52d5da2cc49b56a0c9dc6", + "kind": "method", + "name": "Now", + "qualified_name": "Fixed::Now", + "file_path": "src/clock.go", + "language": "go", + "start_line": 18, + "end_line": 18, + "start_column": 0, + "end_column": 46, + "docstring": null, + "signature": "() time.Time", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": "Time", + "updated_at": 1788880566858 + }, + { + "id": "file:src/plugins.ts", + "kind": "file", + "name": "plugins.ts", + "qualified_name": "src/plugins.ts", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 1, + "end_line": 40, + "start_column": 0, + "end_column": 0, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566855 + }, + { + "id": "interface:1cc121d1a175c7b64084761db727ee93", + "kind": "interface", + "name": "Plugin", + "qualified_name": "Plugin", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 1, + "end_line": 3, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566856 + }, + { + "id": "class:0724246c2fc012ade16fd11c08d9516d", + "kind": "class", + "name": "AlphaPlugin", + "qualified_name": "AlphaPlugin", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 5, + "end_line": 7, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566856 + }, + { + "id": "method:1d1cce8e79085265a2774d0cdbc65492", + "kind": "method", + "name": "run", + "qualified_name": "AlphaPlugin::run", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 6, + "end_line": 6, + "start_column": 2, + "end_column": 16, + "docstring": null, + "signature": "(): void", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + }, + { + "id": "class:0b06234dfa1966ab9be1af4b43e34ba9", + "kind": "class", + "name": "BravoPlugin", + "qualified_name": "BravoPlugin", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 9, + "end_line": 11, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + }, + { + "id": "method:8f7887ab72b423b411246872ec1854c6", + "kind": "method", + "name": "run", + "qualified_name": "BravoPlugin::run", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 10, + "end_line": 10, + "start_column": 2, + "end_column": 16, + "docstring": null, + "signature": "(): void", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + }, + { + "id": "class:9c29d0bbf2a0de3df485a2ef357d8ccc", + "kind": "class", + "name": "CharliePlugin", + "qualified_name": "CharliePlugin", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 13, + "end_line": 15, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + }, + { + "id": "method:e0255cc8ef774fbcc55cfbe41bc6d807", + "kind": "method", + "name": "run", + "qualified_name": "CharliePlugin::run", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 14, + "end_line": 14, + "start_column": 2, + "end_column": 16, + "docstring": null, + "signature": "(): void", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + }, + { + "id": "class:24ed18b215be98dbadd36c0e4cee473d", + "kind": "class", + "name": "DeltaPlugin", + "qualified_name": "DeltaPlugin", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 17, + "end_line": 19, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + }, + { + "id": "method:6a8b23cd8fd9380f295c304a93d42b70", + "kind": "method", + "name": "run", + "qualified_name": "DeltaPlugin::run", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 18, + "end_line": 18, + "start_column": 2, + "end_column": 16, + "docstring": null, + "signature": "(): void", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566858 + }, + { + "id": "class:8eef1358668f6b164292dab5eb21a2a9", + "kind": "class", + "name": "EchoPlugin", + "qualified_name": "EchoPlugin", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 21, + "end_line": 23, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566858 + }, + { + "id": "method:e3abfb1ca720a8dece5a2b45ecfa45c2", + "kind": "method", + "name": "run", + "qualified_name": "EchoPlugin::run", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 22, + "end_line": 22, + "start_column": 2, + "end_column": 16, + "docstring": null, + "signature": "(): void", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566858 + }, + { + "id": "class:89ed5f78db185c1660969f2db2ab2cbe", + "kind": "class", + "name": "FoxtrotPlugin", + "qualified_name": "FoxtrotPlugin", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 25, + "end_line": 27, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566858 + }, + { + "id": "method:bb25731e3868967797f386401650fa44", + "kind": "method", + "name": "run", + "qualified_name": "FoxtrotPlugin::run", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 26, + "end_line": 26, + "start_column": 2, + "end_column": 16, + "docstring": null, + "signature": "(): void", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566858 + }, + { + "id": "class:f806b484a796760aa338720a0c0dbe7b", + "kind": "class", + "name": "GolfPlugin", + "qualified_name": "GolfPlugin", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 29, + "end_line": 31, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566858 + }, + { + "id": "method:e9ee4a0bbb901384e474a9be0f4c49d3", + "kind": "method", + "name": "run", + "qualified_name": "GolfPlugin::run", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 30, + "end_line": 30, + "start_column": 2, + "end_column": 16, + "docstring": null, + "signature": "(): void", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566858 + }, + { + "id": "class:0a76787e18af0e2b81c72f1c428a8fea", + "kind": "class", + "name": "HotelPlugin", + "qualified_name": "HotelPlugin", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 33, + "end_line": 35, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566858 + }, + { + "id": "method:ea2d08af5cf73f2894f295b94aa35a26", + "kind": "method", + "name": "run", + "qualified_name": "HotelPlugin::run", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 34, + "end_line": 34, + "start_column": 2, + "end_column": 16, + "docstring": null, + "signature": "(): void", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566858 + }, + { + "id": "class:e6359bd18a3cb7320d1319af50f535a2", + "kind": "class", + "name": "IndiaPlugin", + "qualified_name": "IndiaPlugin", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 37, + "end_line": 39, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566858 + }, + { + "id": "method:1fba32ffbd7cf0e84dc823c295357350", + "kind": "method", + "name": "run", + "qualified_name": "IndiaPlugin::run", + "file_path": "src/plugins.ts", + "language": "typescript", + "start_line": 38, + "end_line": 38, + "start_column": 2, + "end_column": 16, + "docstring": null, + "signature": "(): void", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566858 + }, + { + "id": "file:src/shapes.ts", + "kind": "file", + "name": "shapes.ts", + "qualified_name": "src/shapes.ts", + "file_path": "src/shapes.ts", + "language": "typescript", + "start_line": 1, + "end_line": 23, + "start_column": 0, + "end_column": 0, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566854 + }, + { + "id": "interface:7746919dc58c40a700ae21b23d8ad894", + "kind": "interface", + "name": "Drawable", + "qualified_name": "Drawable", + "file_path": "src/shapes.ts", + "language": "typescript", + "start_line": 1, + "end_line": 3, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566855 + }, + { + "id": "class:6d292d761173eef3b57b2a51cba5b3a4", + "kind": "class", + "name": "Shape", + "qualified_name": "Shape", + "file_path": "src/shapes.ts", + "language": "typescript", + "start_line": 5, + "end_line": 12, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566856 + }, + { + "id": "method:6e3a80e4b71cb12e27bb21eff7695d05", + "kind": "method", + "name": "draw", + "qualified_name": "Shape::draw", + "file_path": "src/shapes.ts", + "language": "typescript", + "start_line": 6, + "end_line": 8, + "start_column": 2, + "end_column": 3, + "docstring": null, + "signature": "(): string", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + }, + { + "id": "method:0d64c7a97bbcbf475603542c885dcb4b", + "kind": "method", + "name": "area", + "qualified_name": "Shape::area", + "file_path": "src/shapes.ts", + "language": "typescript", + "start_line": 9, + "end_line": 11, + "start_column": 2, + "end_column": 3, + "docstring": null, + "signature": "(): number", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + }, + { + "id": "class:371f74cc2f4f938e604899d75e85044f", + "kind": "class", + "name": "Square", + "qualified_name": "Square", + "file_path": "src/shapes.ts", + "language": "typescript", + "start_line": 14, + "end_line": 18, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + }, + { + "id": "method:dbfa91be79344198a9e97eae97c0805d", + "kind": "method", + "name": "draw", + "qualified_name": "Square::draw", + "file_path": "src/shapes.ts", + "language": "typescript", + "start_line": 15, + "end_line": 17, + "start_column": 2, + "end_column": 3, + "docstring": null, + "signature": "(): string", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + }, + { + "id": "class:e270b067812dfee02be7f292a125efd9", + "kind": "class", + "name": "Tile", + "qualified_name": "Tile", + "file_path": "src/shapes.ts", + "language": "typescript", + "start_line": 20, + "end_line": 22, + "start_column": 7, + "end_column": 1, + "docstring": null, + "signature": null, + "visibility": null, + "is_exported": 1, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + }, + { + "id": "property:6785dc07a54c95b7ee75db2938456e6a", + "kind": "property", + "name": "label", + "qualified_name": "Tile::label", + "file_path": "src/shapes.ts", + "language": "typescript", + "start_line": 21, + "end_line": 21, + "start_column": 2, + "end_column": 16, + "docstring": null, + "signature": "label", + "visibility": null, + "is_exported": 0, + "is_async": 0, + "is_static": 0, + "is_abstract": 0, + "decorators": null, + "type_parameters": null, + "return_type": null, + "updated_at": 1788880566857 + } + ], + "edges": [ + { + "id": 37, + "source": "class:0724246c2fc012ade16fd11c08d9516d", + "target": "interface:1cc121d1a175c7b64084761db727ee93", + "kind": "implements", + "metadata": "{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Plugin\"}", + "line": 5, + "col": 36, + "provenance": null + }, + { + "id": 12, + "source": "class:0724246c2fc012ade16fd11c08d9516d", + "target": "method:1d1cce8e79085265a2774d0cdbc65492", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 44, + "source": "class:0a76787e18af0e2b81c72f1c428a8fea", + "target": "interface:1cc121d1a175c7b64084761db727ee93", + "kind": "implements", + "metadata": "{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Plugin\"}", + "line": 33, + "col": 36, + "provenance": null + }, + { + "id": 26, + "source": "class:0a76787e18af0e2b81c72f1c428a8fea", + "target": "method:ea2d08af5cf73f2894f295b94aa35a26", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 38, + "source": "class:0b06234dfa1966ab9be1af4b43e34ba9", + "target": "interface:1cc121d1a175c7b64084761db727ee93", + "kind": "implements", + "metadata": "{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Plugin\"}", + "line": 9, + "col": 36, + "provenance": null + }, + { + "id": 14, + "source": "class:0b06234dfa1966ab9be1af4b43e34ba9", + "target": "method:8f7887ab72b423b411246872ec1854c6", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 40, + "source": "class:24ed18b215be98dbadd36c0e4cee473d", + "target": "interface:1cc121d1a175c7b64084761db727ee93", + "kind": "implements", + "metadata": "{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Plugin\"}", + "line": 17, + "col": 36, + "provenance": null + }, + { + "id": 18, + "source": "class:24ed18b215be98dbadd36c0e4cee473d", + "target": "method:6a8b23cd8fd9380f295c304a93d42b70", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 47, + "source": "class:371f74cc2f4f938e604899d75e85044f", + "target": "class:6d292d761173eef3b57b2a51cba5b3a4", + "kind": "extends", + "metadata": "{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Shape\"}", + "line": 14, + "col": 28, + "provenance": null + }, + { + "id": 34, + "source": "class:371f74cc2f4f938e604899d75e85044f", + "target": "method:dbfa91be79344198a9e97eae97c0805d", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 46, + "source": "class:6d292d761173eef3b57b2a51cba5b3a4", + "target": "interface:7746919dc58c40a700ae21b23d8ad894", + "kind": "implements", + "metadata": "{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Drawable\"}", + "line": 5, + "col": 39, + "provenance": null + }, + { + "id": 32, + "source": "class:6d292d761173eef3b57b2a51cba5b3a4", + "target": "method:0d64c7a97bbcbf475603542c885dcb4b", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 31, + "source": "class:6d292d761173eef3b57b2a51cba5b3a4", + "target": "method:6e3a80e4b71cb12e27bb21eff7695d05", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 42, + "source": "class:89ed5f78db185c1660969f2db2ab2cbe", + "target": "interface:1cc121d1a175c7b64084761db727ee93", + "kind": "implements", + "metadata": "{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Plugin\"}", + "line": 25, + "col": 38, + "provenance": null + }, + { + "id": 22, + "source": "class:89ed5f78db185c1660969f2db2ab2cbe", + "target": "method:bb25731e3868967797f386401650fa44", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 41, + "source": "class:8eef1358668f6b164292dab5eb21a2a9", + "target": "interface:1cc121d1a175c7b64084761db727ee93", + "kind": "implements", + "metadata": "{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Plugin\"}", + "line": 21, + "col": 35, + "provenance": null + }, + { + "id": 20, + "source": "class:8eef1358668f6b164292dab5eb21a2a9", + "target": "method:e3abfb1ca720a8dece5a2b45ecfa45c2", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 39, + "source": "class:9c29d0bbf2a0de3df485a2ef357d8ccc", + "target": "interface:1cc121d1a175c7b64084761db727ee93", + "kind": "implements", + "metadata": "{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Plugin\"}", + "line": 13, + "col": 38, + "provenance": null + }, + { + "id": 16, + "source": "class:9c29d0bbf2a0de3df485a2ef357d8ccc", + "target": "method:e0255cc8ef774fbcc55cfbe41bc6d807", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 48, + "source": "class:e270b067812dfee02be7f292a125efd9", + "target": "class:371f74cc2f4f938e604899d75e85044f", + "kind": "extends", + "metadata": "{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Square\"}", + "line": 20, + "col": 26, + "provenance": null + }, + { + "id": 36, + "source": "class:e270b067812dfee02be7f292a125efd9", + "target": "property:6785dc07a54c95b7ee75db2938456e6a", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 45, + "source": "class:e6359bd18a3cb7320d1319af50f535a2", + "target": "interface:1cc121d1a175c7b64084761db727ee93", + "kind": "implements", + "metadata": "{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Plugin\"}", + "line": 37, + "col": 36, + "provenance": null + }, + { + "id": 28, + "source": "class:e6359bd18a3cb7320d1319af50f535a2", + "target": "method:1fba32ffbd7cf0e84dc823c295357350", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 43, + "source": "class:f806b484a796760aa338720a0c0dbe7b", + "target": "interface:1cc121d1a175c7b64084761db727ee93", + "kind": "implements", + "metadata": "{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Plugin\"}", + "line": 29, + "col": 35, + "provenance": null + }, + { + "id": 24, + "source": "class:f806b484a796760aa338720a0c0dbe7b", + "target": "method:e9ee4a0bbb901384e474a9be0f4c49d3", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 1, + "source": "file:src/clock.go", + "target": "import:6efab0afebb09a263e373416641f8c1f", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 2, + "source": "file:src/clock.go", + "target": "interface:51774c4756182e9feb0479676126a6fd", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 5, + "source": "file:src/clock.go", + "target": "method:9af4c9ba003be80faaac09eecd0ae295", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 8, + "source": "file:src/clock.go", + "target": "method:e8e483d6fdd52d5da2cc49b56a0c9dc6", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 4, + "source": "file:src/clock.go", + "target": "struct:67d2cf6e28291ecfbb5027925a2eb283", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 7, + "source": "file:src/clock.go", + "target": "struct:7b16c615e24f11b54bcf6e340818171c", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 11, + "source": "file:src/plugins.ts", + "target": "class:0724246c2fc012ade16fd11c08d9516d", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 25, + "source": "file:src/plugins.ts", + "target": "class:0a76787e18af0e2b81c72f1c428a8fea", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 13, + "source": "file:src/plugins.ts", + "target": "class:0b06234dfa1966ab9be1af4b43e34ba9", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 17, + "source": "file:src/plugins.ts", + "target": "class:24ed18b215be98dbadd36c0e4cee473d", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 21, + "source": "file:src/plugins.ts", + "target": "class:89ed5f78db185c1660969f2db2ab2cbe", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 19, + "source": "file:src/plugins.ts", + "target": "class:8eef1358668f6b164292dab5eb21a2a9", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 15, + "source": "file:src/plugins.ts", + "target": "class:9c29d0bbf2a0de3df485a2ef357d8ccc", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 27, + "source": "file:src/plugins.ts", + "target": "class:e6359bd18a3cb7320d1319af50f535a2", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 23, + "source": "file:src/plugins.ts", + "target": "class:f806b484a796760aa338720a0c0dbe7b", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 10, + "source": "file:src/plugins.ts", + "target": "interface:1cc121d1a175c7b64084761db727ee93", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 33, + "source": "file:src/shapes.ts", + "target": "class:371f74cc2f4f938e604899d75e85044f", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 30, + "source": "file:src/shapes.ts", + "target": "class:6d292d761173eef3b57b2a51cba5b3a4", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 35, + "source": "file:src/shapes.ts", + "target": "class:e270b067812dfee02be7f292a125efd9", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 29, + "source": "file:src/shapes.ts", + "target": "interface:7746919dc58c40a700ae21b23d8ad894", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 3, + "source": "interface:51774c4756182e9feb0479676126a6fd", + "target": "method:0416990a5a93fdf2e18b5dfb7399c930", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 52, + "source": "method:0416990a5a93fdf2e18b5dfb7399c930", + "target": "method:9af4c9ba003be80faaac09eecd0ae295", + "kind": "calls", + "metadata": "{\"synthesizedBy\":\"interface-impl\",\"via\":\"Now\",\"registeredAt\":\"src/clock.go:13\"}", + "line": 7, + "col": null, + "provenance": "heuristic" + }, + { + "id": 53, + "source": "method:0416990a5a93fdf2e18b5dfb7399c930", + "target": "method:e8e483d6fdd52d5da2cc49b56a0c9dc6", + "kind": "calls", + "metadata": "{\"synthesizedBy\":\"interface-impl\",\"via\":\"Now\",\"registeredAt\":\"src/clock.go:18\"}", + "line": 7, + "col": null, + "provenance": "heuristic" + }, + { + "id": 51, + "source": "method:6e3a80e4b71cb12e27bb21eff7695d05", + "target": "method:dbfa91be79344198a9e97eae97c0805d", + "kind": "calls", + "metadata": "{\"synthesizedBy\":\"interface-impl\",\"via\":\"draw\",\"registeredAt\":\"src/shapes.ts:15\"}", + "line": 6, + "col": null, + "provenance": "heuristic" + }, + { + "id": 49, + "source": "struct:67d2cf6e28291ecfbb5027925a2eb283", + "target": "interface:51774c4756182e9feb0479676126a6fd", + "kind": "implements", + "metadata": "{\"synthesizedBy\":\"go-implements\",\"via\":\"Clock\",\"registeredAt\":\"src/clock.go:11\"}", + "line": 11, + "col": null, + "provenance": "heuristic" + }, + { + "id": 6, + "source": "struct:67d2cf6e28291ecfbb5027925a2eb283", + "target": "method:9af4c9ba003be80faaac09eecd0ae295", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + }, + { + "id": 50, + "source": "struct:7b16c615e24f11b54bcf6e340818171c", + "target": "interface:51774c4756182e9feb0479676126a6fd", + "kind": "implements", + "metadata": "{\"synthesizedBy\":\"go-implements\",\"via\":\"Clock\",\"registeredAt\":\"src/clock.go:16\"}", + "line": 16, + "col": null, + "provenance": "heuristic" + }, + { + "id": 9, + "source": "struct:7b16c615e24f11b54bcf6e340818171c", + "target": "method:e8e483d6fdd52d5da2cc49b56a0c9dc6", + "kind": "contains", + "metadata": null, + "line": null, + "col": null, + "provenance": null + } + ], + "files": [ + { + "path": "src/clock.go", + "content_hash": "96137f6407c45c31afe9a498b3d333f8cfc63ea33e1b6a704f57f184fa2748a3", + "language": "go", + "size": 326, + "modified_at": 1788880566489.0725, + "indexed_at": 1788880566858, + "node_count": 8, + "errors": null, + "generated": 0 + }, + { + "path": "src/plugins.ts", + "content_hash": "e36b5726c5596007b9cb678adb54d807426dd5b5de2d6153c8a75d6aab6647b0", + "language": "typescript", + "size": 630, + "modified_at": 1788880566485.9392, + "indexed_at": 1788880566859, + "node_count": 20, + "errors": null, + "generated": 0 + }, + { + "path": "src/shapes.ts", + "content_hash": "f9354f8bfd3e6068f91aae9abfa83e6c3a68537db84c3a8f34a9369777ad282b", + "language": "typescript", + "size": 320, + "modified_at": 1788880566482.6628, + "indexed_at": 1788880566859, + "node_count": 9, + "errors": null, + "generated": 0 + } + ], + "unresolvedRefs": [ + { + "id": 1, + "from_node_id": "file:src/clock.go", + "reference_name": "time", + "reference_kind": "imports", + "line": 3, + "col": 7, + "candidates": null, + "file_path": "src/clock.go", + "language": "go", + "status": "failed", + "name_tail": "time" + }, + { + "id": 2, + "from_node_id": "method:9af4c9ba003be80faaac09eecd0ae295", + "reference_name": "Time", + "reference_kind": "references", + "line": 13, + "col": 25, + "candidates": null, + "file_path": "src/clock.go", + "language": "go", + "status": "failed", + "name_tail": "Time" + }, + { + "id": 3, + "from_node_id": "method:9af4c9ba003be80faaac09eecd0ae295", + "reference_name": "UTC", + "reference_kind": "calls", + "line": 13, + "col": 39, + "candidates": null, + "file_path": "src/clock.go", + "language": "go", + "status": "failed", + "name_tail": "UTC" + }, + { + "id": 4, + "from_node_id": "method:9af4c9ba003be80faaac09eecd0ae295", + "reference_name": "time.Now", + "reference_kind": "calls", + "line": 13, + "col": 39, + "candidates": null, + "file_path": "src/clock.go", + "language": "go", + "status": "failed", + "name_tail": "Now" + }, + { + "id": 5, + "from_node_id": "method:e8e483d6fdd52d5da2cc49b56a0c9dc6", + "reference_name": "Time", + "reference_kind": "references", + "line": 18, + "col": 26, + "candidates": null, + "file_path": "src/clock.go", + "language": "go", + "status": "failed", + "name_tail": "Time" + } + ], + "projectMetadata": [ + { + "key": "index_files_accounted", + "value": "3", + "updated_at": 1788880566892 + }, + { + "key": "index_files_discovered", + "value": "3", + "updated_at": 1788880566892 + }, + { + "key": "index_state", + "value": "complete", + "updated_at": 1788880566892 + }, + { + "key": "indexed_with_extraction_version", + "value": "26", + "updated_at": 1788880566892 + }, + { + "key": "indexed_with_version", + "value": "1.6.0", + "updated_at": 1788880566892 + } + ], + "schemaVersions": [ + { + "version": 1, + "applied_at": 1788880566000, + "description": "Initial schema" + }, + { + "version": 9, + "applied_at": 1788880566513, + "description": "Initial schema includes all migrations" + } + ], + "unknownLanguageNodes": [] +} diff --git a/test/fixtures/codegraph/type-hierarchy-source/go.mod b/test/fixtures/codegraph/type-hierarchy-source/go.mod new file mode 100644 index 00000000..177037d4 --- /dev/null +++ b/test/fixtures/codegraph/type-hierarchy-source/go.mod @@ -0,0 +1,3 @@ +module fixture + +go 1.22 diff --git a/test/fixtures/codegraph/type-hierarchy-source/src/clock.go b/test/fixtures/codegraph/type-hierarchy-source/src/clock.go new file mode 100644 index 00000000..17cfe54f --- /dev/null +++ b/test/fixtures/codegraph/type-hierarchy-source/src/clock.go @@ -0,0 +1,18 @@ +package clock + +import "time" + +// Clock is the time seam. +type Clock interface { + Now() time.Time +} + +// System is the production clock. +type System struct{} + +func (System) Now() time.Time { return time.Now().UTC() } + +// Fixed is a frozen clock. +type Fixed struct{ At time.Time } + +func (f Fixed) Now() time.Time { return f.At } diff --git a/test/fixtures/codegraph/type-hierarchy-source/src/plugins.ts b/test/fixtures/codegraph/type-hierarchy-source/src/plugins.ts new file mode 100644 index 00000000..f74b60e9 --- /dev/null +++ b/test/fixtures/codegraph/type-hierarchy-source/src/plugins.ts @@ -0,0 +1,39 @@ +export interface Plugin { + run(): void; +} + +export class AlphaPlugin implements Plugin { + run(): void {} +} + +export class BravoPlugin implements Plugin { + run(): void {} +} + +export class CharliePlugin implements Plugin { + run(): void {} +} + +export class DeltaPlugin implements Plugin { + run(): void {} +} + +export class EchoPlugin implements Plugin { + run(): void {} +} + +export class FoxtrotPlugin implements Plugin { + run(): void {} +} + +export class GolfPlugin implements Plugin { + run(): void {} +} + +export class HotelPlugin implements Plugin { + run(): void {} +} + +export class IndiaPlugin implements Plugin { + run(): void {} +} diff --git a/test/fixtures/codegraph/type-hierarchy-source/src/shapes.ts b/test/fixtures/codegraph/type-hierarchy-source/src/shapes.ts new file mode 100644 index 00000000..b5b2556e --- /dev/null +++ b/test/fixtures/codegraph/type-hierarchy-source/src/shapes.ts @@ -0,0 +1,22 @@ +export interface Drawable { + draw(): string; +} + +export abstract class Shape implements Drawable { + draw(): string { + return 'shape'; + } + area(): number { + return 0; + } +} + +export class Square extends Shape { + draw(): string { + return 'square'; + } +} + +export class Tile extends Square { + label = 'tile'; +} From 4a86f0a4206e5d5864c701f420a4366365344eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Thu, 24 Sep 2026 08:53:30 +0200 Subject: [PATCH 2/7] feat(graph): add partial type hierarchy query and service Co-Authored-By: Claude Opus 5.5 --- internal/graphprotocol/type_hierarchy.go | 81 +++ internal/graphquery/type_hierarchy.go | 521 +++++++++++++++++++ internal/graphquery/type_hierarchy_test.go | 287 ++++++++++ internal/graphservice/type_hierarchy.go | 32 ++ internal/graphservice/type_hierarchy_test.go | 54 ++ 5 files changed, 975 insertions(+) create mode 100644 internal/graphprotocol/type_hierarchy.go create mode 100644 internal/graphquery/type_hierarchy.go create mode 100644 internal/graphquery/type_hierarchy_test.go create mode 100644 internal/graphservice/type_hierarchy.go create mode 100644 internal/graphservice/type_hierarchy_test.go diff --git a/internal/graphprotocol/type_hierarchy.go b/internal/graphprotocol/type_hierarchy.go new file mode 100644 index 00000000..189e10e7 --- /dev/null +++ b/internal/graphprotocol/type_hierarchy.go @@ -0,0 +1,81 @@ +package graphprotocol + +const ( + TypeKnowledgeRecorded = "recorded" + TypeKnowledgeUnknown = "unknown" +) + +type TypeRelationsRequest struct { + Scope Scope `json:"scope"` + Occurrence string `json:"occurrence"` +} + +// RelatedEntities groups repeated edge occurrences without losing evidence. +type RelatedEntities struct { + Entity Entity `json:"entity"` + Relation string `json:"relation"` + Edges []Evidence `json:"edges"` +} + +type TypeRelationsResponse struct { + Status string `json:"status"` + TypeKnowledge string `json:"type_knowledge"` + Types []RelatedEntities `json:"types,omitempty"` + Users []RelatedEntities `json:"users,omitempty"` + Returners []RelatedEntities `json:"returners,omitempty"` + ReferencedTypes []RelatedEntities `json:"referenced_types,omitempty"` + RecordedOverrides []RelatedEntities `json:"recorded_overrides,omitempty"` + Analysis *AnalysisState `json:"analysis,omitempty"` + Generations []Generation `json:"generations"` + Boundaries []Boundary `json:"boundaries,omitempty"` + Partial bool `json:"partial"` +} + +type TypeHierarchyRequest struct { + Scope Scope `json:"scope"` + Occurrence string `json:"occurrence"` +} + +type HierarchyEntry struct { + Entity Entity `json:"entity"` + Depth int `json:"depth"` + ParentID string `json:"parent_id"` + Relation string `json:"relation"` + Edge Evidence `json:"edge"` + Synthesized bool `json:"synthesized"` + Via string `json:"via,omitempty"` + RegisteredAt string `json:"registered_at,omitempty"` + HiddenSubtypes int `json:"hidden_subtypes"` +} + +// HierarchyRows keeps every bounded query row while stating the viewer cap. +type HierarchyRows struct { + Items []HierarchyEntry `json:"items"` + Total int `json:"total"` + Shown int `json:"shown"` + Truncated bool `json:"truncated"` +} + +type DerivedOverride struct { + Member Entity `json:"member"` + BaseMember Entity `json:"base_member"` + BaseType Entity `json:"base_type"` + Relation string `json:"relation"` + SignatureUncertain bool `json:"signature_uncertain"` +} + +type TypeHierarchyResponse struct { + Status string `json:"status"` + Focus Entity `json:"focus"` + Ancestors HierarchyRows `json:"ancestors"` + Descendants HierarchyRows `json:"descendants"` + DirectSubtypes int `json:"direct_subtypes"` + DirectImplementers int `json:"direct_implementers"` + Bounded bool `json:"bounded"` + Polymorphic bool `json:"polymorphic"` + DerivedOverrides []DerivedOverride `json:"derived_overrides,omitempty"` + Analysis *AnalysisState `json:"analysis,omitempty"` + Generations []Generation `json:"generations"` + Boundaries []Boundary `json:"boundaries,omitempty"` + Partial bool `json:"partial"` +} diff --git a/internal/graphquery/type_hierarchy.go b/internal/graphquery/type_hierarchy.go new file mode 100644 index 00000000..3fbb863c --- /dev/null +++ b/internal/graphquery/type_hierarchy.go @@ -0,0 +1,521 @@ +package graphquery + +import ( + "context" + "encoding/json" + "slices" + "sort" + "strings" + + "github.com/balcsida/graphnest/internal/graphartifact" + "github.com/balcsida/graphnest/internal/graphprotocol" +) + +const ( + maxAncestorDepth = 8 + maxDescendantDepth = 6 + maxHierarchyRows = 400 + maxOverrideAncestors = 12 + maxShownAncestors = 24 + maxShownDescendants = 240 + dispatchMinImplementers = 8 +) + +var hierarchyRelations = []string{"extends", "implements"} + +var hierarchyKinds = map[string]bool{ + "class": true, "interface": true, "struct": true, "trait": true, + "protocol": true, "enum": true, "type_alias": true, "union": true, +} + +var overridableKinds = map[string]bool{"method": true, "function": true, "property": true, "field": true} + +type HierarchyCountQuery struct { + Snapshot QuerySnapshot + Occurrence string +} + +type HierarchyCount struct { + Subtypes, Implementers int +} + +type HierarchyStore interface { + AnalysisStore + CountHierarchyChildren(context.Context, HierarchyCountQuery) (HierarchyCount, error) +} + +func (service *Service) TypeRelations(ctx context.Context, request graphprotocol.TypeRelationsRequest) (graphprotocol.TypeRelationsResponse, error) { + if service == nil { + return graphprotocol.TypeRelationsResponse{}, ErrInvalidRequest + } + ctx, cancel := service.entityContext(ctx) + defer cancel() + ready, _, err := service.analysisReady(ctx, request.Scope) + if err != nil { + return graphprotocol.TypeRelationsResponse{}, err + } + root, status, err := analysisRoot(ctx, ready, request.Occurrence) + if err != nil { + return graphprotocol.TypeRelationsResponse{}, err + } + result := graphprotocol.TypeRelationsResponse{Status: status, TypeKnowledge: graphprotocol.TypeKnowledgeUnknown} + if status == graphprotocol.StatusOK { + limits := service.limits() + load := func(relations []string, direction string) ([]EntityNeighbor, bool, error) { + return analysisNeighbors(ctx, ready, root, relations, direction, limits.MaxEdges) + } + outgoing, partial, err := load([]string{"type_of", "returns"}, "outgoing") + if err != nil { + return graphprotocol.TypeRelationsResponse{}, err + } + result.Types = groupRelated(outgoing, nil) + if len(result.Types) > 0 { + result.TypeKnowledge = graphprotocol.TypeKnowledgeRecorded + } + incomingTypes, incomingPartial, err := load([]string{"type_of"}, "incoming") + if err != nil { + return graphprotocol.TypeRelationsResponse{}, err + } + result.Users = groupRelated(incomingTypes, nil) + incomingReturns, returnsPartial, err := load([]string{"returns"}, "incoming") + if err != nil { + return graphprotocol.TypeRelationsResponse{}, err + } + result.Returners = groupRelated(incomingReturns, nil) + references, referencesPartial, err := load([]string{"references"}, "outgoing") + if err != nil { + return graphprotocol.TypeRelationsResponse{}, err + } + result.ReferencedTypes = groupRelated(references, func(entity graphprotocol.Entity) bool { + return entity.Fact != nil && hierarchyKinds[entity.Fact.Kind] + }) + overrides, overridesPartial, err := load([]string{"overrides"}, "outgoing") + if err != nil { + return graphprotocol.TypeRelationsResponse{}, err + } + result.RecordedOverrides = groupRelated(overrides, nil) + result.Partial = partial || incomingPartial || returnsPartial || referencesPartial || overridesPartial + if result.Partial { + result.Boundaries = appendBoundary(result.Boundaries, "edge_limit", 1) + } + } + return finishTypeRelations(ctx, ready, result) +} + +func groupRelated(rows []EntityNeighbor, keep func(graphprotocol.Entity) bool) []graphprotocol.RelatedEntities { + result := []graphprotocol.RelatedEntities{} + indexes := map[string]int{} + for _, row := range rows { + if keep != nil && !keep(row.Entity) { + continue + } + relation := "" + if row.Edge.Fact != nil { + relation = row.Edge.Fact.Kind.String() + if relationValue, ok := relationshipName(row.Edge.Fact.Kind); ok { + relation = relationValue + } + } + key := relation + "\x00" + row.Entity.ID + index, exists := indexes[key] + if !exists { + index = len(result) + indexes[key] = index + result = append(result, graphprotocol.RelatedEntities{Entity: row.Entity, Relation: relation}) + } + result[index].Edges = append(result[index].Edges, row.Edge) + } + return result +} + +func relationshipName(kind interface{ String() string }) (string, bool) { + for _, relation := range graphartifact.Relationships() { + if relation.WireKind().String() == kind.String() { + return relation.Name, true + } + } + return "", false +} + +func finishTypeRelations(ctx context.Context, ready entityReady, result graphprotocol.TypeRelationsResponse) (graphprotocol.TypeRelationsResponse, error) { + for _, groups := range [][]graphprotocol.RelatedEntities{result.Types, result.Users, result.Returners, result.ReferencedTypes, result.RecordedOverrides} { + for i := range groups { + if err := publicRelated(ready, &groups[i]); err != nil { + return graphprotocol.TypeRelationsResponse{}, err + } + } + } + if err := ready.current(ctx); err != nil { + return graphprotocol.TypeRelationsResponse{}, err + } + result.Generations = ready.publicGenerations() + result.Analysis = analysisState(ready) + if err := entityResponseSize(result); err != nil { + return graphprotocol.TypeRelationsResponse{}, err + } + return result, nil +} + +func publicRelated(ready entityReady, group *graphprotocol.RelatedEntities) error { + entities := []graphprotocol.Entity{group.Entity} + if err := ready.publicEntities(entities); err != nil { + return err + } + group.Entity = entities[0] + for i := range group.Edges { + id := ready.publicIDs[group.Edges[i].RepositoryID] + if id == 0 { + return ErrGenerationChanged + } + group.Edges[i].RepositoryID = id + } + return nil +} + +func (service *Service) TypeHierarchy(ctx context.Context, request graphprotocol.TypeHierarchyRequest) (graphprotocol.TypeHierarchyResponse, error) { + if service == nil { + return graphprotocol.TypeHierarchyResponse{}, ErrInvalidRequest + } + ctx, cancel := service.entityContext(ctx) + defer cancel() + ready, analysisStore, err := service.analysisReady(ctx, request.Scope) + if err != nil { + return graphprotocol.TypeHierarchyResponse{}, err + } + store, ok := analysisStore.(HierarchyStore) + if !ok { + return graphprotocol.TypeHierarchyResponse{}, ErrInvalidRequest + } + root, status, err := analysisRoot(ctx, ready, request.Occurrence) + if err != nil { + return graphprotocol.TypeHierarchyResponse{}, err + } + result := graphprotocol.TypeHierarchyResponse{Status: status} + if status == graphprotocol.StatusNotFound { + return finishTypeHierarchy(ctx, ready, result) + } + result.Focus = root + if root.Fact == nil || !hierarchyKinds[root.Fact.Kind] { + return finishTypeHierarchy(ctx, ready, result) + } + result.Ancestors.Items, result.Partial, err = walkHierarchyAncestors(ctx, ready, root) + if err != nil { + return graphprotocol.TypeHierarchyResponse{}, err + } + if result.Partial { + result.Boundaries = appendBoundary(result.Boundaries, "hierarchy_limit", maxAncestorDepth) + } + var descendantPartial bool + result.Descendants.Items, result.DirectSubtypes, result.DirectImplementers, result.Bounded, descendantPartial, err = walkHierarchyDescendants(ctx, ready, store, root) + if err != nil { + return graphprotocol.TypeHierarchyResponse{}, err + } + if descendantPartial { + result.Partial = true + result.Boundaries = appendBoundary(result.Boundaries, "hierarchy_limit", maxDescendantDepth) + } + result.Polymorphic = result.DirectImplementers >= dispatchMinImplementers + result.DerivedOverrides, err = matchDerivedOverrides(ctx, ready, root, result.Ancestors.Items) + if err != nil { + return graphprotocol.TypeHierarchyResponse{}, err + } + result.Ancestors = hierarchyRows(result.Ancestors.Items, maxShownAncestors) + result.Descendants = hierarchyRows(result.Descendants.Items, maxShownDescendants) + return finishTypeHierarchy(ctx, ready, result) +} + +func walkHierarchyAncestors(ctx context.Context, ready entityReady, root graphprotocol.Entity) ([]graphprotocol.HierarchyEntry, bool, error) { + result := []graphprotocol.HierarchyEntry{} + seen := map[string]bool{root.Fact.Occurrence: true} + frontier := []graphprotocol.Entity{root} + partial := false + for depth := 1; depth <= maxAncestorDepth && len(frontier) > 0; depth++ { + level := []graphprotocol.HierarchyEntry{} + for _, parent := range frontier { + rows, bounded, err := hierarchyNeighbors(ctx, ready, parent, "outgoing") + if err != nil { + return nil, false, err + } + partial = partial || bounded + for _, row := range rows { + level = append(level, hierarchyEntry(row, parent.ID, depth)) + } + } + sortHierarchy(level) + frontier = nil + for _, entry := range level { + occurrence := entry.Entity.Fact.Occurrence + if seen[occurrence] { + continue + } + seen[occurrence] = true + result = append(result, entry) + frontier = append(frontier, entry.Entity) + } + if depth == maxAncestorDepth && len(frontier) > 0 { + for _, parent := range frontier { + rows, bounded, err := hierarchyNeighbors(ctx, ready, parent, "outgoing") + if err != nil { + return nil, false, err + } + partial = partial || bounded + for _, row := range rows { + if !seen[row.Entity.Fact.Occurrence] { + partial = true + break + } + } + } + } + } + return result, partial, nil +} + +func walkHierarchyDescendants(ctx context.Context, ready entityReady, store HierarchyStore, root graphprotocol.Entity) ([]graphprotocol.HierarchyEntry, int, int, bool, bool, error) { + count, err := store.CountHierarchyChildren(ctx, HierarchyCountQuery{Snapshot: ready.selected[0], Occurrence: root.Fact.Occurrence}) + if err != nil { + return nil, 0, 0, false, false, err + } + if count.Subtypes < 0 || count.Implementers < 0 || count.Implementers > count.Subtypes { + return nil, 0, 0, false, false, ErrGenerationChanged + } + result := []graphprotocol.HierarchyEntry{} + indexes := map[string]int{} + seen := map[string]bool{root.Fact.Occurrence: true} + frontier := []graphprotocol.Entity{root} + bounded, partial := false, false + for depth := 1; depth <= maxDescendantDepth && len(frontier) > 0; depth++ { + level := []graphprotocol.HierarchyEntry{} + for _, parent := range frontier { + rows, queryBounded, queryErr := hierarchyNeighbors(ctx, ready, parent, "incoming") + if queryErr != nil { + return nil, 0, 0, false, false, queryErr + } + if queryBounded { + bounded, partial = true, true + } + for _, row := range rows { + level = append(level, hierarchyEntry(row, parent.ID, depth)) + } + } + sortHierarchy(level) + frontier = nil + levelSeen := map[string]bool{} + for _, entry := range level { + occurrence := entry.Entity.Fact.Occurrence + if seen[occurrence] || levelSeen[occurrence] { + continue + } + levelSeen[occurrence] = true + if len(result) >= maxHierarchyRows { + bounded, partial = true, true + if index, exists := indexes[entry.ParentID]; exists { + result[index].HiddenSubtypes++ + } + continue + } + seen[occurrence] = true + indexes[entry.Entity.ID] = len(result) + result = append(result, entry) + frontier = append(frontier, entry.Entity) + } + if bounded && len(result) >= maxHierarchyRows { + break + } + if depth == maxDescendantDepth && len(frontier) > 0 { + for _, parent := range frontier { + children, countErr := store.CountHierarchyChildren(ctx, HierarchyCountQuery{Snapshot: ready.selected[0], Occurrence: parent.Fact.Occurrence}) + if countErr != nil { + return nil, 0, 0, false, false, countErr + } + if children.Subtypes > 0 { + bounded, partial = true, true + if index, exists := indexes[parent.ID]; exists { + result[index].HiddenSubtypes = children.Subtypes + } + } + } + } + } + if count.Subtypes > len(result) && len(result) >= maxHierarchyRows { + bounded, partial = true, true + } + return result, count.Subtypes, count.Implementers, bounded, partial, nil +} + +func hierarchyNeighbors(ctx context.Context, ready entityReady, parent graphprotocol.Entity, direction string) ([]EntityNeighbor, bool, error) { + rows := []EntityNeighbor{} + partial := false + for _, relation := range hierarchyRelations { + remaining := maxHierarchyRows - len(rows) + if remaining <= 0 { + partial = true + break + } + found, err := ready.store.EntityNeighbors(ctx, EntityNeighborQuery{Snapshot: ready.selected[0], Occurrence: parent.Fact.Occurrence, Relation: relation, Direction: direction, Limit: remaining + 1}) + if err != nil { + return nil, false, err + } + if len(found) > remaining { + found, partial = found[:remaining], true + } + for _, row := range found { + if row.Entity.RepositoryID != parent.RepositoryID || row.Entity.Fact == nil || row.Edge.RepositoryID != parent.RepositoryID || row.Edge.Fact == nil { + return nil, false, ErrGenerationChanged + } + } + rows = append(rows, found...) + } + return rows, partial, nil +} + +func hierarchyEntry(row EntityNeighbor, parentID string, depth int) graphprotocol.HierarchyEntry { + relation, _ := relationshipName(row.Edge.Fact.Kind) + entry := graphprotocol.HierarchyEntry{Entity: row.Entity, Depth: depth, ParentID: parentID, Relation: relation, Edge: row.Edge, Synthesized: row.Edge.Fact.GetProvenance() == "heuristic"} + for _, extension := range row.Edge.Fact.Extensions { + if extension.Namespace != "codegraph.edge-metadata" { + continue + } + var metadata struct { + SynthesizedBy string `json:"synthesizedBy"` + Via string `json:"via"` + RegisteredAt string `json:"registeredAt"` + } + if json.Unmarshal(extension.Json, &metadata) == nil { + entry.Via = metadata.SynthesizedBy + if entry.Via == "" { + entry.Via = metadata.Via + } + entry.RegisteredAt = metadata.RegisteredAt + } + break + } + return entry +} + +func sortHierarchy(entries []graphprotocol.HierarchyEntry) { + sort.SliceStable(entries, func(i, j int) bool { + a, b := entries[i], entries[j] + if a.Relation != b.Relation { + return a.Relation == "extends" + } + if a.Entity.Fact.Name != b.Entity.Fact.Name { + return a.Entity.Fact.Name < b.Entity.Fact.Name + } + if a.Entity.Fact.GetPath() != b.Entity.Fact.GetPath() { + return a.Entity.Fact.GetPath() < b.Entity.Fact.GetPath() + } + if a.Entity.Fact.GetLocation().GetStart().GetLine() != b.Entity.Fact.GetLocation().GetStart().GetLine() { + return a.Entity.Fact.GetLocation().GetStart().GetLine() < b.Entity.Fact.GetLocation().GetStart().GetLine() + } + return a.Entity.Fact.Occurrence < b.Entity.Fact.Occurrence + }) +} + +func matchDerivedOverrides(ctx context.Context, ready entityReady, focus graphprotocol.Entity, ancestors []graphprotocol.HierarchyEntry) ([]graphprotocol.DerivedOverride, error) { + if len(ancestors) == 0 { + return nil, nil + } + own, _, err := containedMembers(ctx, ready, focus) + if err != nil || len(own) == 0 { + return nil, err + } + type base struct { + member graphprotocol.Entity + owner graphprotocol.HierarchyEntry + } + byName := map[string]base{} + for _, ancestor := range ancestors[:min(len(ancestors), maxOverrideAncestors)] { + members, _, memberErr := containedMembers(ctx, ready, ancestor.Entity) + if memberErr != nil { + return nil, memberErr + } + for _, member := range members { + if _, exists := byName[member.Fact.Name]; !exists { + byName[member.Fact.Name] = base{member: member, owner: ancestor} + } + } + } + result := []graphprotocol.DerivedOverride{} + for _, member := range own { + if !overridableKinds[member.Fact.Kind] { + continue + } + match, exists := byName[member.Fact.Name] + if !exists || match.member.ID == member.ID { + continue + } + result = append(result, graphprotocol.DerivedOverride{Member: member, BaseMember: match.member, BaseType: match.owner.Entity, Relation: match.owner.Relation, SignatureUncertain: true}) + } + return result, nil +} + +func containedMembers(ctx context.Context, ready entityReady, container graphprotocol.Entity) ([]graphprotocol.Entity, bool, error) { + found, err := ready.store.EntityNeighbors(ctx, EntityNeighborQuery{Snapshot: ready.selected[0], Occurrence: container.Fact.Occurrence, Relation: "contains", Direction: "outgoing", Limit: maxHierarchyRows + 1}) + if err != nil { + return nil, false, err + } + partial := len(found) > maxHierarchyRows + if partial { + found = found[:maxHierarchyRows] + } + result := make([]graphprotocol.Entity, len(found)) + for i, row := range found { + if row.Entity.RepositoryID != container.RepositoryID || row.Entity.Fact == nil || row.Edge.Fact == nil { + return nil, false, ErrGenerationChanged + } + result[i] = row.Entity + } + slices.SortFunc(result, func(a, b graphprotocol.Entity) int { + if a.Fact.GetLocation().GetStart().GetLine() != b.Fact.GetLocation().GetStart().GetLine() { + return int(a.Fact.GetLocation().GetStart().GetLine() - b.Fact.GetLocation().GetStart().GetLine()) + } + return strings.Compare(a.Fact.Occurrence, b.Fact.Occurrence) + }) + return result, partial, nil +} + +func hierarchyRows(items []graphprotocol.HierarchyEntry, displayLimit int) graphprotocol.HierarchyRows { + return graphprotocol.HierarchyRows{Items: items, Total: len(items), Shown: min(len(items), displayLimit), Truncated: len(items) > displayLimit} +} + +func finishTypeHierarchy(ctx context.Context, ready entityReady, result graphprotocol.TypeHierarchyResponse) (graphprotocol.TypeHierarchyResponse, error) { + entities := []*graphprotocol.Entity{&result.Focus} + for i := range result.Ancestors.Items { + entities = append(entities, &result.Ancestors.Items[i].Entity) + } + for i := range result.Descendants.Items { + entities = append(entities, &result.Descendants.Items[i].Entity) + } + for i := range result.DerivedOverrides { + entities = append(entities, &result.DerivedOverrides[i].Member, &result.DerivedOverrides[i].BaseMember, &result.DerivedOverrides[i].BaseType) + } + for _, entity := range entities { + if entity.Fact == nil { + continue + } + values := []graphprotocol.Entity{*entity} + if err := ready.publicEntities(values); err != nil { + return graphprotocol.TypeHierarchyResponse{}, err + } + *entity = values[0] + } + for _, entries := range [][]graphprotocol.HierarchyEntry{result.Ancestors.Items, result.Descendants.Items} { + for i := range entries { + id := ready.publicIDs[entries[i].Edge.RepositoryID] + if id == 0 { + return graphprotocol.TypeHierarchyResponse{}, ErrGenerationChanged + } + entries[i].Edge.RepositoryID = id + } + } + if err := ready.current(ctx); err != nil { + return graphprotocol.TypeHierarchyResponse{}, err + } + result.Generations = ready.publicGenerations() + result.Analysis = analysisState(ready) + if err := entityResponseSize(result); err != nil { + return graphprotocol.TypeHierarchyResponse{}, err + } + return result, nil +} diff --git a/internal/graphquery/type_hierarchy_test.go b/internal/graphquery/type_hierarchy_test.go new file mode 100644 index 00000000..8b3c7df4 --- /dev/null +++ b/internal/graphquery/type_hierarchy_test.go @@ -0,0 +1,287 @@ +package graphquery + +import ( + "context" + "errors" + "fmt" + "reflect" + "testing" + + graphv2 "github.com/balcsida/graphnest/internal/graphartifact/v2" + "github.com/balcsida/graphnest/internal/graphprotocol" + "google.golang.org/protobuf/proto" +) + +type typeHierarchyTestStore struct { + entityImpactTestStore + counts func(context.Context, HierarchyCountQuery) (HierarchyCount, error) +} + +func (s *typeHierarchyTestStore) CountHierarchyChildren(ctx context.Context, query HierarchyCountQuery) (HierarchyCount, error) { + if s.counts != nil { + return s.counts(ctx, query) + } + return HierarchyCount{}, ctx.Err() +} + +func hierarchyEntity(occurrence, name, kind string) graphprotocol.Entity { + entity := impactEntity(occurrence, kind, "types.ts") + entity.Fact.Name = name + entity.Fact.QualifiedName = name + return entity +} + +func hierarchyNeighbor(entity graphprotocol.Entity, parent, edgeOccurrence, relation, direction string) EntityNeighbor { + edge := &graphv2.Edge{ + SourceId: edgeOccurrence, Occurrence: edgeOccurrence, Kind: graphv2.EdgeKind(graphRelationKindForTest(relation)), + Source: entity.Fact.Occurrence, Target: parent, + Location: &graphv2.Location{Path: proto.String("types.ts"), Start: &graphv2.Position{Line: proto.Int32(7), Character: proto.Int32(11)}}, + Confidence: proto.Float64(0.9), ResolutionReason: proto.String("exact-match"), + } + row := EntityNeighbor{Entity: entity, Edge: graphprotocol.Evidence{RepositoryID: 1, SourceID: entity.ID, TargetID: parent, Fact: edge}} + if direction == "outgoing" { + row.Edge.SourceID, row.Edge.TargetID = parent, entity.ID + row.Edge.Fact.Source, row.Edge.Fact.Target = parent, entity.Fact.Occurrence + } + return row +} + +func TestTypeRelationsPreserveDirectionsOccurrencesAndUnknownCoverage(t *testing.T) { + root := hierarchyEntity("root", "makeService", "function") + service := hierarchyEntity("service", "Service", "class") + greeter := hierarchyEntity("greeter", "Greeter", "interface") + caller := hierarchyEntity("caller", "caller", "function") + otherFunction := hierarchyEntity("other", "helper", "function") + store := &typeHierarchyTestStore{} + store.lookup = func(_ context.Context, query EntityQuery) ([]graphprotocol.Entity, error) { + if query.Selector.Occurrence != nil && *query.Selector.Occurrence == "root" { + return []graphprotocol.Entity{root}, nil + } + return nil, nil + } + store.neighbors = func(_ context.Context, query EntityNeighborQuery) ([]EntityNeighbor, error) { + switch query.Direction + ":" + query.Relation { + case "outgoing:type_of": + return []EntityNeighbor{ + hierarchyNeighbor(service, "root", "type-site-1", "type_of", "outgoing"), + hierarchyNeighbor(service, "root", "type-site-2", "type_of", "outgoing"), + }, nil + case "outgoing:returns": + return []EntityNeighbor{hierarchyNeighbor(greeter, "root", "return-site", "returns", "outgoing")}, nil + case "incoming:type_of": + return []EntityNeighbor{hierarchyNeighbor(caller, "root", "user-site", "type_of", "incoming")}, nil + case "incoming:returns": + return []EntityNeighbor{hierarchyNeighbor(caller, "root", "returner-site", "returns", "incoming")}, nil + case "outgoing:references": + return []EntityNeighbor{ + hierarchyNeighbor(service, "root", "reference-type", "references", "outgoing"), + hierarchyNeighbor(otherFunction, "root", "reference-function", "references", "outgoing"), + }, nil + case "outgoing:overrides": + return []EntityNeighbor{hierarchyNeighbor(caller, "root", "recorded-override", "overrides", "outgoing")}, nil + default: + return nil, nil + } + } + + got, err := (&Service{Store: store}).TypeRelations(t.Context(), graphprotocol.TypeRelationsRequest{Scope: entityTestScope(), Occurrence: "root"}) + if err != nil || got.Status != graphprotocol.StatusOK || got.TypeKnowledge != graphprotocol.TypeKnowledgeRecorded { + t.Fatalf("relations=%+v err=%v", got, err) + } + if len(got.Types) != 2 || len(got.Users) != 1 || len(got.Returners) != 1 || len(got.ReferencedTypes) != 1 || len(got.RecordedOverrides) != 1 { + t.Fatalf("relation groups=%+v", got) + } + if got.Types[0].Relation != "type_of" || len(got.Types[0].Edges) != 2 || got.Types[0].Edges[0].Fact.Occurrence != "type-site-1" || got.Types[0].Edges[1].Fact.Occurrence != "type-site-2" { + t.Fatalf("type evidence=%+v", got.Types) + } + if got.Types[1].Relation != "returns" || got.Types[1].Edges[0].Fact.Source != "root" || got.Users[0].Edges[0].Fact.Target != "root" || got.ReferencedTypes[0].Entity.Fact.Kind != "class" { + t.Fatalf("relation directions=%+v", got) + } + assertAnalysisState(t, got, true, 0) + + empty, err := (&Service{Store: store}).TypeRelations(t.Context(), graphprotocol.TypeRelationsRequest{Scope: entityTestScope(), Occurrence: "missing"}) + if err != nil || empty.Status != graphprotocol.StatusNotFound || empty.TypeKnowledge != graphprotocol.TypeKnowledgeUnknown || len(empty.Types) != 0 { + t.Fatalf("missing relations=%+v err=%v", empty, err) + } +} + +func TestTypeHierarchyReturnsBothDirectionsEvidenceAndDerivedOverrides(t *testing.T) { + square := hierarchyEntity("square", "Square", "class") + shape := hierarchyEntity("shape", "Shape", "class") + drawable := hierarchyEntity("drawable", "Drawable", "interface") + tile := hierarchyEntity("tile", "Tile", "class") + squareDraw := hierarchyEntity("square-draw", "draw", "method") + shapeDraw := hierarchyEntity("shape-draw", "draw", "method") + shapeArea := hierarchyEntity("shape-area", "area", "method") + store := &typeHierarchyTestStore{} + store.lookup = func(_ context.Context, query EntityQuery) ([]graphprotocol.Entity, error) { + if query.Selector.Occurrence != nil && *query.Selector.Occurrence == "square" { + return []graphprotocol.Entity{square}, nil + } + return nil, nil + } + store.neighbors = func(_ context.Context, query EntityNeighborQuery) ([]EntityNeighbor, error) { + switch query.Direction + ":" + query.Relation + ":" + query.Occurrence { + case "outgoing:extends:square": + return []EntityNeighbor{hierarchyNeighbor(shape, "square", "square-shape", "extends", "outgoing")}, nil + case "outgoing:implements:shape": + return []EntityNeighbor{hierarchyNeighbor(drawable, "shape", "shape-drawable", "implements", "outgoing")}, nil + case "incoming:extends:square": + return []EntityNeighbor{hierarchyNeighbor(tile, "square", "tile-square", "extends", "incoming")}, nil + case "outgoing:contains:square": + return []EntityNeighbor{hierarchyNeighbor(squareDraw, "square", "square-contains-draw", "contains", "outgoing")}, nil + case "outgoing:contains:shape": + return []EntityNeighbor{ + hierarchyNeighbor(shapeDraw, "shape", "shape-contains-draw", "contains", "outgoing"), + hierarchyNeighbor(shapeArea, "shape", "shape-contains-area", "contains", "outgoing"), + }, nil + default: + return nil, nil + } + } + store.counts = func(_ context.Context, query HierarchyCountQuery) (HierarchyCount, error) { + if query.Occurrence == "square" { + return HierarchyCount{Subtypes: 1}, nil + } + return HierarchyCount{}, nil + } + + got, err := (&Service{Store: store}).TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: entityTestScope(), Occurrence: "square"}) + if err != nil || got.Status != graphprotocol.StatusOK || got.Focus.Fact.Occurrence != "square" || got.Bounded || got.Polymorphic { + t.Fatalf("hierarchy=%+v err=%v", got, err) + } + wantAncestors := []string{"shape", "drawable"} + ancestorOccurrences := []string{got.Ancestors.Items[0].Entity.Fact.Occurrence, got.Ancestors.Items[1].Entity.Fact.Occurrence} + if !reflect.DeepEqual(ancestorOccurrences, wantAncestors) || got.Ancestors.Items[0].Depth != 1 || got.Ancestors.Items[1].Depth != 2 || got.Ancestors.Items[1].ParentID != "shape" { + t.Fatalf("ancestors=%+v", got.Ancestors) + } + if len(got.Descendants.Items) != 1 || got.Descendants.Items[0].Entity.Fact.Occurrence != "tile" || got.DirectSubtypes != 1 || got.DirectImplementers != 0 { + t.Fatalf("descendants=%+v direct=%d/%d", got.Descendants, got.DirectSubtypes, got.DirectImplementers) + } + if got.Ancestors.Items[0].Edge.Fact.Occurrence != "square-shape" || got.Ancestors.Items[0].Edge.Fact.GetLocation().GetStart().GetLine() != 7 { + t.Fatalf("hierarchy evidence=%+v", got.Ancestors.Items[0].Edge) + } + if len(got.DerivedOverrides) != 1 || got.DerivedOverrides[0].Member.Fact.Occurrence != "square-draw" || got.DerivedOverrides[0].BaseMember.Fact.Occurrence != "shape-draw" || got.DerivedOverrides[0].BaseType.Fact.Occurrence != "shape" || !got.DerivedOverrides[0].SignatureUncertain { + t.Fatalf("derived overrides=%+v", got.DerivedOverrides) + } + assertAnalysisState(t, got, true, 0) +} + +func TestTypeHierarchyPreservesSynthesizedAndDuplicateEvidence(t *testing.T) { + clock := hierarchyEntity("clock", "Clock", "interface") + system := hierarchyEntity("system", "System", "struct") + store := &typeHierarchyTestStore{} + store.lookup = func(_ context.Context, query EntityQuery) ([]graphprotocol.Entity, error) { + return []graphprotocol.Entity{clock}, nil + } + store.neighbors = func(_ context.Context, query EntityNeighborQuery) ([]EntityNeighbor, error) { + if query.Direction != "incoming" || query.Occurrence != "clock" { + return nil, nil + } + row := hierarchyNeighbor(system, "clock", query.Relation+"-system", query.Relation, "incoming") + if query.Relation == "implements" { + row.Edge.Fact.Provenance = proto.String("heuristic") + row.Edge.Fact.Extensions = []*graphv2.Extension{{Namespace: "codegraph.edge-metadata", Json: []byte(`{"synthesizedBy":"go-implements","via":"Clock","registeredAt":"types.ts:11"}`)}} + } + return []EntityNeighbor{row}, nil + } + store.counts = func(context.Context, HierarchyCountQuery) (HierarchyCount, error) { + return HierarchyCount{Subtypes: 1}, nil + } + + got, err := (&Service{Store: store}).TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: entityTestScope(), Occurrence: "clock"}) + if err != nil || len(got.Descendants.Items) != 1 || got.Descendants.Items[0].Relation != "extends" || got.DirectSubtypes != 1 || got.DirectImplementers != 0 { + t.Fatalf("duplicate hierarchy=%+v err=%v", got, err) + } + // With only the synthesized edge, the original provenance and lifted viewer fields remain visible. + store.neighbors = func(_ context.Context, query EntityNeighborQuery) ([]EntityNeighbor, error) { + if query.Direction != "incoming" || query.Relation != "implements" || query.Occurrence != "clock" { + return nil, nil + } + row := hierarchyNeighbor(system, "clock", "implements-system", "implements", "incoming") + row.Edge.Fact.Provenance = proto.String("heuristic") + row.Edge.Fact.Extensions = []*graphv2.Extension{{Namespace: "codegraph.edge-metadata", Json: []byte(`{"synthesizedBy":"go-implements","via":"Clock","registeredAt":"types.ts:11"}`)}} + return []EntityNeighbor{row}, nil + } + store.counts = func(context.Context, HierarchyCountQuery) (HierarchyCount, error) { + return HierarchyCount{Subtypes: 1, Implementers: 1}, nil + } + got, err = (&Service{Store: store}).TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: entityTestScope(), Occurrence: "clock"}) + entry := got.Descendants.Items[0] + if err != nil || !entry.Synthesized || entry.Via != "go-implements" || entry.RegisteredAt != "types.ts:11" || entry.Edge.Fact.GetProvenance() != "heuristic" || got.DirectImplementers != 1 { + t.Fatalf("synthesized hierarchy=%+v err=%v", got, err) + } +} + +func TestTypeHierarchyReportsWideDepthAndViewerBounds(t *testing.T) { + root := hierarchyEntity("root", "Root", "interface") + store := &typeHierarchyTestStore{} + store.lookup = func(_ context.Context, query EntityQuery) ([]graphprotocol.Entity, error) { + return []graphprotocol.Entity{root}, nil + } + store.neighbors = func(_ context.Context, query EntityNeighborQuery) ([]EntityNeighbor, error) { + if query.Direction != "incoming" || query.Relation != "implements" || query.Occurrence != "root" { + return nil, nil + } + rows := make([]EntityNeighbor, 401) + for i := range rows { + child := hierarchyEntity(fmt.Sprintf("child-%03d", i), fmt.Sprintf("Child%03d", i), "class") + rows[i] = hierarchyNeighbor(child, "root", fmt.Sprintf("edge-%03d", i), "implements", "incoming") + } + return rows, nil + } + store.counts = func(context.Context, HierarchyCountQuery) (HierarchyCount, error) { + return HierarchyCount{Subtypes: 437, Implementers: 437}, nil + } + + got, err := (&Service{Store: store}).TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: entityTestScope(), Occurrence: "root"}) + if err != nil || len(got.Descendants.Items) != 400 || got.Descendants.Total != 400 || got.Descendants.Shown != 240 || !got.Descendants.Truncated || got.DirectSubtypes != 437 || got.DirectImplementers != 437 || !got.Bounded || !got.Polymorphic { + t.Fatalf("wide hierarchy=%+v err=%v", got, err) + } + + chain := map[string]graphprotocol.Entity{"root": root} + for i := 1; i <= 7; i++ { + chain[fmt.Sprintf("d%d", i)] = hierarchyEntity(fmt.Sprintf("d%d", i), fmt.Sprintf("D%d", i), "class") + } + store.neighbors = func(_ context.Context, query EntityNeighborQuery) ([]EntityNeighbor, error) { + if query.Direction != "incoming" || query.Relation != "extends" { + return nil, nil + } + var next string + if query.Occurrence == "root" { + next = "d1" + } else { + var index int + if _, err := fmt.Sscanf(query.Occurrence, "d%d", &index); err == nil && index < 7 { + next = fmt.Sprintf("d%d", index+1) + } + } + if next == "" { + return nil, nil + } + return []EntityNeighbor{hierarchyNeighbor(chain[next], query.Occurrence, next+"-parent", "extends", "incoming")}, nil + } + store.counts = func(context.Context, HierarchyCountQuery) (HierarchyCount, error) { + return HierarchyCount{Subtypes: 1}, nil + } + got, err = (&Service{Store: store}).TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: entityTestScope(), Occurrence: "root"}) + if err != nil || len(got.Descendants.Items) != 6 || got.Descendants.Items[5].HiddenSubtypes != 1 || !got.Bounded { + t.Fatalf("deep hierarchy=%+v err=%v", got, err) + } +} + +func TestTypeHierarchyRejectsStaleCanceledInvalidAndHiddenResults(t *testing.T) { + request := graphprotocol.TypeHierarchyRequest{Scope: entityTestScope(), Occurrence: "root"} + stale := &typeHierarchyTestStore{entityImpactTestStore: entityImpactTestStore{entityTestStore: entityTestStore{changed: true}}} + if got, err := (&Service{Store: stale}).TypeHierarchy(t.Context(), request); !errors.Is(err, ErrGenerationChanged) || !reflect.DeepEqual(got, graphprotocol.TypeHierarchyResponse{}) { + t.Fatalf("stale hierarchy=%+v err=%v", got, err) + } + canceled, cancel := context.WithCancel(t.Context()) + cancel() + if got, err := (&Service{Store: &typeHierarchyTestStore{}}).TypeHierarchy(canceled, request); !errors.Is(err, context.Canceled) || !reflect.DeepEqual(got, graphprotocol.TypeHierarchyResponse{}) { + t.Fatalf("canceled hierarchy=%+v err=%v", got, err) + } + if _, err := (&Service{Store: &typeHierarchyTestStore{}}).TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: entityTestScope()}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("empty hierarchy occurrence=%v", err) + } +} diff --git a/internal/graphservice/type_hierarchy.go b/internal/graphservice/type_hierarchy.go new file mode 100644 index 00000000..5a328e91 --- /dev/null +++ b/internal/graphservice/type_hierarchy.go @@ -0,0 +1,32 @@ +package graphservice + +import ( + "context" + + "github.com/balcsida/graphnest/internal/authn" + "github.com/balcsida/graphnest/internal/graphprotocol" +) + +func (s *Service) TypeRelations(ctx context.Context, principal authn.Principal, request EntityAnalysisRequest) (graphprotocol.TypeRelationsResponse, error) { + backend, ok := s.Backend.(interface { + TypeRelations(context.Context, graphprotocol.TypeRelationsRequest) (graphprotocol.TypeRelationsResponse, error) + }) + if !ok { + return graphprotocol.TypeRelationsResponse{}, ErrGraphNotReady + } + return aggregateCall(ctx, s, principal, request.AggregateScopeRequest, func(scope graphprotocol.Scope) (graphprotocol.TypeRelationsResponse, error) { + return backend.TypeRelations(ctx, graphprotocol.TypeRelationsRequest{Scope: scope, Occurrence: request.Occurrence}) + }, func(value graphprotocol.TypeRelationsResponse) []graphprotocol.Generation { return value.Generations }) +} + +func (s *Service) TypeHierarchy(ctx context.Context, principal authn.Principal, request EntityAnalysisRequest) (graphprotocol.TypeHierarchyResponse, error) { + backend, ok := s.Backend.(interface { + TypeHierarchy(context.Context, graphprotocol.TypeHierarchyRequest) (graphprotocol.TypeHierarchyResponse, error) + }) + if !ok { + return graphprotocol.TypeHierarchyResponse{}, ErrGraphNotReady + } + return aggregateCall(ctx, s, principal, request.AggregateScopeRequest, func(scope graphprotocol.Scope) (graphprotocol.TypeHierarchyResponse, error) { + return backend.TypeHierarchy(ctx, graphprotocol.TypeHierarchyRequest{Scope: scope, Occurrence: request.Occurrence}) + }, func(value graphprotocol.TypeHierarchyResponse) []graphprotocol.Generation { return value.Generations }) +} diff --git a/internal/graphservice/type_hierarchy_test.go b/internal/graphservice/type_hierarchy_test.go new file mode 100644 index 00000000..3979a59c --- /dev/null +++ b/internal/graphservice/type_hierarchy_test.go @@ -0,0 +1,54 @@ +package graphservice + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/balcsida/graphnest/internal/graphprotocol" + "github.com/balcsida/graphnest/internal/graphquery" + "github.com/balcsida/graphnest/internal/repository" + "github.com/balcsida/graphnest/pkg/api" +) + +type typeHierarchyBackend struct { + *inspectionBackend + relations graphprotocol.TypeRelationsRequest + hierarchy graphprotocol.TypeHierarchyRequest +} + +func (backend *typeHierarchyBackend) TypeRelations(_ context.Context, request graphprotocol.TypeRelationsRequest) (graphprotocol.TypeRelationsResponse, error) { + backend.relations = request + return graphprotocol.TypeRelationsResponse{Status: graphprotocol.StatusOK, Generations: []graphprotocol.Generation{backend.generation}}, nil +} + +func (backend *typeHierarchyBackend) TypeHierarchy(_ context.Context, request graphprotocol.TypeHierarchyRequest) (graphprotocol.TypeHierarchyResponse, error) { + backend.hierarchy = request + return graphprotocol.TypeHierarchyResponse{Status: graphprotocol.StatusOK, Generations: []graphprotocol.Generation{backend.generation}}, nil +} + +func TestTypeHierarchyServiceKeepsSelectedAuthorityAndRejectsDrift(t *testing.T) { + service, base, store := inspectionFixture() + backend := &typeHierarchyBackend{inspectionBackend: base} + service.Backend = backend + request := EntityAnalysisRequest{AggregateScopeRequest: AggregateScopeRequest{Repo: api.GraphRepositorySelector{ID: 101}}, Occurrence: "service"} + relations, err := service.TypeRelations(t.Context(), principalFor(101), request) + if err != nil || relations.Status != graphprotocol.StatusOK || backend.relations.Scope.SelectedRepositoryID == 0 || backend.relations.Occurrence != "service" { + t.Fatalf("relations=%+v request=%+v err=%v", relations, backend.relations, err) + } + hierarchy, err := service.TypeHierarchy(t.Context(), principalFor(101), request) + if err != nil || hierarchy.Status != graphprotocol.StatusOK || backend.hierarchy.Scope.SelectedRepositoryID == 0 || backend.hierarchy.Occurrence != "service" { + t.Fatalf("hierarchy=%+v request=%+v err=%v", hierarchy, backend.hierarchy, err) + } + + store.repositories = nil + if got, err := service.TypeHierarchy(t.Context(), principalFor(101), request); err == nil || !reflect.DeepEqual(got, graphprotocol.TypeHierarchyResponse{}) { + t.Fatalf("unauthorized hierarchy=%+v err=%v", got, err) + } + store.repositories = []repository.Repository{readyRepository("acme/visible")} + base.changed = true + if got, err := service.TypeRelations(t.Context(), principalFor(101), request); !errors.Is(err, graphquery.ErrGenerationChanged) || !reflect.DeepEqual(got, graphprotocol.TypeRelationsResponse{}) { + t.Fatalf("changed relations=%+v err=%v", got, err) + } +} From e1017fbb06a29cd38b67cdc8e2fdc760f97dad02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Thu, 24 Sep 2026 08:53:30 +0200 Subject: [PATCH 3/7] test(graph): add WIP PostgreSQL type hierarchy test Co-Authored-By: Claude Opus 5.5 --- .../postgres/graph_type_hierarchy_test.go | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 internal/postgres/graph_type_hierarchy_test.go diff --git a/internal/postgres/graph_type_hierarchy_test.go b/internal/postgres/graph_type_hierarchy_test.go new file mode 100644 index 00000000..bf0147fb --- /dev/null +++ b/internal/postgres/graph_type_hierarchy_test.go @@ -0,0 +1,310 @@ +//go:build integration + +package postgres + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "strconv" + "strings" + "testing" + + "github.com/balcsida/graphnest/internal/graphartifact" + graphv2 "github.com/balcsida/graphnest/internal/graphartifact/v2" + "github.com/balcsida/graphnest/internal/graphprotocol" + "github.com/balcsida/graphnest/internal/graphquery" + "google.golang.org/protobuf/proto" +) + +type hierarchyCapture struct { + Nodes []positiveNode `json:"nodes"` + Edges []positiveEdge `json:"edges"` + Files []hierarchyFile `json:"files"` + UnresolvedRefs []hierarchyUnresolved `json:"unresolvedRefs"` +} + +type hierarchyFile struct { + Path string `json:"path"` + ContentHash string `json:"content_hash"` + Language string `json:"language"` + Size int64 `json:"size"` + ModifiedAt float64 `json:"modified_at"` + IndexedAt int64 `json:"indexed_at"` + NodeCount int64 `json:"node_count"` + Errors json.RawMessage `json:"errors"` + Generated int `json:"generated"` +} + +type hierarchyUnresolved struct { + ID int64 `json:"id"` + Source string `json:"from_node_id"` + Name string `json:"reference_name"` + Kind string `json:"reference_kind"` + Line, Column *int32 `json:"line"` + Candidates json.RawMessage `json:"candidates"` + Path *string `json:"file_path"` + Language *string `json:"language"` + Status *string `json:"status"` + NameTail *string `json:"name_tail"` +} + +func loadHierarchyCapture(t *testing.T) (*graphv2.Artifact, hierarchyCapture) { + t.Helper() + path := "../../test/fixtures/codegraph/type-hierarchy-facts.json" + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := fmt.Sprintf("%x", sha256.Sum256(raw)); got != "652e54bf26e606eb35ea65004b13fd6a5a4572c43a91b6385753d5dab959c904" { + t.Fatalf("hierarchy fact capture hash=%s", got) + } + var capture hierarchyCapture + if err = json.Unmarshal(raw, &capture); err != nil { + t.Fatal(err) + } + if len(capture.Nodes) != 37 || len(capture.Edges) != 53 || len(capture.Files) != 3 || len(capture.UnresolvedRefs) != 5 { + t.Fatalf("incomplete hierarchy capture nodes=%d edges=%d files=%d unresolved=%d", len(capture.Nodes), len(capture.Edges), len(capture.Files), len(capture.UnresolvedRefs)) + } + for name, want := range map[string]string{ + "go.mod": "b54d66f2b7960919ede3cfc12752e99416429a7f3d2b8a8e3d5d1ba31042a94a", + "src/shapes.ts": "f9354f8bfd3e6068f91aae9abfa83e6c3a68537db84c3a8f34a9369777ad282b", + "src/plugins.ts": "e36b5726c5596007b9cb678adb54d807426dd5b5de2d6153c8a75d6aab6647b0", + "src/clock.go": "96137f6407c45c31afe9a498b3d333f8cfc63ea33e1b6a704f57f184fa2748a3", + } { + data, readErr := os.ReadFile(filepath.Join("../../test/fixtures/codegraph/type-hierarchy-source", name)) + if readErr != nil || fmt.Sprintf("%x", sha256.Sum256(data)) != want { + t.Fatalf("hierarchy source %s hash=%x err=%v", name, sha256.Sum256(data), readErr) + } + } + + artifact := storageV2Artifact() + artifact.ContentHash = nil + artifact.Commit = strings.Repeat("7", 40) + artifact.Repository = "s1-06b-cd02-hierarchy" + artifact.Nodes, artifact.Edges, artifact.Files, artifact.Unresolved, artifact.Diagnostics = nil, nil, nil, nil, nil + for _, node := range capture.Nodes { + path := node.FilePath + artifact.Nodes = append(artifact.Nodes, &graphv2.Node{ + SourceId: node.ID, Occurrence: node.ID, Kind: node.Kind, Name: node.Name, + QualifiedName: node.QualifiedName, Path: &path, Language: node.Language, + Location: &graphv2.Location{ + Start: positivePoint(proto.Int32(node.StartLine), proto.Int32(node.StartColumn)), + End: positivePoint(proto.Int32(node.EndLine), proto.Int32(node.EndColumn)), + }, + Documentation: node.Documentation, Signature: node.Signature, Visibility: node.Visibility, + IsExported: proto.Bool(node.IsExported != 0), IsAsync: proto.Bool(node.IsAsync != 0), + IsStatic: proto.Bool(node.IsStatic != 0), IsAbstract: proto.Bool(node.IsAbstract != 0), + Decorators: positiveStringList(t, node.Decorators), TypeParameters: positiveStringList(t, node.TypeParameters), + ReturnType: node.ReturnType, UpdatedAt: proto.Int64(node.UpdatedAt), + }) + } + for _, edge := range capture.Edges { + relation, ok := graphartifact.ParseRelationship(edge.Kind) + if !ok { + t.Fatal(edge.Kind) + } + fact := &graphv2.Edge{ + SourceId: strconv.FormatInt(edge.ID, 10), Occurrence: "edge:" + strconv.FormatInt(edge.ID, 10), + Source: edge.Source, Target: edge.Target, Kind: relation.WireKind(), Provenance: edge.Provenance, + } + if point := positivePoint(edge.Line, edge.Col); point != nil { + fact.Location = &graphv2.Location{Start: point} + } + if edge.Metadata != nil { + fact.Extensions = []*graphv2.Extension{{Namespace: "codegraph.edge-metadata", Json: []byte(*edge.Metadata)}} + var metadata struct { + Confidence *float64 `json:"confidence"` + ResolvedBy *string `json:"resolvedBy"` + } + if err = json.Unmarshal([]byte(*edge.Metadata), &metadata); err != nil { + t.Fatal(err) + } + fact.Confidence, fact.ResolutionReason = metadata.Confidence, metadata.ResolvedBy + } + artifact.Edges = append(artifact.Edges, fact) + } + for _, file := range capture.Files { + artifact.Files = append(artifact.Files, &graphv2.File{ + Path: file.Path, ContentHash: file.ContentHash, Language: file.Language, Size: file.Size, + ModifiedAt: proto.Int64(int64(file.ModifiedAt)), IndexedAt: proto.Int64(file.IndexedAt), + NodeCount: proto.Int64(file.NodeCount), Generated: proto.Bool(file.Generated != 0), + Errors: positiveExtension("codegraph.extraction-errors", file.Errors), + }) + } + for _, unresolved := range capture.UnresolvedRefs { + fact := &graphv2.UnresolvedReference{ + SourceId: strconv.FormatInt(unresolved.ID, 10), Occurrence: "unresolved:" + strconv.FormatInt(unresolved.ID, 10), + Source: unresolved.Source, Name: unresolved.Name, Kind: unresolved.Kind, + Candidates: positiveStringList(t, unresolved.Candidates), Path: unresolved.Path, Language: unresolved.Language, + Status: unresolved.Status, NameTail: unresolved.NameTail, + } + if point := positivePoint(unresolved.Line, unresolved.Column); point != nil { + fact.Location = &graphv2.Location{Path: unresolved.Path, Start: point} + } + artifact.Unresolved = append(artifact.Unresolved, fact) + } + return artifact, capture +} + +func captureNode(t *testing.T, artifact *graphv2.Artifact, name, kind string) *graphv2.Node { + t.Helper() + var found *graphv2.Node + for _, node := range artifact.Nodes { + if node.Name == name && node.Kind == kind { + if found != nil { + t.Fatalf("ambiguous captured node %s/%s", name, kind) + } + found = node + } + } + if found == nil { + t.Fatalf("missing captured node %s/%s", name, kind) + } + return found +} + +func hierarchyNames(entries []graphprotocol.HierarchyEntry) []string { + result := make([]string, len(entries)) + for i, entry := range entries { + result[i] = entry.Entity.Fact.Name + } + return result +} + +func TestGraphTypeHierarchyProducerOracle(t *testing.T) { + artifact, _ := loadHierarchyCapture(t) + store, service, scope := fileDependencyService(t, artifact) + originalNodes, originalEdges := entityImpactFacts(artifact) + + all, err := service.Entities(t.Context(), graphprotocol.EntitiesRequest{Scope: scope, Limit: 100}) + if err != nil || len(all.Entities) != 37 { + t.Fatalf("complete nodes=%d err=%v", len(all.Entities), err) + } + assertEntityFacts(t, all.Entities, originalNodes) + occurrences := make([]string, len(artifact.Nodes)) + for i, node := range artifact.Nodes { + occurrences[i] = node.Occurrence + } + slices.Sort(occurrences) + evidence, err := store.QueryAnalysisEvidence(t.Context(), graphquery.AnalysisEvidenceQuery{Snapshot: graphquery.QuerySnapshot{RepositoryID: scope.SelectedRepositoryID, UploadID: all.Generations[0].UploadID, Commit: artifact.Commit}, Occurrences: occurrences, Limit: 54}) + if err != nil || len(evidence) != 53 { + t.Fatalf("complete edges=%d err=%v", len(evidence), err) + } + assertEvidenceFacts(t, evidence, originalEdges) + if all.Generations[0].NodeCount != 37 || all.Generations[0].EdgeCount != 53 || all.Generations[0].UnresolvedCount != 5 { + t.Fatalf("complete generation=%+v", all.Generations[0]) + } + + for _, test := range []struct { + name, kind string + ancestors []string + descendants []string + direct, implementers int + polymorphic bool + }{ + {"Tile", "class", []string{"Square", "Shape", "Drawable"}, nil, 0, 0, false}, + {"Shape", "class", []string{"Drawable"}, []string{"Square", "Tile"}, 1, 0, false}, + {"Plugin", "interface", nil, []string{"AlphaPlugin", "BravoPlugin", "CharliePlugin", "DeltaPlugin", "EchoPlugin", "FoxtrotPlugin", "GolfPlugin", "HotelPlugin", "IndiaPlugin"}, 9, 9, true}, + {"Clock", "interface", nil, []string{"Fixed", "System"}, 2, 2, false}, + } { + t.Run(test.name, func(t *testing.T) { + focus := captureNode(t, artifact, test.name, test.kind) + got, callErr := service.TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: scope, Occurrence: focus.Occurrence}) + if callErr != nil || !reflect.DeepEqual(hierarchyNames(got.Ancestors.Items), test.ancestors) || !reflect.DeepEqual(hierarchyNames(got.Descendants.Items), test.descendants) || got.DirectSubtypes != test.direct || got.DirectImplementers != test.implementers || got.Polymorphic != test.polymorphic || got.Bounded { + t.Fatalf("hierarchy ancestors=%v descendants=%v direct=%d/%d bounded=%v err=%v", hierarchyNames(got.Ancestors.Items), hierarchyNames(got.Descendants.Items), got.DirectSubtypes, got.DirectImplementers, got.Bounded, callErr) + } + if got.Analysis == nil || got.Analysis.Unresolved != 5 || got.Analysis.Coverage != graphprotocol.AnalysisCoverageNotAssessed || !got.Analysis.Complete { + t.Fatalf("hierarchy analysis=%+v", got.Analysis) + } + for _, entry := range append(slices.Clone(got.Ancestors.Items), got.Descendants.Items...) { + if originalEdges[entry.Edge.Fact.Occurrence] == nil || !proto.Equal(entry.Edge.Fact, originalEdges[entry.Edge.Fact.Occurrence]) { + t.Fatalf("lost hierarchy edge evidence=%+v", entry.Edge.Fact) + } + } + if test.name == "Clock" { + for _, entry := range got.Descendants.Items { + if !entry.Synthesized || entry.Via != "go-implements" || entry.RegisteredAt == "" || entry.Edge.Fact.GetProvenance() != "heuristic" { + t.Fatalf("implicit hierarchy=%+v", entry) + } + } + } + }) + } + + square := captureNode(t, artifact, "Square", "class") + squareHierarchy, err := service.TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: scope, Occurrence: square.Occurrence}) + if err != nil || len(squareHierarchy.DerivedOverrides) != 1 || squareHierarchy.DerivedOverrides[0].Member.Fact.Name != "draw" || squareHierarchy.DerivedOverrides[0].BaseMember.Fact.QualifiedName != "Shape::draw" || !squareHierarchy.DerivedOverrides[0].SignatureUncertain { + t.Fatalf("square overrides=%+v err=%v", squareHierarchy.DerivedOverrides, err) + } + tile := captureNode(t, artifact, "Tile", "class") + tileHierarchy, err := service.TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: scope, Occurrence: tile.Occurrence}) + if err != nil || len(tileHierarchy.DerivedOverrides) != 0 { + t.Fatalf("tile overrides=%+v err=%v", tileHierarchy.DerivedOverrides, err) + } +} + +func TestGraphTypeHierarchyOriginalServiceAndBaseOracle(t *testing.T) { + raw, err := os.ReadFile(os.Getenv("GRAPHNEST_TEST_CODEGRAPH_V2_FIXTURE")) + if err != nil { + t.Fatal(err) + } + artifact, err := graphartifact.ParseV2(raw, graphartifact.Limits{}) + if err != nil { + t.Fatal(err) + } + verifyAggregateSources(t) + _, service, scope := fileDependencyService(t, artifact) + base := captureNode(t, artifact, "Base", "class") + serviceType := captureNode(t, artifact, "Service", "class") + baseHierarchy, err := service.TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: scope, Occurrence: base.Occurrence}) + if err != nil || !reflect.DeepEqual(hierarchyNames(baseHierarchy.Descendants.Items), []string{"Service"}) || baseHierarchy.DirectSubtypes != 1 || baseHierarchy.Bounded { + t.Fatalf("Base descendants=%+v err=%v", baseHierarchy, err) + } + entry := baseHierarchy.Descendants.Items[0] + if entry.Depth != 1 || entry.ParentID != baseHierarchy.Focus.ID || entry.Relation != "extends" || entry.Edge.Fact.GetLocation().GetStart().GetLine() != 5 || entry.Edge.Fact.GetLocation().GetStart().GetCharacter() != 28 || entry.Edge.Fact.GetConfidence() != 0.9 || entry.Edge.Fact.GetResolutionReason() != "exact-match" { + t.Fatalf("Base descendant evidence=%+v", entry) + } + serviceHierarchy, err := service.TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: scope, Occurrence: serviceType.Occurrence}) + if err != nil || !reflect.DeepEqual(hierarchyNames(serviceHierarchy.Ancestors.Items), []string{"Base", "Greeter"}) || len(serviceHierarchy.DerivedOverrides) != 1 || serviceHierarchy.DerivedOverrides[0].Member.Fact.QualifiedName != "Service::greet" || serviceHierarchy.DerivedOverrides[0].BaseMember.Fact.QualifiedName != "Base::greet" { + t.Fatalf("Service hierarchy=%+v err=%v", serviceHierarchy, err) + } + relations, err := service.TypeRelations(t.Context(), graphprotocol.TypeRelationsRequest{Scope: scope, Occurrence: serviceType.Occurrence}) + if err != nil || relations.TypeKnowledge != graphprotocol.TypeKnowledgeUnknown || len(relations.Types) != 0 || len(relations.ReferencedTypes) != 0 { + t.Fatalf("portable producer type relations=%+v err=%v", relations, err) + } +} + +func TestGraphTypeRelationsSyntheticDirectionAndOccurrences(t *testing.T) { + artifact := storageV2Artifact() + artifact.ContentHash = nil + artifact.Commit = strings.Repeat("8", 40) + artifact.Repository = "s1-06b-t1-synthetic-relations" + artifact.Nodes, artifact.Edges, artifact.Files, artifact.Unresolved, artifact.Diagnostics = nil, nil, nil, nil, nil + for _, value := range []struct{ occurrence, name, kind string }{ + {"root", "makeService", "function"}, {"service", "Service", "class"}, {"greeter", "Greeter", "interface"}, + {"user", "serviceVariable", "variable"}, {"returner", "returnService", "function"}, {"base", "baseMethod", "method"}, {"helper", "helper", "function"}, + } { + artifact.Nodes = append(artifact.Nodes, &graphv2.Node{SourceId: value.occurrence, Occurrence: value.occurrence, Name: value.name, QualifiedName: value.name, Kind: value.kind}) + } + for i, value := range []struct{ source, target, kind string }{ + {"root", "service", "type_of"}, {"root", "service", "type_of"}, {"root", "greeter", "returns"}, + {"user", "root", "type_of"}, {"returner", "root", "returns"}, {"root", "service", "references"}, + {"root", "helper", "references"}, {"root", "base", "overrides"}, + } { + relation, _ := graphartifact.ParseRelationship(value.kind) + artifact.Edges = append(artifact.Edges, &graphv2.Edge{SourceId: strconv.Itoa(i + 1), Occurrence: fmt.Sprintf("synthetic-edge-%d", i+1), Source: value.source, Target: value.target, Kind: relation.WireKind()}) + } + _, service, scope := fileDependencyService(t, artifact) + got, err := service.TypeRelations(t.Context(), graphprotocol.TypeRelationsRequest{Scope: scope, Occurrence: "root"}) + if err != nil || got.TypeKnowledge != graphprotocol.TypeKnowledgeRecorded || len(got.Types) != 2 || len(got.Types[0].Edges) != 2 || len(got.Users) != 1 || len(got.Returners) != 1 || len(got.ReferencedTypes) != 1 || len(got.RecordedOverrides) != 1 { + t.Fatalf("synthetic type relations=%+v err=%v", got, err) + } + if got.Types[0].Edges[0].Fact.Occurrence == got.Types[0].Edges[1].Fact.Occurrence || got.Users[0].Edges[0].Fact.Source != "user" || got.Returners[0].Edges[0].Fact.Source != "returner" || got.ReferencedTypes[0].Entity.Fact.Occurrence != "service" { + t.Fatalf("synthetic direction/evidence=%+v", got) + } +} From 9b3d5856f7a1a09bd937831c711a7ee4deb3e41e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Thu, 24 Sep 2026 17:35:09 +0200 Subject: [PATCH 4/7] feat(graph): count type hierarchy children in PostgreSQL Co-Authored-By: Claude Opus 5.5 --- internal/postgres/graph_entity.go | 2 +- internal/postgres/graph_type_hierarchy.go | 32 +++++ .../postgres/graph_type_hierarchy_test.go | 126 ++++++++++++++---- 3 files changed, 135 insertions(+), 25 deletions(-) create mode 100644 internal/postgres/graph_type_hierarchy.go diff --git a/internal/postgres/graph_entity.go b/internal/postgres/graph_entity.go index dd6ef3d2..55293750 100644 --- a/internal/postgres/graph_entity.go +++ b/internal/postgres/graph_entity.go @@ -184,7 +184,7 @@ func (s *Store) EntityNeighbors(ctx context.Context, q graphquery.EntityNeighbor ctx, cancel := s.graphQueryContext(ctx) defer cancel() relation, ok := graphartifact.ParseRelationship(q.Relation) - if !ok || (q.Direction != "incoming" && q.Direction != "outgoing") || q.Limit <= 0 || q.Limit > 101 || math.IsNaN(q.MinConfidence) || math.IsInf(q.MinConfidence, 0) || q.MinConfidence < 0 || q.MinConfidence > 1 { + if !ok || (q.Direction != "incoming" && q.Direction != "outgoing") || q.Limit <= 0 || q.Limit > 401 || math.IsNaN(q.MinConfidence) || math.IsInf(q.MinConfidence, 0) || q.MinConfidence < 0 || q.MinConfidence > 1 { return nil, graphquery.ErrInvalidRequest } parent, neighbor := "source", "target" diff --git a/internal/postgres/graph_type_hierarchy.go b/internal/postgres/graph_type_hierarchy.go new file mode 100644 index 00000000..7cf7029e --- /dev/null +++ b/internal/postgres/graph_type_hierarchy.go @@ -0,0 +1,32 @@ +package postgres + +import ( + "context" + + "github.com/balcsida/graphnest/internal/graphartifact" + "github.com/balcsida/graphnest/internal/graphquery" +) + +// One row per distinct direct subtype, matching the viewer list: a type tied to +// its supertype by both extends and implements counts once, as extends. +const hierarchyChildCountSQL = `select count(*), count(*) filter (where not has_extends) from ( + select bool_or(e.kind=$5) has_extends + from graph_uploads u join graph_v2_edges e on e.upload_id=u.id + join graph_v2_nodes t on t.upload_id=u.id and t.occurrence_key=e.target_key and t.occurrence=e.target + join graph_v2_nodes s on s.upload_id=u.id and s.occurrence_key=e.source_key and s.occurrence=e.source + where u.repository_id=$1 and u.id=$2 and u.commit=$3 and u.schema_version=2 + and e.target_key=sha256($4::bytea) and e.target=$4::bytea and e.kind in ($5,$6) and e.source<>$4::bytea + group by e.source) children` + +func (s *Store) CountHierarchyChildren(ctx context.Context, q graphquery.HierarchyCountQuery) (graphquery.HierarchyCount, error) { + ctx, cancel := s.graphQueryContext(ctx) + defer cancel() + if q.Snapshot.RepositoryID <= 0 || q.Snapshot.UploadID <= 0 || q.Snapshot.Commit == "" || q.Occurrence == "" { + return graphquery.HierarchyCount{}, graphquery.ErrInvalidRequest + } + extends, _ := graphartifact.ParseRelationship("extends") + implements, _ := graphartifact.ParseRelationship("implements") + var count graphquery.HierarchyCount + err := s.pool.QueryRow(ctx, hierarchyChildCountSQL, q.Snapshot.RepositoryID, q.Snapshot.UploadID, q.Snapshot.Commit, []byte(q.Occurrence), int16(extends.Kind), int16(implements.Kind)).Scan(&count.Subtypes, &count.Implementers) + return count, err +} diff --git a/internal/postgres/graph_type_hierarchy_test.go b/internal/postgres/graph_type_hierarchy_test.go index bf0147fb..97e38ec7 100644 --- a/internal/postgres/graph_type_hierarchy_test.go +++ b/internal/postgres/graph_type_hierarchy_test.go @@ -3,12 +3,13 @@ package postgres import ( + "context" "crypto/sha256" "encoding/json" + "errors" "fmt" "os" "path/filepath" - "reflect" "slices" "strconv" "strings" @@ -22,8 +23,8 @@ import ( ) type hierarchyCapture struct { - Nodes []positiveNode `json:"nodes"` - Edges []positiveEdge `json:"edges"` + Nodes []positiveNode `json:"nodes"` + Edges []positiveEdge `json:"edges"` Files []hierarchyFile `json:"files"` UnresolvedRefs []hierarchyUnresolved `json:"unresolvedRefs"` } @@ -41,16 +42,17 @@ type hierarchyFile struct { } type hierarchyUnresolved struct { - ID int64 `json:"id"` - Source string `json:"from_node_id"` - Name string `json:"reference_name"` - Kind string `json:"reference_kind"` - Line, Column *int32 `json:"line"` - Candidates json.RawMessage `json:"candidates"` - Path *string `json:"file_path"` - Language *string `json:"language"` - Status *string `json:"status"` - NameTail *string `json:"name_tail"` + ID int64 `json:"id"` + Source string `json:"from_node_id"` + Name string `json:"reference_name"` + Kind string `json:"reference_kind"` + Line *int32 `json:"line"` + Column *int32 `json:"col"` + Candidates json.RawMessage `json:"candidates"` + Path *string `json:"file_path"` + Language *string `json:"language"` + Status *string `json:"status"` + NameTail *string `json:"name_tail"` } func loadHierarchyCapture(t *testing.T) (*graphv2.Artifact, hierarchyCapture) { @@ -85,7 +87,6 @@ func loadHierarchyCapture(t *testing.T) (*graphv2.Artifact, hierarchyCapture) { artifact := storageV2Artifact() artifact.ContentHash = nil artifact.Commit = strings.Repeat("7", 40) - artifact.Repository = "s1-06b-cd02-hierarchy" artifact.Nodes, artifact.Edges, artifact.Files, artifact.Unresolved, artifact.Diagnostics = nil, nil, nil, nil, nil for _, node := range capture.Nodes { path := node.FilePath @@ -195,17 +196,22 @@ func TestGraphTypeHierarchyProducerOracle(t *testing.T) { if err != nil || len(evidence) != 53 { t.Fatalf("complete edges=%d err=%v", len(evidence), err) } - assertEvidenceFacts(t, evidence, originalEdges) + for _, edge := range evidence { + // Store rows carry the internal repository ID; the service maps it to the public one. + if edge.RepositoryID != scope.SelectedRepositoryID || !proto.Equal(edge.Fact, originalEdges[edge.Fact.GetOccurrence()]) { + t.Fatalf("lost evidence fact=%+v", edge) + } + } if all.Generations[0].NodeCount != 37 || all.Generations[0].EdgeCount != 53 || all.Generations[0].UnresolvedCount != 5 { t.Fatalf("complete generation=%+v", all.Generations[0]) } for _, test := range []struct { - name, kind string - ancestors []string - descendants []string + name, kind string + ancestors []string + descendants []string direct, implementers int - polymorphic bool + polymorphic bool }{ {"Tile", "class", []string{"Square", "Shape", "Drawable"}, nil, 0, 0, false}, {"Shape", "class", []string{"Drawable"}, []string{"Square", "Tile"}, 1, 0, false}, @@ -215,7 +221,7 @@ func TestGraphTypeHierarchyProducerOracle(t *testing.T) { t.Run(test.name, func(t *testing.T) { focus := captureNode(t, artifact, test.name, test.kind) got, callErr := service.TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: scope, Occurrence: focus.Occurrence}) - if callErr != nil || !reflect.DeepEqual(hierarchyNames(got.Ancestors.Items), test.ancestors) || !reflect.DeepEqual(hierarchyNames(got.Descendants.Items), test.descendants) || got.DirectSubtypes != test.direct || got.DirectImplementers != test.implementers || got.Polymorphic != test.polymorphic || got.Bounded { + if callErr != nil || !slices.Equal(hierarchyNames(got.Ancestors.Items), test.ancestors) || !slices.Equal(hierarchyNames(got.Descendants.Items), test.descendants) || got.DirectSubtypes != test.direct || got.DirectImplementers != test.implementers || got.Polymorphic != test.polymorphic || got.Bounded { t.Fatalf("hierarchy ancestors=%v descendants=%v direct=%d/%d bounded=%v err=%v", hierarchyNames(got.Ancestors.Items), hierarchyNames(got.Descendants.Items), got.DirectSubtypes, got.DirectImplementers, got.Bounded, callErr) } if got.Analysis == nil || got.Analysis.Unresolved != 5 || got.Analysis.Coverage != graphprotocol.AnalysisCoverageNotAssessed || !got.Analysis.Complete { @@ -262,15 +268,15 @@ func TestGraphTypeHierarchyOriginalServiceAndBaseOracle(t *testing.T) { base := captureNode(t, artifact, "Base", "class") serviceType := captureNode(t, artifact, "Service", "class") baseHierarchy, err := service.TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: scope, Occurrence: base.Occurrence}) - if err != nil || !reflect.DeepEqual(hierarchyNames(baseHierarchy.Descendants.Items), []string{"Service"}) || baseHierarchy.DirectSubtypes != 1 || baseHierarchy.Bounded { + if err != nil || !slices.Equal(hierarchyNames(baseHierarchy.Descendants.Items), []string{"Service"}) || baseHierarchy.DirectSubtypes != 1 || baseHierarchy.Bounded { t.Fatalf("Base descendants=%+v err=%v", baseHierarchy, err) } entry := baseHierarchy.Descendants.Items[0] - if entry.Depth != 1 || entry.ParentID != baseHierarchy.Focus.ID || entry.Relation != "extends" || entry.Edge.Fact.GetLocation().GetStart().GetLine() != 5 || entry.Edge.Fact.GetLocation().GetStart().GetCharacter() != 28 || entry.Edge.Fact.GetConfidence() != 0.9 || entry.Edge.Fact.GetResolutionReason() != "exact-match" { + if entry.Depth != 1 || entry.ParentID != baseHierarchy.Focus.ID || entry.Relation != "extends" || entry.Edge.Fact.GetLocation().GetStart().GetLine() != 5 || entry.Edge.Fact.GetLocation().GetStart().GetCharacter() != 29 || entry.Edge.Fact.GetConfidence() != 0.9 || entry.Edge.Fact.GetResolutionReason() != "exact-match" { t.Fatalf("Base descendant evidence=%+v", entry) } serviceHierarchy, err := service.TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: scope, Occurrence: serviceType.Occurrence}) - if err != nil || !reflect.DeepEqual(hierarchyNames(serviceHierarchy.Ancestors.Items), []string{"Base", "Greeter"}) || len(serviceHierarchy.DerivedOverrides) != 1 || serviceHierarchy.DerivedOverrides[0].Member.Fact.QualifiedName != "Service::greet" || serviceHierarchy.DerivedOverrides[0].BaseMember.Fact.QualifiedName != "Base::greet" { + if err != nil || !slices.Equal(hierarchyNames(serviceHierarchy.Ancestors.Items), []string{"Base", "Greeter"}) || len(serviceHierarchy.DerivedOverrides) != 1 || serviceHierarchy.DerivedOverrides[0].Member.Fact.QualifiedName != "Service::greet" || serviceHierarchy.DerivedOverrides[0].BaseMember.Fact.QualifiedName != "Base::greet" { t.Fatalf("Service hierarchy=%+v err=%v", serviceHierarchy, err) } relations, err := service.TypeRelations(t.Context(), graphprotocol.TypeRelationsRequest{Scope: scope, Occurrence: serviceType.Occurrence}) @@ -283,7 +289,6 @@ func TestGraphTypeRelationsSyntheticDirectionAndOccurrences(t *testing.T) { artifact := storageV2Artifact() artifact.ContentHash = nil artifact.Commit = strings.Repeat("8", 40) - artifact.Repository = "s1-06b-t1-synthetic-relations" artifact.Nodes, artifact.Edges, artifact.Files, artifact.Unresolved, artifact.Diagnostics = nil, nil, nil, nil, nil for _, value := range []struct{ occurrence, name, kind string }{ {"root", "makeService", "function"}, {"service", "Service", "class"}, {"greeter", "Greeter", "interface"}, @@ -308,3 +313,76 @@ func TestGraphTypeRelationsSyntheticDirectionAndOccurrences(t *testing.T) { t.Fatalf("synthetic direction/evidence=%+v", got) } } + +func TestGraphHierarchyChildCountScopeAndBounds(t *testing.T) { + artifact := storageV2Artifact() + artifact.ContentHash = nil + artifact.Commit = strings.Repeat("9", 40) + artifact.Nodes, artifact.Edges, artifact.Files, artifact.Unresolved, artifact.Diagnostics = nil, nil, nil, nil, nil + for _, occurrence := range []string{"base", "both", "impl", "sub", "self"} { + artifact.Nodes = append(artifact.Nodes, &graphv2.Node{SourceId: occurrence, Occurrence: occurrence, Name: occurrence, QualifiedName: occurrence, Kind: "class"}) + } + for i, value := range []struct{ source, target, kind string }{ + {"both", "base", "implements"}, {"both", "base", "extends"}, {"impl", "base", "implements"}, + {"impl", "base", "implements"}, {"sub", "base", "extends"}, {"base", "base", "extends"}, {"self", "base", "references"}, + } { + relation, _ := graphartifact.ParseRelationship(value.kind) + artifact.Edges = append(artifact.Edges, &graphv2.Edge{SourceId: strconv.Itoa(i + 1), Occurrence: fmt.Sprintf("count-edge-%d", i+1), Source: value.source, Target: value.target, Kind: relation.WireKind()}) + } + store, service, scope := fileDependencyService(t, artifact) + all, err := service.Entities(t.Context(), graphprotocol.EntitiesRequest{Scope: scope, Limit: 10}) + if err != nil || len(all.Generations) != 1 { + t.Fatalf("generations=%+v err=%v", all.Generations, err) + } + snapshot := graphquery.QuerySnapshot{RepositoryID: scope.SelectedRepositoryID, UploadID: all.Generations[0].UploadID, Commit: artifact.Commit} + + // Distinct subtypes only (extends rows list first, as upstream sortLevel): duplicate occurrences count once, extends wins over + // implements, self edges and other relations are not subtypes. + got, err := store.CountHierarchyChildren(t.Context(), graphquery.HierarchyCountQuery{Snapshot: snapshot, Occurrence: "base"}) + if err != nil || got != (graphquery.HierarchyCount{Subtypes: 3, Implementers: 1}) { + t.Fatalf("base count=%+v err=%v", got, err) + } + hierarchy, err := service.TypeHierarchy(t.Context(), graphprotocol.TypeHierarchyRequest{Scope: scope, Occurrence: "base"}) + if err != nil || !slices.Equal(hierarchyNames(hierarchy.Descendants.Items), []string{"both", "sub", "impl"}) || hierarchy.DirectSubtypes != 3 || hierarchy.DirectImplementers != 1 { + t.Fatalf("base hierarchy descendants=%v direct=%d/%d err=%v", hierarchyNames(hierarchy.Descendants.Items), hierarchy.DirectSubtypes, hierarchy.DirectImplementers, err) + } + for _, entry := range hierarchy.Descendants.Items { + if entry.Entity.Fact.Occurrence == "both" && entry.Relation != "extends" { + t.Fatalf("duplicate relation preference=%+v", entry) + } + } + for name, query := range map[string]graphquery.HierarchyCountQuery{ + "leaf": {Snapshot: snapshot, Occurrence: "sub"}, + "missing": {Snapshot: snapshot, Occurrence: "missing"}, + "commit": {Snapshot: graphquery.QuerySnapshot{RepositoryID: snapshot.RepositoryID, UploadID: snapshot.UploadID, Commit: strings.Repeat("8", 40)}, Occurrence: "base"}, + "upload": {Snapshot: graphquery.QuerySnapshot{RepositoryID: snapshot.RepositoryID, UploadID: snapshot.UploadID + 1000, Commit: snapshot.Commit}, Occurrence: "base"}, + "repository": {Snapshot: graphquery.QuerySnapshot{RepositoryID: snapshot.RepositoryID + 1000, UploadID: snapshot.UploadID, Commit: snapshot.Commit}, Occurrence: "base"}, + } { + if got, err := store.CountHierarchyChildren(t.Context(), query); err != nil || got != (graphquery.HierarchyCount{}) { + t.Fatalf("%s count=%+v err=%v", name, got, err) + } + } + for name, query := range map[string]graphquery.HierarchyCountQuery{ + "occurrence": {Snapshot: snapshot}, + "snapshot": {Snapshot: graphquery.QuerySnapshot{RepositoryID: snapshot.RepositoryID, UploadID: 0, Commit: snapshot.Commit}, Occurrence: "base"}, + } { + if _, err := store.CountHierarchyChildren(t.Context(), query); !errors.Is(err, graphquery.ErrInvalidRequest) { + t.Fatalf("%s invalid count err=%v", name, err) + } + } + canceled, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := store.CountHierarchyChildren(canceled, graphquery.HierarchyCountQuery{Snapshot: snapshot, Occurrence: "base"}); err == nil { + t.Fatal("canceled count succeeded") + } + + // The descendant walk needs one row of lookahead beyond its 400-row page. + neighbors := graphquery.EntityNeighborQuery{Snapshot: snapshot, Occurrence: "base", Relation: "extends", Direction: "incoming", Limit: 401} + if rows, err := store.EntityNeighbors(t.Context(), neighbors); err != nil || len(rows) != 3 { // both, sub and the raw self edge + t.Fatalf("401 lookahead rows=%d err=%v", len(rows), err) + } + neighbors.Limit = 402 + if _, err := store.EntityNeighbors(t.Context(), neighbors); !errors.Is(err, graphquery.ErrInvalidRequest) { + t.Fatalf("402 lookahead err=%v", err) + } +} From 8a3560b3a40048c9b375a0fa77faa0c57bf0db94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Thu, 24 Sep 2026 17:35:10 +0200 Subject: [PATCH 5/7] docs(graph): describe type relations and hierarchy analysis Co-Authored-By: Claude Opus 5.5 --- docs/graph-analysis.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/graph-analysis.md b/docs/graph-analysis.md index 7e88bf24..17b6c396 100644 --- a/docs/graph-analysis.md +++ b/docs/graph-analysis.md @@ -139,3 +139,17 @@ PostgreSQL applies repository, upload, commit, and schema constraints to both en Every A3 response reports analysis state. `freshness=current` means current relative to the selected indexed commit. `complete=true` means the bounded query finished; `partial` and its boundaries independently report omitted graph results. `coverage=not_assessed` states that these operations do not establish producer or source-analysis coverage, and `unresolved` exposes the selected generation's unresolved-reference count. Impact processes dependency levels and closes same-depth containment before advancing. This intentionally differs from the pinned source's depth-first traversal, which finalizes a node's first depth and can omit descendants when a later containment path reaches that node more cheaply. Accepted producer captures remain unchanged; GraphNest reports the shortest retained dependency depth and truthful within-hop reachability. + +## Type relations and hierarchy + +`graphquery.Service` adds two generation-scoped operations, `TypeRelations` and `TypeHierarchy`; `graphservice.Service` authorizes them and rechecks the selected commit like the other analysis operations. They are service-level only; REST, MCP, and browser exposure remain transport work. + +Type relations return recorded outgoing `type_of`/`returns` targets, incoming `type_of` users, incoming `returns` returners, outgoing `references` whose target is a type kind, and recorded `overrides`. Each group keeps every original edge occurrence rather than collapsing repeated evidence. `type_knowledge=unknown` means the producer recorded no outgoing type facts for the entity; it is not a claim that the entity is untyped. The pinned portable producer emitted no `type_of` or `returns` edges for the accepted fixtures, so they report `unknown`. + +Type hierarchy walks `extends` and `implements` for class, interface, struct, trait, protocol, enum, type alias, and union entities. Ancestors are complete to depth 8; descendants are breadth-first to depth 6 and at most 400 materialized rows, of which the viewer shows 24 ancestors and 240 descendants while reporting `total`, `shown`, and `truncated`. Each level lists `extends` before `implements`, then name, path, and line, as the pinned source does. A type tied to one supertype by both relations appears once, as `extends`. Cycles and duplicate occurrences are visited once per walk. When rows or depth stop the walk, `bounded`, `partial`, the boundary, and each parent's `hidden_subtypes` say so instead of presenting the last row as a leaf. Synthesized Go implicit implementations keep `synthesized`, `via`, `registered_at`, and their heuristic provenance. + +`direct_subtypes` and `direct_implementers` count distinct direct subtypes in PostgreSQL, independent of the row bound, so they stay true when the list is truncated. A subtype with both relations counts as `extends`; the pinned source counts it by whichever edge it reads first. `polymorphic` is set at eight direct implementers. + +Derived overrides match the focus's method, function, property, and field members by name against the nearest of up to 12 ancestors' members. They are always `signature_uncertain`, because overloads and signatures are not compared, and are reported separately from recorded `overrides` edges. Names sort by byte order; the pinned source uses locale comparison, which can order mixed-case names differently. + +Permanent conformance uses the accepted real-producer capture in `test/fixtures/codegraph/type-hierarchy-facts.json` (37 nodes, 53 edges, 3 files, 5 unresolved references) with its four source files, whose hashes the integration test verifies, plus the pinned library fixture's `Base`/`Service` hierarchy. The capture was produced by the pinned CodeGraph at `b9ca4b7981116909900368cc1686a1074cd4d4c1` by indexing those sources twice and comparing both passes. Synthetic topologies cover bounds, cycles, direction, and recorded type relations; they are not producer output. From bdaf6cf73cf42c4d7440a52aabd850a527179fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Thu, 24 Sep 2026 17:35:10 +0200 Subject: [PATCH 6/7] docs(codegraph): record S1.06b1 type hierarchy progress Co-Authored-By: Claude Opus 5.5 --- docs/execplans/codegraph-parity.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/execplans/codegraph-parity.md b/docs/execplans/codegraph-parity.md index 387fe13e..c1cf0733 100644 --- a/docs/execplans/codegraph-parity.md +++ b/docs/execplans/codegraph-parity.md @@ -5,6 +5,16 @@ Implementation, validation, draft publication, and release are separate states. ## Progress +- 2026-09-24: Resumed the paused S1.06b1 type relations and hierarchy layer + on `feat/codegraph/type-hierarchy`, based on `main`. PostgreSQL now counts + distinct direct subtypes, and the internal neighbor lookahead allows 401 rows + for the 400-row descendant walk. The integration test's capture translation + was corrected: the artifact keeps the store's repository ID, and store evidence + is compared by fact. The `Base` edge column expectation is 29, which matches + `core.ts`. The accepted CD02 capture, the library `Base`/`Service` hierarchy, + the synthetic direction controls and the count/scope/cancellation/bound + checks pass. Service-level only: no REST, MCP or browser exposure. B2 dead + code and the rest of S1.06 remain open. - 2026-09-14: The partial S1.07 discovery transport milestone exposes bounded v2 discovery, exploration, file inventory, and versioned capabilities over bearer-only REST and MCP while preserving existing context/impact/trace @@ -610,6 +620,10 @@ The rebased sessions layer also passes the exact two-call restoration comparison ## Remaining gaps +- S1.06b1 type hierarchy is implemented at the service level only. Level + ordering uses byte order where the pinned source uses locale comparison, and + a subtype with both `extends` and `implements` counts as `extends` rather than + by the first edge read. Transport exposure belongs to S1.07/S1.09. - Representative reference captures are mapped to inventory task IDs; remaining conformance variants stay planned with their owning stages, comparison contracts and upstream tests. @@ -649,6 +663,7 @@ The rebased sessions layer also passes the exact two-call restoration comparison | S1.06a1 file dependencies | `feat/codegraph/s1-06a1-file-dependencies` | Implemented, independently reviewed and signed; depends on PR #78 | Draft [PR #79](https://github.com/balcsida/graphnest/pull/79); native stack #66, position 15 | | S1.06a2 graph aggregates | `feat/codegraph/s1-06a2-graph-aggregates` | Implemented, independently reviewed and signed (`85dee2e`); depends on PR #79 | Draft [PR #81](https://github.com/balcsida/graphnest/pull/81); native stack #66, position 16; CI and CodeQL passed | | S1.06a3 entity impact | `feat/codegraph/s1-06a3-entity-impact` | Implemented, independently approved and signed (`931e7d9`); depends on PR #81 | Draft [PR #82](https://github.com/balcsida/graphnest/pull/82); native stack #66, position 17; CI and CodeQL passed | +| S1.06b1 type relations and hierarchy | `feat/codegraph/type-hierarchy` | Implemented; focused unit, service and PostgreSQL checks pass; based on `main` | Draft PR pending | The first one-branch submission created a draft PR without a remote stack. Submitting the second real dependent layer created native stack #66 From 282f008d54dc9bf3d4aba578971a6e2f102ad5bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Thu, 24 Sep 2026 17:36:10 +0200 Subject: [PATCH 7/7] docs(codegraph): link S1.06b1 record to PR #119 Co-Authored-By: Claude Opus 5.5 --- docs/execplans/codegraph-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/execplans/codegraph-parity.md b/docs/execplans/codegraph-parity.md index c1cf0733..e6316b59 100644 --- a/docs/execplans/codegraph-parity.md +++ b/docs/execplans/codegraph-parity.md @@ -663,7 +663,7 @@ The rebased sessions layer also passes the exact two-call restoration comparison | S1.06a1 file dependencies | `feat/codegraph/s1-06a1-file-dependencies` | Implemented, independently reviewed and signed; depends on PR #78 | Draft [PR #79](https://github.com/balcsida/graphnest/pull/79); native stack #66, position 15 | | S1.06a2 graph aggregates | `feat/codegraph/s1-06a2-graph-aggregates` | Implemented, independently reviewed and signed (`85dee2e`); depends on PR #79 | Draft [PR #81](https://github.com/balcsida/graphnest/pull/81); native stack #66, position 16; CI and CodeQL passed | | S1.06a3 entity impact | `feat/codegraph/s1-06a3-entity-impact` | Implemented, independently approved and signed (`931e7d9`); depends on PR #81 | Draft [PR #82](https://github.com/balcsida/graphnest/pull/82); native stack #66, position 17; CI and CodeQL passed | -| S1.06b1 type relations and hierarchy | `feat/codegraph/type-hierarchy` | Implemented; focused unit, service and PostgreSQL checks pass; based on `main` | Draft PR pending | +| S1.06b1 type relations and hierarchy | `feat/codegraph/type-hierarchy` | Implemented; focused unit, service and PostgreSQL checks pass; based on `main` | [PR #119](https://github.com/balcsida/graphnest/pull/119) | The first one-branch submission created a draft PR without a remote stack. Submitting the second real dependent layer created native stack #66