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