feat(typescript): make object-type aliases own their members

A TS object-type alias declares the same `property_signature` members as the
interface beside it and answers the same question, but was not a member
owner: its fields were minted with bare ids and no owner edge, so two
aliases in one file sharing a field name collapsed onto one node, while the
identical interface resolved normally.

`type_alias_declaration` joins CLASS_CONTAINER_TYPES (and
CONTAINER_TYPE_TO_LABEL, as that set's invariant requires — a container
missing there gets orphaned member edges or a wrong owner label). Aliases
with no object type (`type Id = string`) declare no members, so they own
nothing and are unaffected.

This also lands the INTERFACE field -> consumer edges, verified on the
mini-repo fixture rather than only on a purpose-built one: `saveToDb` now
links to `ValidationResult.value`, and `formatLogEntry` to `LogEntry.level`
and `LogEntry.message` — three real contract-field reads that previously had
no graph path at all. Golden updated: +3 ACCESSES, no node changes.

The ALIAS field -> consumer edge is still not linked and is recorded as a
todo with the exact blocker: resolving a receiver typed as the alias needs
the NAME to resolve to a class-like def, and `isClassLike` is
Class|Interface|Struct|Record|Enum|Trait. That predicate is read from ~12
sites including MRO and heritage, and every language mints TypeAlias, so
widening it would enrol aliases in linearizations where they do not belong.
Widening only the scope index was tried and reverted — the type-name walkers
gate on it independently, so it fixed nothing and left dead code. That needs
a deliberate "shape-like" concept, not more call-site widening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-06 02:27:15 +03:00
parent 6ec8dfc3cd
commit 448868f891
3 changed files with 29 additions and 5 deletions

View file

@ -335,6 +335,15 @@ export const CLASS_CONTAINER_TYPES = new Set([
'class_declaration',
'abstract_class_declaration',
'interface_declaration',
// A TypeScript object-type alias owns its members exactly as the interface
// beside it does — same `property_signature` members, same "who reads this
// contract field?" question. Without it an alias member is minted with a
// bare id and no owner, so two aliases in one file sharing a field name
// collapse onto one node and nothing links the field to its consumers,
// while the identical interface resolves. Aliases with no object type
// (`type Id = string`) declare no members, so they own nothing and are
// unaffected.
'type_alias_declaration',
'struct_declaration',
'record_declaration',
'class_specifier',
@ -398,6 +407,9 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
class_declaration: 'Class',
abstract_class_declaration: 'Class',
interface_declaration: 'Interface',
// Required by the CLASS_CONTAINER_TYPES invariant above: a container missing
// here gets orphaned member edges or a wrong owner label.
type_alias_declaration: 'TypeAlias',
struct_declaration: 'Struct',
struct_specifier: 'Struct',
class_specifier: 'Class',

View file

@ -3,7 +3,7 @@
"fixture": "mini-repo",
"totalFileCount": 7,
"symbols": 41,
"relationships": 77,
"relationships": 80,
"processes": 4,
"byType": {
"Class": 1,
@ -17,6 +17,7 @@
"Property": 8
},
"byRelType": {
"ACCESSES": 3,
"CALLS": 9,
"CONTAINS": 7,
"DEFINES": 16,
@ -26,5 +27,5 @@
"MEMBER_OF": 12,
"STEP_IN_PROCESS": 12
},
"edgeDigest": "1ccfc9e5de04d7e71430bcfeb3ce61fe85135b826f33aad2b1bdd2ee193e4728"
"edgeDigest": "d617ad94554d760eb0497bf5365b7b0a2e21842ab5b16568def721b954aa1594"
}

View file

@ -73,8 +73,19 @@ describe('TypeScript type-alias and interface members (A4)', () => {
// `fieldFallbackOnMethodLookup: false` (scope-resolver.ts) because name
// matching over-connects in a typed language, and the unique-name pass
// honors that opt-out. The precise path is the only route for TS, by design.
it.todo('links an alias field to its consumer');
it.todo('links an interface field to its consumer');
it('links an interface field to its consumer', () => {
expect(readersOf('ifaceSlots')).toContain('renderIface');
});
void readersOf;
// The ALIAS half still does not link, and the remaining blocker is now
// exact: resolving `cfg: LiveModeConfig` to its members requires the name
// `LiveModeConfig` to resolve to a CLASS-LIKE def, and `isClassLike` is
// Class|Interface|Struct|Record|Enum|Trait — no TypeAlias. That predicate is
// consulted from ~12 sites including MRO and heritage, and every language
// mints TypeAlias (Rust type_item, Kotlin/Swift/Dart typealias, C typedef),
// so widening it would enrol aliases in linearization where they do not
// belong. Widening only the scope index was tried and is NOT sufficient —
// the type-name walkers gate on it independently. Needs a deliberate
// "shape-like" concept rather than more call-site widening.
it.todo('links an alias field to its consumer');
});