GitNexus/gitnexus/test/unit/symbol-table.test.ts
Gergő Magyar 973c7bfbf0
feat: ACCESSES edge type with read/write field access tracking (#372)
* feat: Phase 1 ACCESSES edge type — read tracking from chain resolution

Add ACCESSES relationship type to track field read access during call
chain resolution. When walkMixedChain resolves a field access (e.g.,
user.address.save()), an ACCESSES edge with reason 'read' is emitted
from the calling function to the Property node.

Schema: ACCESSES added to RelationshipType, REL_TYPES, VALID_RELATION_TYPES,
context queries, tools/resources descriptions. Excluded from default
impact BFS to prevent traversal explosion.

Implementation: resolveFieldAccessType now returns FieldResolution with
fieldNodeId. walkMixedChain accepts optional onFieldResolved callback.
makeAccessEmitter factory provides Set-based dedup per source node.

Bug fix: Added Java 'field_access' to FIELD_ACCESS_NODE_TYPES — was
missing, causing extractMixedChain to fail for Java member access.

* feat: Phase 2 ACCESSES write edges — assignment detection across 12 languages

Add tree-sitter query patterns for field write detection (obj.field = value)
across all supported languages: TS/JS, Python, Java, Go, C++, C#, Rust,
PHP, Ruby (setter syntax), Kotlin, Swift.

Processing: Sequential path handles assignment captures inline. Worker
path extracts ExtractedAssignment data for deferred resolution via new
processAssignmentsFromExtracted function.

Bug fix: Kotlin/Swift assignment queries used invalid navigation_expression
wrapper — fixed to match actual directly_assignable_expression AST structure.

Tests: Write access integration tests for TS, Java, Python, Go with
dedicated fixtures. All use strict toBe() assertions.

* test: add unit tests for call-routing, shared type extractors, and symbol-table branches

Add 215 new unit tests across 3 files to increase branch coverage toward
the 23% global threshold (was 21.49%):

- call-routing.test.ts (49 tests): Ruby call routing — require/require_relative,
  include/extend/prepend heritage, attr_accessor properties with YARD types
- shared-type-extractors.test.ts (108 tests): pure string functions —
  extractElementTypeFromString, stripNullable, extractReturnTypeName,
  methodToTypeArgPosition, getContainerDescriptor
- symbol-table.test.ts (+29 tests): Property/fieldByOwner index, metadata
  spread branches, lazy callable index, lookupExactFull shape

* fix: defer write-access resolution to fix Ruby cross-file property timing

Ruby attr_accessor properties are registered during processCalls (not
the parsing phase), so lookupFieldByOwner fails when service.rb is
processed before models.rb. Fix by collecting pending write-access
edges during the file loop and resolving them after all files are done.

Also adds write-access integration tests and fixtures for 7 languages
(C++, C#, JS, Kotlin, PHP, Ruby, Rust), Ruby compound assignment query,
PHP static property write query, and Kotlin property type extraction.

* fix: address PR #372 review — write-access constructor bindings parity and docs

- Add verified constructor bindings fallback to write-access resolution
  in both sequential path (receiverIndex lookup) and worker path
  (constructorBindings param for processAssignmentsFromExtracted),
  closing the read/write ACCESSES edge asymmetry for factory-returned
  receivers
- Clarify inner guard control flow comment in processCalls match loop
- Document Go inc_statement/dec_statement gap in roadmap
- Clarify PHP nullsafe write footnote (invalid syntax, not just untracked)
- Update symbol-table tests for intentional fieldByOwner behavior change
  (Properties without declaredType now indexed for dynamic language
  write-access tracking)
2026-03-19 03:48:04 +05:30

555 lines
23 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import { createSymbolTable, type SymbolTable } from '../../src/core/ingestion/symbol-table.js';
describe('SymbolTable', () => {
let table: SymbolTable;
beforeEach(() => {
table = createSymbolTable();
});
describe('add', () => {
it('registers a symbol in the table', () => {
table.add('src/index.ts', 'main', 'func:main', 'Function');
expect(table.getStats().globalSymbolCount).toBe(1);
expect(table.getStats().fileCount).toBe(1);
});
it('handles multiple symbols in the same file', () => {
table.add('src/index.ts', 'main', 'func:main', 'Function');
table.add('src/index.ts', 'helper', 'func:helper', 'Function');
expect(table.getStats().fileCount).toBe(1);
expect(table.getStats().globalSymbolCount).toBe(2);
});
it('handles same name in different files', () => {
table.add('src/a.ts', 'init', 'func:a:init', 'Function');
table.add('src/b.ts', 'init', 'func:b:init', 'Function');
expect(table.getStats().fileCount).toBe(2);
// Global index groups by name, so 'init' has one entry with two definitions
expect(table.getStats().globalSymbolCount).toBe(1);
});
it('allows duplicate adds for same file and name', () => {
table.add('src/a.ts', 'foo', 'func:foo:1', 'Function');
table.add('src/a.ts', 'foo', 'func:foo:2', 'Function');
// File index overwrites: last wins
expect(table.lookupExact('src/a.ts', 'foo')).toBe('func:foo:2');
// Global index appends
expect(table.lookupFuzzy('foo')).toHaveLength(2);
});
});
describe('lookupExact', () => {
it('finds a symbol by file path and name', () => {
table.add('src/index.ts', 'main', 'func:main', 'Function');
expect(table.lookupExact('src/index.ts', 'main')).toBe('func:main');
});
it('returns undefined for unknown file', () => {
table.add('src/index.ts', 'main', 'func:main', 'Function');
expect(table.lookupExact('src/other.ts', 'main')).toBeUndefined();
});
it('returns undefined for unknown symbol name', () => {
table.add('src/index.ts', 'main', 'func:main', 'Function');
expect(table.lookupExact('src/index.ts', 'notExist')).toBeUndefined();
});
it('returns undefined for empty table', () => {
expect(table.lookupExact('src/index.ts', 'main')).toBeUndefined();
});
});
describe('lookupFuzzy', () => {
it('finds all definitions of a symbol across files', () => {
table.add('src/a.ts', 'render', 'func:a:render', 'Function');
table.add('src/b.ts', 'render', 'func:b:render', 'Method');
const results = table.lookupFuzzy('render');
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ nodeId: 'func:a:render', filePath: 'src/a.ts', type: 'Function' });
expect(results[1]).toEqual({ nodeId: 'func:b:render', filePath: 'src/b.ts', type: 'Method' });
});
it('returns empty array for unknown symbol', () => {
expect(table.lookupFuzzy('nonexistent')).toEqual([]);
});
it('returns empty array for empty table', () => {
expect(table.lookupFuzzy('anything')).toEqual([]);
});
});
describe('getStats', () => {
it('returns zero counts for empty table', () => {
expect(table.getStats()).toEqual({ fileCount: 0, globalSymbolCount: 0 });
});
it('tracks unique file count correctly', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
table.add('src/a.ts', 'bar', 'func:bar', 'Function');
table.add('src/b.ts', 'baz', 'func:baz', 'Function');
expect(table.getStats().fileCount).toBe(2);
});
it('tracks unique global symbol names', () => {
table.add('src/a.ts', 'foo', 'func:a:foo', 'Function');
table.add('src/b.ts', 'foo', 'func:b:foo', 'Function');
table.add('src/a.ts', 'bar', 'func:a:bar', 'Function');
// 'foo' and 'bar' are 2 unique global names
expect(table.getStats().globalSymbolCount).toBe(2);
});
});
describe('returnType metadata', () => {
it('stores returnType in SymbolDefinition', () => {
table.add('src/utils.ts', 'getUser', 'func:getUser', 'Function', { returnType: 'User' });
const def = table.lookupExactFull('src/utils.ts', 'getUser');
expect(def).toBeDefined();
expect(def!.returnType).toBe('User');
});
it('returnType is available via lookupFuzzy', () => {
table.add('src/utils.ts', 'getUser', 'func:getUser', 'Function', { returnType: 'Promise<User>' });
const results = table.lookupFuzzy('getUser');
expect(results).toHaveLength(1);
expect(results[0].returnType).toBe('Promise<User>');
});
it('omits returnType when not provided', () => {
table.add('src/utils.ts', 'helper', 'func:helper', 'Function');
const def = table.lookupExactFull('src/utils.ts', 'helper');
expect(def).toBeDefined();
expect(def!.returnType).toBeUndefined();
});
it('stores returnType alongside parameterCount and ownerId', () => {
table.add('src/models.ts', 'save', 'method:save', 'Method', {
parameterCount: 1,
returnType: 'boolean',
ownerId: 'class:User',
});
const def = table.lookupExactFull('src/models.ts', 'save');
expect(def).toBeDefined();
expect(def!.parameterCount).toBe(1);
expect(def!.returnType).toBe('boolean');
expect(def!.ownerId).toBe('class:User');
});
});
describe('declaredType metadata', () => {
it('stores declaredType in SymbolDefinition', () => {
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
declaredType: 'Address',
ownerId: 'class:User',
});
const def = table.lookupExactFull('src/models.ts', 'address');
expect(def).toBeDefined();
expect(def!.declaredType).toBe('Address');
});
it('omits declaredType when not provided', () => {
table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' });
const def = table.lookupExactFull('src/models.ts', 'name');
expect(def).toBeDefined();
expect(def!.declaredType).toBeUndefined();
});
});
describe('Property exclusion from globalIndex', () => {
it('Property with ownerId is NOT added to globalIndex', () => {
table.add('src/models.ts', 'name', 'prop:name', 'Property', {
declaredType: 'string',
ownerId: 'class:User',
});
// Should not appear in fuzzy lookup
expect(table.lookupFuzzy('name')).toEqual([]);
// But should still be in fileIndex
expect(table.lookupExact('src/models.ts', 'name')).toBe('prop:name');
});
it('Property without ownerId IS added to globalIndex', () => {
table.add('src/models.ts', 'name', 'prop:name', 'Property');
expect(table.lookupFuzzy('name')).toHaveLength(1);
});
it('Property without declaredType is still added to fieldByOwner index only (not globalIndex)', () => {
table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' });
// No declaredType → still indexed in fieldByOwner (for write-access tracking
// in dynamically-typed languages like Ruby/JS), but excluded from globalIndex
expect(table.lookupFuzzy('name')).toEqual([]);
expect(table.lookupFieldByOwner('class:User', 'name')).toEqual({
nodeId: 'prop:name',
filePath: 'src/models.ts',
type: 'Property',
ownerId: 'class:User',
});
});
it('non-Property types are always added to globalIndex', () => {
table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:User' });
expect(table.lookupFuzzy('save')).toHaveLength(1);
});
});
describe('conditional callableIndex invalidation', () => {
it('adding a Function invalidates callableIndex', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function', { returnType: 'void' });
// First call builds the index
expect(table.lookupFuzzyCallable('foo')).toHaveLength(1);
// Add another callable — should invalidate and rebuild
table.add('src/a.ts', 'bar', 'func:bar', 'Method');
expect(table.lookupFuzzyCallable('bar')).toHaveLength(1);
});
it('adding a Property does NOT invalidate callableIndex', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
// Build callable index
expect(table.lookupFuzzyCallable('foo')).toHaveLength(1);
// Add a Property — callable index should still be valid (foo still found)
table.add('src/models.ts', 'name', 'prop:name', 'Property', {
declaredType: 'string',
ownerId: 'class:User',
});
expect(table.lookupFuzzyCallable('foo')).toHaveLength(1);
});
it('adding a Class does NOT invalidate callableIndex', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
expect(table.lookupFuzzyCallable('foo')).toHaveLength(1);
table.add('src/models.ts', 'User', 'class:User', 'Class');
// Class is not callable, should not trigger rebuild
expect(table.lookupFuzzyCallable('foo')).toHaveLength(1);
});
});
describe('lookupFieldByOwner', () => {
it('finds a Property by ownerNodeId and fieldName', () => {
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
declaredType: 'Address',
ownerId: 'class:User',
});
const def = table.lookupFieldByOwner('class:User', 'address');
expect(def).toBeDefined();
expect(def!.declaredType).toBe('Address');
expect(def!.nodeId).toBe('prop:address');
});
it('returns undefined for unknown owner', () => {
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
declaredType: 'Address',
ownerId: 'class:User',
});
expect(table.lookupFieldByOwner('class:Unknown', 'address')).toBeUndefined();
});
it('returns undefined for unknown field name', () => {
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
declaredType: 'Address',
ownerId: 'class:User',
});
expect(table.lookupFieldByOwner('class:User', 'email')).toBeUndefined();
});
it('returns undefined for empty table', () => {
expect(table.lookupFieldByOwner('class:User', 'name')).toBeUndefined();
});
it('indexes Property without declaredType (for dynamic language write-access)', () => {
table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' });
expect(table.lookupFieldByOwner('class:User', 'name')).toEqual({
nodeId: 'prop:name',
filePath: 'src/models.ts',
type: 'Property',
ownerId: 'class:User',
});
});
it('distinguishes fields by owner', () => {
table.add('src/models.ts', 'name', 'prop:user:name', 'Property', {
declaredType: 'string',
ownerId: 'class:User',
});
table.add('src/models.ts', 'name', 'prop:repo:name', 'Property', {
declaredType: 'RepoName',
ownerId: 'class:Repo',
});
expect(table.lookupFieldByOwner('class:User', 'name')!.declaredType).toBe('string');
expect(table.lookupFieldByOwner('class:Repo', 'name')!.declaredType).toBe('RepoName');
});
});
describe('lookupFuzzyCallable', () => {
it('returns only callable types (Function, Method, Constructor)', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
table.add('src/a.ts', 'bar', 'method:bar', 'Method');
table.add('src/a.ts', 'Baz', 'ctor:Baz', 'Constructor');
table.add('src/a.ts', 'User', 'class:User', 'Class');
table.add('src/a.ts', 'IUser', 'iface:IUser', 'Interface');
expect(table.lookupFuzzyCallable('foo')).toHaveLength(1);
expect(table.lookupFuzzyCallable('bar')).toHaveLength(1);
expect(table.lookupFuzzyCallable('Baz')).toHaveLength(1);
expect(table.lookupFuzzyCallable('User')).toEqual([]);
expect(table.lookupFuzzyCallable('IUser')).toEqual([]);
});
it('returns empty array for unknown name', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
expect(table.lookupFuzzyCallable('unknown')).toEqual([]);
});
it('rebuilds index after adding new callable', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
expect(table.lookupFuzzyCallable('foo')).toHaveLength(1);
expect(table.lookupFuzzyCallable('bar')).toEqual([]);
table.add('src/a.ts', 'bar', 'func:bar', 'Function');
expect(table.lookupFuzzyCallable('bar')).toHaveLength(1);
});
it('filters non-callable types from mixed name entries', () => {
table.add('src/a.ts', 'save', 'func:save', 'Function');
table.add('src/b.ts', 'save', 'class:save', 'Class');
const callables = table.lookupFuzzyCallable('save');
expect(callables).toHaveLength(1);
expect(callables[0].type).toBe('Function');
});
});
describe('clear', () => {
it('resets all state including fieldByOwner', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
table.add('src/b.ts', 'bar', 'func:bar', 'Function');
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
declaredType: 'Address',
ownerId: 'class:User',
});
table.clear();
expect(table.getStats()).toEqual({ fileCount: 0, globalSymbolCount: 0 });
expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined();
expect(table.lookupFuzzy('foo')).toEqual([]);
expect(table.lookupFieldByOwner('class:User', 'address')).toBeUndefined();
expect(table.lookupFuzzyCallable('foo')).toEqual([]);
});
it('allows re-adding after clear', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
table.clear();
table.add('src/b.ts', 'bar', 'func:bar', 'Function');
expect(table.getStats()).toEqual({ fileCount: 1, globalSymbolCount: 1 });
});
it('resets callableIndex so first lookup after clear rebuilds from scratch', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
// Populate the lazy callable index
expect(table.lookupFuzzyCallable('foo')).toHaveLength(1);
table.clear();
// After clear the callable index must be gone — empty table returns nothing
expect(table.lookupFuzzyCallable('foo')).toEqual([]);
// Re-adding and looking up rebuilds successfully
table.add('src/a.ts', 'foo', 'func:foo2', 'Function');
expect(table.lookupFuzzyCallable('foo')).toHaveLength(1);
expect(table.lookupFuzzyCallable('foo')[0].nodeId).toBe('func:foo2');
});
});
describe('metadata spread branches (individual optional fields)', () => {
it('stores only parameterCount when no other metadata is given', () => {
table.add('src/utils.ts', 'compute', 'func:compute', 'Function', { parameterCount: 3 });
const def = table.lookupExactFull('src/utils.ts', 'compute');
expect(def).toBeDefined();
expect(def!.parameterCount).toBe(3);
expect(def!.returnType).toBeUndefined();
expect(def!.declaredType).toBeUndefined();
expect(def!.ownerId).toBeUndefined();
});
it('stores only ownerId on a Method (non-Property) — still added to globalIndex', () => {
table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:Repo' });
const def = table.lookupExactFull('src/models.ts', 'save');
expect(def).toBeDefined();
expect(def!.ownerId).toBe('class:Repo');
expect(def!.parameterCount).toBeUndefined();
expect(def!.returnType).toBeUndefined();
expect(def!.declaredType).toBeUndefined();
// Non-Property with ownerId must still appear in globalIndex
expect(table.lookupFuzzy('save')).toHaveLength(1);
});
it('stores declaredType alone (no ownerId) — symbol goes to globalIndex', () => {
// A Variable/Property without an owner should still be globally visible
table.add('src/config.ts', 'DEFAULT_TIMEOUT', 'var:DEFAULT_TIMEOUT', 'Variable', {
declaredType: 'number',
});
const def = table.lookupExactFull('src/config.ts', 'DEFAULT_TIMEOUT');
expect(def).toBeDefined();
expect(def!.declaredType).toBe('number');
expect(def!.ownerId).toBeUndefined();
// No ownerId → not a Property exclusion path → must be in globalIndex
expect(table.lookupFuzzy('DEFAULT_TIMEOUT')).toHaveLength(1);
expect(table.lookupFuzzy('DEFAULT_TIMEOUT')[0].declaredType).toBe('number');
});
it('stores all four optional metadata fields simultaneously on a Method', () => {
table.add('src/models.ts', 'find', 'method:find', 'Method', {
parameterCount: 2,
returnType: 'User | undefined',
declaredType: 'QueryResult',
ownerId: 'class:UserRepository',
});
const def = table.lookupExactFull('src/models.ts', 'find');
expect(def).toBeDefined();
expect(def!.parameterCount).toBe(2);
expect(def!.returnType).toBe('User | undefined');
expect(def!.declaredType).toBe('QueryResult');
expect(def!.ownerId).toBe('class:UserRepository');
});
it('omits all optional fields when metadata is not provided at all', () => {
table.add('src/utils.ts', 'noop', 'func:noop', 'Function');
const def = table.lookupExactFull('src/utils.ts', 'noop');
expect(def).toBeDefined();
expect(def!.parameterCount).toBeUndefined();
expect(def!.returnType).toBeUndefined();
expect(def!.declaredType).toBeUndefined();
expect(def!.ownerId).toBeUndefined();
});
it('stores parameterCount: 0 (falsy value) correctly', () => {
// parameterCount of 0 must not be dropped by the spread guard
table.add('src/utils.ts', 'noArgs', 'func:noArgs', 'Function', { parameterCount: 0 });
const def = table.lookupExactFull('src/utils.ts', 'noArgs');
expect(def).toBeDefined();
expect(def!.parameterCount).toBe(0);
});
});
describe('lookupFuzzyCallable — lazy index behaviour', () => {
it('returns empty array when table has no callables', () => {
table.add('src/models.ts', 'User', 'class:User', 'Class');
table.add('src/models.ts', 'IUser', 'iface:IUser', 'Interface');
expect(table.lookupFuzzyCallable('User')).toEqual([]);
expect(table.lookupFuzzyCallable('IUser')).toEqual([]);
});
it('uses cached index on second call without adding new symbols', () => {
table.add('src/a.ts', 'fetch', 'func:fetch', 'Function', { returnType: 'Response' });
// First call — builds the lazy index
const first = table.lookupFuzzyCallable('fetch');
expect(first).toHaveLength(1);
// Second call — must return equivalent result from cache
const second = table.lookupFuzzyCallable('fetch');
expect(second).toHaveLength(1);
expect(second[0].nodeId).toBe('func:fetch');
// Both calls return the same array reference (same cache entry)
expect(first).toBe(second);
});
it('invalidated cache is rebuilt correctly after adding a Method', () => {
table.add('src/a.ts', 'alpha', 'func:alpha', 'Function');
// Warm the cache
expect(table.lookupFuzzyCallable('alpha')).toHaveLength(1);
expect(table.lookupFuzzyCallable('beta')).toEqual([]);
// Add a Method — must invalidate cache
table.add('src/a.ts', 'beta', 'method:beta', 'Method');
// Rebuilt cache must now include beta
const result = table.lookupFuzzyCallable('beta');
expect(result).toHaveLength(1);
expect(result[0].type).toBe('Method');
});
it('invalidated cache is rebuilt correctly after adding a Constructor', () => {
table.add('src/a.ts', 'existing', 'func:existing', 'Function');
expect(table.lookupFuzzyCallable('existing')).toHaveLength(1);
table.add('src/models.ts', 'MyClass', 'ctor:MyClass', 'Constructor');
expect(table.lookupFuzzyCallable('MyClass')).toHaveLength(1);
expect(table.lookupFuzzyCallable('MyClass')[0].type).toBe('Constructor');
});
});
describe('lookupExactFull — full SymbolDefinition shape', () => {
it('returns undefined for unknown file', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
expect(table.lookupExactFull('src/other.ts', 'foo')).toBeUndefined();
});
it('returns undefined for unknown symbol name within a known file', () => {
table.add('src/a.ts', 'foo', 'func:foo', 'Function');
expect(table.lookupExactFull('src/a.ts', 'bar')).toBeUndefined();
});
it('returns undefined for empty table', () => {
expect(table.lookupExactFull('src/a.ts', 'foo')).toBeUndefined();
});
it('returns the full SymbolDefinition including nodeId, filePath, and type', () => {
table.add('src/models.ts', 'address', 'prop:address', 'Property', {
declaredType: 'Address',
ownerId: 'class:User',
});
const def = table.lookupExactFull('src/models.ts', 'address');
expect(def).toBeDefined();
expect(def!.nodeId).toBe('prop:address');
expect(def!.filePath).toBe('src/models.ts');
expect(def!.type).toBe('Property');
expect(def!.declaredType).toBe('Address');
expect(def!.ownerId).toBe('class:User');
});
it('last-write-wins when same file and name are added twice', () => {
table.add('src/a.ts', 'foo', 'func:foo:v1', 'Function', { returnType: 'void' });
table.add('src/a.ts', 'foo', 'func:foo:v2', 'Function', { returnType: 'string' });
const def = table.lookupExactFull('src/a.ts', 'foo');
expect(def).toBeDefined();
expect(def!.nodeId).toBe('func:foo:v2');
expect(def!.returnType).toBe('string');
});
});
describe('lookupFieldByOwner — additional coverage', () => {
it('stores multiple distinct fields under the same owner', () => {
table.add('src/models.ts', 'id', 'prop:user:id', 'Property', {
declaredType: 'number',
ownerId: 'class:User',
});
table.add('src/models.ts', 'email', 'prop:user:email', 'Property', {
declaredType: 'string',
ownerId: 'class:User',
});
table.add('src/models.ts', 'createdAt', 'prop:user:createdAt', 'Property', {
declaredType: 'Date',
ownerId: 'class:User',
});
expect(table.lookupFieldByOwner('class:User', 'id')!.declaredType).toBe('number');
expect(table.lookupFieldByOwner('class:User', 'email')!.declaredType).toBe('string');
expect(table.lookupFieldByOwner('class:User', 'createdAt')!.declaredType).toBe('Date');
});
it('returns the full SymbolDefinition (nodeId + filePath + type) not just declaredType', () => {
table.add('src/models.ts', 'score', 'prop:score', 'Property', {
declaredType: 'number',
ownerId: 'class:Player',
});
const def = table.lookupFieldByOwner('class:Player', 'score');
expect(def).toBeDefined();
expect(def!.nodeId).toBe('prop:score');
expect(def!.filePath).toBe('src/models.ts');
expect(def!.type).toBe('Property');
});
it('key collision is impossible between different owners sharing a field name', () => {
// Ensures the null-byte separator in the key prevents cross-owner leakage
table.add('src/models.ts', 'id', 'prop:a:id', 'Property', {
declaredType: 'string',
ownerId: 'class:A',
});
table.add('src/models.ts', 'id', 'prop:b:id', 'Property', {
declaredType: 'UUID',
ownerId: 'class:B',
});
expect(table.lookupFieldByOwner('class:A', 'id')!.nodeId).toBe('prop:a:id');
expect(table.lookupFieldByOwner('class:B', 'id')!.nodeId).toBe('prop:b:id');
// An owner whose id is the concatenation of A's ownerId + fieldName must not match
expect(table.lookupFieldByOwner('class:A\0id', '')).toBeUndefined();
});
});
});