From b63a7004cb1f92eef7d02476fed3e54b30ed8faa Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Wed, 8 Jul 2026 09:26:43 -0600 Subject: [PATCH] docs(roles): correct hasActiveFileSiblings JSDoc to match population logic RoleClassificationNode.hasActiveFileSiblings claimed to be populated only for constant/TYPE_DEF_KINDS nodes, undefined for regular callables. In reality buildClassifierInput (features/structure.ts) also populates it for method/function kinds via a separate self-sibling-safe source set (calledActiveFiles), which classifyUnreferencedNode relies on to rescue functions/methods referenced only as values (logical-or defaults, interface-dispatch methods). The stale comment made those branches read as dead code. Added a regression test exercising the real population path (not just the pure classifier) for the positive method/function promotion case, complementing the existing self-sibling negative-boundary tests. --- src/graph/classifiers/roles.ts | 26 ++++++++++++++++++-------- tests/unit/roles.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/graph/classifiers/roles.ts b/src/graph/classifiers/roles.ts index 3f441837..e6a4ace5 100644 --- a/src/graph/classifiers/roles.ts +++ b/src/graph/classifiers/roles.ts @@ -187,14 +187,24 @@ export interface RoleClassificationNode { testOnlyFanIn?: number; productionFanIn?: number; /** - * True when the same file contains at least one callable connected to the graph - * (fanIn > 0 or fanOut > 0) that is not itself an annotation-only kind. - * Annotation-only kinds are `constant` and all members of `TYPE_DEF_KINDS` - * (struct, enum, trait, type, interface, record) — these are excluded because - * they are consumed via references/type-annotations rather than call edges and - * would otherwise produce a circular dependency in the active-file heuristic. - * Populated only for `constant` and `TYPE_DEF_KINDS` nodes; `undefined` for - * regular callables (functions, methods, classes, etc.) which don't need it. + * True when the file also contains at least one *other* connected, + * non-annotation-only callable. Annotation-only kinds are `constant` and all + * members of `TYPE_DEF_KINDS` (struct, enum, trait, type, interface, record) + * — these are excluded from the "active" side of the check because they are + * consumed via references/type-annotations rather than call edges, and + * including them would make the active-file heuristic circular. + * + * Populated for two groups, each using a different source set (see + * `buildActiveFilesSet`/`buildClassifierInput` in `features/structure.ts`) to + * avoid a self-sibling false positive: + * - `constant` and `TYPE_DEF_KINDS` nodes: true if the file has a + * non-annotation-only callable with `fanIn > 0 || fanOut > 0`. + * - `method` and `function` nodes: true only if the file has a + * non-annotation-only callable with `fanIn > 0` (strictly called) — + * using `fanOut > 0` here would let a node with `fanIn === 0, fanOut > 0` + * count itself as its own "active sibling" and wrongly promote itself. + * + * `undefined` for all other kinds (e.g. `class`), which don't use this field. */ hasActiveFileSiblings?: boolean; } diff --git a/tests/unit/roles.test.ts b/tests/unit/roles.test.ts index 72b3352a..9d049c9c 100644 --- a/tests/unit/roles.test.ts +++ b/tests/unit/roles.test.ts @@ -398,6 +398,29 @@ describe('classifyNodeRoles', () => { expect(role).toBe('dead-unresolved'); }); + it('promotes function with fanIn=0, fanOut>0 to leaf when a real active sibling exists in the file (#1911)', () => { + // Counterpart to the #1586 self-sibling boundary tests above: this time the + // file has a genuine OTHER callable with fanIn > 0, so hasActiveFileSiblings + // must be populated (true) for the function/method group, not left + // `undefined` as the stale JSDoc on RoleClassificationNode.hasActiveFileSiblings + // used to claim for "regular callables (functions, methods, classes, etc.)". + insertNode('src/helpers/mixed.ts', 'file', 'src/helpers/mixed.ts', 0); + const isolated = insertNode('isolatedHelper', 'function', 'src/helpers/mixed.ts', 5); + const callee = insertNode('callee', 'function', 'src/helpers/other3.ts', 10); + insertEdge(isolated, callee, 'calls'); + // A genuine sibling in the same file that IS called (fanIn > 0). + const sibling = insertNode('activeSibling', 'function', 'src/helpers/mixed.ts', 15); + const caller = insertNode('caller', 'function', 'src/helpers/other3.ts', 20); + insertEdge(caller, sibling, 'calls'); + + classifyNodeRoles(db); + + const role = db.prepare("SELECT role FROM nodes WHERE name = 'isolatedHelper'").get()?.role; + // isolatedHelper has fanIn=0, fanOut=1, and a real active sibling in its + // file — must be promoted to leaf, not dead-unresolved. + expect(role).toBe('leaf'); + }); + it('incremental path: does not classify exported interface as dead when used only as same-file type annotation (#1583)', () => { // Exercises classifyNodeRolesIncremental (triggered by passing changedFiles). // An exported=1 interface with no cross-file edges must be promoted to leaf,