@@ -171,6 +171,58 @@ describe("schema v2 — L1 tree shape", () => {
171171 } ) ;
172172} ) ;
173173
174+ // ---- Inheritance: heritage signatures resolve to can:// ids (issue #33) ------------------------
175+ // sample-app's models.ts is first-party heritage: Entity implements Identifiable; User extends
176+ // Entity implements Named; Robot implements Named (a second, unrelated implementer of Named).
177+ // This resolution is unconditional (independent of `-a` level) — it only needs idBySig, which the
178+ // unconditional L1 tree walk already populates — so it's exercised here even though `st` is L1.
179+ describe ( "schema v2 — inheritance resolves heritage signatures to can:// ids (issue #33)" , ( ) => {
180+ test ( "a class's extends/implements signatures resolve to their declaring node's id" , ( ) => {
181+ const types = st [ "src/models.ts" ] . types ;
182+ const entity = types . Entity ;
183+ const user = types . User ;
184+ const robot = types . Robot ;
185+ const identifiable = types . Identifiable ;
186+ const named = types . Named ;
187+
188+ expect ( entity . extends_ids ) . toBeUndefined ( ) ; // no base class, only `implements`
189+ expect ( entity . implements_ids ) . toEqual ( [ identifiable . id ] ) ;
190+
191+ expect ( user . extends_ids ) . toEqual ( [ entity . id ] ) ;
192+ expect ( user . implements_ids ) . toEqual ( [ named . id ] ) ;
193+
194+ expect ( robot . extends_ids ) . toBeUndefined ( ) ;
195+ expect ( robot . implements_ids ) . toEqual ( [ named . id ] ) ;
196+ } ) ;
197+
198+ test ( "base_classes/implements_types (signature strings) are unchanged — additive, not replaced" , ( ) => {
199+ const entity = st [ "src/models.ts" ] . types . Entity as unknown as { base_classes : string [ ] ; implements_types : string [ ] } ;
200+ const user = st [ "src/models.ts" ] . types . User as unknown as { base_classes : string [ ] ; implements_types : string [ ] } ;
201+ const identifiableSig = st [ "src/models.ts" ] . types . Identifiable . signature ;
202+ const entitySig = st [ "src/models.ts" ] . types . Entity . signature ;
203+ const namedSig = st [ "src/models.ts" ] . types . Named . signature ;
204+
205+ expect ( entity . base_classes ) . toEqual ( [ identifiableSig ] ) ;
206+ expect ( entity . implements_types ) . toEqual ( [ identifiableSig ] ) ;
207+ expect ( user . base_classes ) . toEqual ( [ entitySig , namedSig ] ) ;
208+ expect ( user . implements_types ) . toEqual ( [ namedSig ] ) ;
209+ } ) ;
210+
211+ test ( "an unresolved (external/library) supertype is dropped, not nulled" , ( ) => {
212+ // Every type in sample-app either has no heritage or fully first-party heritage, so there is no
213+ // dropped entry to observe directly here — instead assert the shape invariant the resolver
214+ // relies on: extends_ids/implements_ids, when present, are always non-empty arrays of can://
215+ // ids (never contain undefined/null placeholders for an unresolved signature).
216+ for ( const t of Object . values ( st [ "src/models.ts" ] . types ) ) {
217+ for ( const ids of [ t . extends_ids , t . implements_ids ] ) {
218+ if ( ids === undefined ) continue ;
219+ expect ( ids . length ) . toBeGreaterThan ( 0 ) ;
220+ for ( const id of ids ) expect ( id . startsWith ( "can://" ) ) . toBe ( true ) ;
221+ }
222+ }
223+ } ) ;
224+ } ) ;
225+
174226describe ( "schema v2 — L1 superset" , ( ) => {
175227 test ( "every v1 callable/type signature has a v2 id" , ( ) => {
176228 const v1sigs = new Set < string > ( ) ;
@@ -512,6 +564,26 @@ function symbolIds(app: V2Application): Set<string> {
512564 return out ;
513565}
514566
567+ /** Every type node — classes/interfaces/enums/aliases/namespaces — recursing through nested scopes. */
568+ function allTypes ( app : V2Application ) : V2Type [ ] {
569+ const out : V2Type [ ] = [ ] ;
570+ const walkCallable = ( c : V2Callable ) : void => {
571+ for ( const cc of Object . values ( c . callables ?? { } ) ) walkCallable ( cc ) ;
572+ for ( const t of Object . values ( c . types ?? { } ) ) walkType ( t ) ;
573+ } ;
574+ const walkType = ( t : V2Type ) : void => {
575+ out . push ( t ) ;
576+ for ( const c of Object . values ( t . callables ?? { } ) ) walkCallable ( c ) ;
577+ for ( const nested of Object . values ( t . types ?? { } ) ) walkType ( nested ) ;
578+ for ( const fn of Object . values ( t . functions ?? { } ) ) walkCallable ( fn ) ;
579+ } ;
580+ for ( const m of Object . values ( app . application . symbol_table ) ) {
581+ for ( const t of Object . values ( m . types ) ) walkType ( t ) ;
582+ for ( const c of Object . values ( m . functions ) ) walkCallable ( c ) ;
583+ }
584+ return out ;
585+ }
586+
515587/** Every body-node key, namespaced by its owning callable id so bare local keys never collide. */
516588function bodyKeys ( app : V2Application ) : Set < string > {
517589 const out = new Set < string > ( ) ;
@@ -603,6 +675,15 @@ function canNodeIds(app: V2Application): Set<string> {
603675 return ids ;
604676}
605677
678+ /** Every `call` body node whose `callee` resolved to an id — the JSON-side source of RESOLVES_TO. */
679+ function resolvesToCount ( app : V2Application ) : number {
680+ let n = 0 ;
681+ for ( const c of allCallables ( app . application ) ) {
682+ for ( const bn of Object . values ( c . body ) ) if ( typeof bn . callee === "string" ) n ++ ;
683+ }
684+ return n ;
685+ }
686+
606687describe ( "neo4j ↔ json count parity — full depth (issue #27)" , ( ) => {
607688 test ( "node count: 1 :Application row + every :CanNode id" , ( ) => {
608689 expect ( monoRows . nodes . length ) . toBe ( 1 + canNodeIds ( monoApp4 ) . size ) ;
@@ -623,11 +704,50 @@ describe("neo4j ↔ json count parity — full depth (issue #27)", () => {
623704 // HAS_MODULE/DECLARES/HAS_METHOD/HAS_FIELD/HAS_BODY_NODE have no JSON edge-list counterpart —
624705 // they ARE the containment tree. Every node except :Application and the off-tree External/
625706 // AnonymousCallable homes gets exactly one incoming containment edge from its tree parent, so
626- // their total must equal every other CanNode id.
707+ // their total must equal every other CanNode id. (RESOLVES_TO and EXTENDS/IMPLEMENTS ALSO have
708+ // no top-level JSON edge-list — they're sourced from a body node's `callee` and a type node's
709+ // `extends_ids`/`implements_ids` props respectively — but they are not containment either, so
710+ // they're deliberately excluded from this invariant too; see the dedicated tests below and the
711+ // exhaustive edge-accounting test that folds every relationship family back into one total.)
627712 const containment = [ "HAS_MODULE" , "DECLARES" , "HAS_METHOD" , "HAS_FIELD" , "HAS_BODY_NODE" ] ;
628713 const containmentEdges = containment . reduce ( ( n , t ) => n + relCount ( monoRows , t ) , 0 ) ;
629714 const externalCount = Object . keys ( monoApp4 . application . external_symbols ?? { } ) . length ;
630715 const synthCount = Object . keys ( monoApp4 . application . synthesized_callables ?? { } ) . length ;
631716 expect ( containmentEdges ) . toBe ( canNodeIds ( monoApp4 ) . size - externalCount - synthCount ) ;
632717 } ) ;
718+
719+ test ( "EXTENDS/IMPLEMENTS have no JSON edge-list (extends_ids/implements_ids node props are the source of truth); counts still match 1:1" , ( ) => {
720+ const types = allTypes ( monoApp4 ) ;
721+ const extendsCount = types . reduce ( ( n , t ) => n + ( t . extends_ids ?. length ?? 0 ) , 0 ) ;
722+ const implementsCount = types . reduce ( ( n , t ) => n + ( t . implements_ids ?. length ?? 0 ) , 0 ) ;
723+ // sanity: dataflow-app's first-party hierarchy (src/hierarchy.ts) exercises both relationship
724+ // kinds — guards against a vacuous 0 === 0 pass if the fixture ever loses its heritage.
725+ expect ( extendsCount ) . toBeGreaterThan ( 0 ) ;
726+ expect ( implementsCount ) . toBeGreaterThan ( 0 ) ;
727+ expect ( relCount ( monoRows , "EXTENDS" ) ) . toBe ( extendsCount ) ;
728+ expect ( relCount ( monoRows , "IMPLEMENTS" ) ) . toBe ( implementsCount ) ;
729+ } ) ;
730+
731+ test ( "every projected edge is accounted for: typed overlay + containment + RESOLVES_TO + EXTENDS/IMPLEMENTS sums to the total" , ( ) => {
732+ // The exhaustive form of the parity gate: unlike the per-family tests above (which only prove
733+ // each family individually matches its JSON source), this proves nothing is left over — if
734+ // project.ts ever grows a new relationship family without this test learning about it, the two
735+ // sides diverge and the gate fails, instead of silently passing on an incomplete accounting.
736+ const typedOverlay =
737+ relCount ( monoRows , "CALLS" ) +
738+ relCount ( monoRows , "PARAM_IN" ) +
739+ relCount ( monoRows , "PARAM_OUT" ) +
740+ relCount ( monoRows , "CFG_NEXT" ) +
741+ relCount ( monoRows , "CDG" ) +
742+ relCount ( monoRows , "DDG" ) +
743+ relCount ( monoRows , "SUMMARY" ) ;
744+ const containment = [ "HAS_MODULE" , "DECLARES" , "HAS_METHOD" , "HAS_FIELD" , "HAS_BODY_NODE" ] . reduce (
745+ ( n , t ) => n + relCount ( monoRows , t ) ,
746+ 0 ,
747+ ) ;
748+ const resolvesTo = relCount ( monoRows , "RESOLVES_TO" ) ;
749+ const heritage = relCount ( monoRows , "EXTENDS" ) + relCount ( monoRows , "IMPLEMENTS" ) ;
750+ expect ( resolvesTo ) . toBe ( resolvesToCount ( monoApp4 ) ) ;
751+ expect ( typedOverlay + containment + resolvesTo + heritage ) . toBe ( monoRows . edges . length ) ;
752+ } ) ;
633753} ) ;
0 commit comments