@@ -475,7 +475,14 @@ function checkOrphanedEdges(runId: string, personId: string): AuditIssue[] {
475475 o . parent_id , null ) ) ;
476476}
477477
478- function checkUnlinkedProviders ( runId : string , personId : string , displayName : string ) : AuditIssue [ ] {
478+ /**
479+ * Check if person is missing links to providers they could be linked to.
480+ * For ancestry: only flag if a child in the BFS chain already has an ancestry link
481+ * (ancestry requires a connected chain from the root).
482+ */
483+ function checkUnlinkedProviders (
484+ runId : string , personId : string , displayName : string , childHasAncestry : boolean ,
485+ ) : { issues : AuditIssue [ ] ; linkedSources : Set < string > } {
479486 const linked = sqliteService . queryAll < { source : string } > (
480487 'SELECT DISTINCT source FROM external_identity WHERE person_id = @personId' ,
481488 { personId }
@@ -484,28 +491,31 @@ function checkUnlinkedProviders(runId: string, personId: string, displayName: st
484491 const linkedSources = new Set ( linked . map ( l => l . source ) ) ;
485492
486493 // Only flag if person has at least one provider link (otherwise they're likely too old/mythological)
487- if ( linkedSources . size === 0 ) return [ ] ;
494+ if ( linkedSources . size === 0 ) return { issues : [ ] , linkedSources } ;
488495
489496 const issues : AuditIssue [ ] = [ ] ;
490- const missingPrimary = PRIMARY_PROVIDERS . filter ( p => ! linkedSources . has ( p ) ) ;
491- const missingOptional = OPTIONAL_PROVIDERS . filter ( p => ! linkedSources . has ( p ) ) ;
497+ const currentStr = [ ...linkedSources ] . join ( ',' ) ;
492498
493- for ( const provider of missingPrimary ) {
499+ for ( const provider of PRIMARY_PROVIDERS ) {
500+ if ( linkedSources . has ( provider ) ) continue ;
501+ // Only flag ancestry if the child in the chain is already linked to ancestry
502+ if ( provider === 'ancestry' && ! childHasAncestry ) continue ;
494503 issues . push ( makeIssue (
495504 runId , personId , 'unlinked_provider' , 'info' ,
496- `${ displayName } is linked to ${ [ ... linkedSources ] . join ( ', ' ) } but not ${ provider } ` ,
497- [ ... linkedSources ] . join ( ',' ) , provider , provider ,
505+ `${ displayName } is linked to ${ currentStr } but not ${ provider } ` ,
506+ currentStr , provider , provider ,
498507 ) ) ;
499508 }
500- for ( const provider of missingOptional ) {
509+ for ( const provider of OPTIONAL_PROVIDERS ) {
510+ if ( linkedSources . has ( provider ) ) continue ;
501511 issues . push ( makeIssue (
502512 runId , personId , 'unlinked_provider' , 'hint' ,
503- `${ displayName } is linked to ${ [ ... linkedSources ] . join ( ', ' ) } but not ${ provider } ` ,
504- [ ... linkedSources ] . join ( ',' ) , provider , provider ,
513+ `${ displayName } is linked to ${ currentStr } but not ${ provider } ` ,
514+ currentStr , provider , provider ,
505515 ) ) ;
506516 }
507517
508- return issues ;
518+ return { issues, linkedSources } ;
509519}
510520
511521function checkDateMismatches ( runId : string , personId : string , displayName : string ) : AuditIssue [ ] {
@@ -790,13 +800,17 @@ const DEFAULT_CONFIG: AuditRunConfig = {
790800
791801/**
792802 * Run structural checks on a single person.
793- * Returns all issues found and the person's display name.
803+ * Returns all issues found, the person's display name, and their linked providers.
804+ * childHasAncestry: whether this person's child (closer to root) has an ancestry link.
794805 */
795- function auditPerson ( runId : string , personId : string , checksEnabled : AuditIssueType [ ] ) : { issues : AuditIssue [ ] ; displayName ?: string } {
806+ function auditPerson (
807+ runId : string , personId : string , checksEnabled : AuditIssueType [ ] , childHasAncestry : boolean ,
808+ ) : { issues : AuditIssue [ ] ; displayName ?: string ; linkedSources : Set < string > } {
796809 const vitals = getPersonVitals ( personId ) ;
797- if ( ! vitals ) return { issues : [ ] } ;
810+ if ( ! vitals ) return { issues : [ ] , linkedSources : new Set ( ) } ;
798811
799812 const issues : AuditIssue [ ] = [ ] ;
813+ let linkedSources = new Set < string > ( ) ;
800814
801815 if ( checksEnabled . includes ( 'impossible_date' ) ) {
802816 issues . push ( ...checkImpossibleDates ( runId , vitals ) ) ;
@@ -814,13 +828,22 @@ function auditPerson(runId: string, personId: string, checksEnabled: AuditIssueT
814828 issues . push ( ...checkOrphanedEdges ( runId , vitals . personId ) ) ;
815829 }
816830 if ( checksEnabled . includes ( 'unlinked_provider' ) ) {
817- issues . push ( ...checkUnlinkedProviders ( runId , vitals . personId , vitals . displayName ) ) ;
831+ const result = checkUnlinkedProviders ( runId , vitals . personId , vitals . displayName , childHasAncestry ) ;
832+ issues . push ( ...result . issues ) ;
833+ linkedSources = result . linkedSources ;
834+ } else {
835+ // Still need linked sources for ancestry chain tracking even if check is disabled
836+ const linked = sqliteService . queryAll < { source : string } > (
837+ 'SELECT DISTINCT source FROM external_identity WHERE person_id = @personId' ,
838+ { personId }
839+ ) ;
840+ linkedSources = new Set ( linked . map ( l => l . source ) ) ;
818841 }
819842 if ( checksEnabled . includes ( 'date_mismatch' ) ) {
820843 issues . push ( ...checkDateMismatches ( runId , vitals . personId , vitals . displayName ) ) ;
821844 }
822845
823- return { issues, displayName : vitals . displayName } ;
846+ return { issues, displayName : vitals . displayName , linkedSources } ;
824847}
825848
826849/**
@@ -904,6 +927,8 @@ async function* runAudit(
904927 } ;
905928
906929 const checkedSet = new Set ( cursor . checkedPersonIds ) ;
930+ // Persons whose child (closer to root) has an ancestry link — ancestry chain is reachable
931+ const childAncestryReachable = new Set < string > ( ) ;
907932 let { personsChecked, issuesFound, fixesApplied } = run ;
908933 let batchCount = 0 ;
909934
@@ -951,8 +976,14 @@ async function* runAudit(
951976
952977 if ( checkedSet . has ( personId ) ) continue ;
953978
954- // Run checks (also returns display name, avoiding redundant query)
955- const { issues, displayName } = auditPerson ( run . runId , personId , run . config . checksEnabled ) ;
979+ // For root person (gen 0), ancestry chain is always reachable.
980+ // For others, only if their child already has ancestry.
981+ const childHasAncestry = currentGen === 0 || childAncestryReachable . has ( personId ) ;
982+
983+ // Run checks
984+ const { issues, displayName, linkedSources } = auditPerson (
985+ run . runId , personId , run . config . checksEnabled , childHasAncestry ,
986+ ) ;
956987
957988 // Persist issues
958989 if ( issues . length > 0 ) {
@@ -986,13 +1017,17 @@ async function* runAudit(
9861017 }
9871018
9881019 // Queue parents for next generation
1020+ // If this person has ancestry, mark parents so they know the chain is reachable
9891021 const parents = sqliteService . queryAll < { parent_id : string } > (
9901022 'SELECT parent_id FROM parent_edge WHERE child_id = @personId' ,
9911023 { personId }
9921024 ) ;
9931025 for ( const p of parents ) {
9941026 if ( ! checkedSet . has ( p . parent_id ) ) {
9951027 nextGenPersonIds . push ( p . parent_id ) ;
1028+ if ( linkedSources . has ( 'ancestry' ) ) {
1029+ childAncestryReachable . add ( p . parent_id ) ;
1030+ }
9961031 }
9971032 }
9981033 }
@@ -1086,9 +1121,12 @@ function auditPath(
10861121
10871122 const allIssues : AuditIssue [ ] = [ ] ;
10881123
1124+ // Path is ordered root → target; track ancestry chain along the path
1125+ let childHasAncestry = true ; // root always starts the chain
10891126 sqliteService . transaction ( ( ) => {
10901127 for ( const personId of personIds ) {
1091- const { issues } = auditPerson ( run . runId , personId , checksEnabled ) ;
1128+ const { issues, linkedSources } = auditPerson ( run . runId , personId , checksEnabled , childHasAncestry ) ;
1129+ childHasAncestry = linkedSources . has ( 'ancestry' ) ;
10921130 for ( const issue of issues ) {
10931131 insertIssue ( issue ) ;
10941132 }
@@ -1104,11 +1142,14 @@ function auditPath(
11041142
11051143/**
11061144 * Get issues grouped by person ID for tree overlay.
1107- * Returns a map of personId -> issue count and max severity .
1145+ * Returns issue counts per person + set of all audited person IDs .
11081146 */
11091147function getIssueOverlay (
11101148 dbId : string ,
1111- ) : Record < string , { count : number ; maxSeverity : AuditIssueSeverity ; types : AuditIssueType [ ] } > {
1149+ ) : {
1150+ issues : Record < string , { count : number ; maxSeverity : AuditIssueSeverity ; types : AuditIssueType [ ] } > ;
1151+ auditedPersonIds : string [ ] ;
1152+ } {
11121153 const rows = sqliteService . queryAll < {
11131154 person_id : string ;
11141155 count : number ;
@@ -1118,7 +1159,7 @@ function getIssueOverlay(
11181159 `SELECT
11191160 ai.person_id,
11201161 COUNT(*) as count,
1121- MIN(CASE ai.severity WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END) as max_severity,
1162+ MIN(CASE ai.severity WHEN 'error' THEN 0 WHEN 'warning' THEN 1 WHEN 'info' THEN 2 ELSE 3 END) as max_severity,
11221163 GROUP_CONCAT(DISTINCT ai.issue_type) as types
11231164 FROM audit_issue ai
11241165 JOIN audit_run ar ON ai.run_id = ar.run_id
@@ -1127,16 +1168,28 @@ function getIssueOverlay(
11271168 { dbId }
11281169 ) ;
11291170
1130- const result : Record < string , { count : number ; maxSeverity : AuditIssueSeverity ; types : AuditIssueType [ ] } > = { } ;
1131- const severityMap = [ 'error' , 'warning' , 'info' ] as const ;
1171+ const issues : Record < string , { count : number ; maxSeverity : AuditIssueSeverity ; types : AuditIssueType [ ] } > = { } ;
1172+ const severityMap = [ 'error' , 'warning' , 'info' , 'hint' ] as const ;
11321173 for ( const row of rows ) {
1133- result [ row . person_id ] = {
1174+ issues [ row . person_id ] = {
11341175 count : row . count ,
11351176 maxSeverity : severityMap [ row . max_severity as unknown as number ] ?? 'info' ,
11361177 types : ( row . types ?. split ( ',' ) ?? [ ] ) as AuditIssueType [ ] ,
11371178 } ;
11381179 }
1139- return result ;
1180+
1181+ // Get audited person IDs from the latest completed or running run's cursor
1182+ const latestRun = sqliteService . queryOne < { cursor : string | null } > (
1183+ `SELECT cursor FROM audit_run
1184+ WHERE db_id = @dbId AND status IN ('completed', 'running', 'paused')
1185+ ORDER BY started_at DESC LIMIT 1` ,
1186+ { dbId }
1187+ ) ;
1188+ const auditedPersonIds : string [ ] = latestRun ?. cursor
1189+ ? ( JSON . parse ( latestRun . cursor ) as AuditCursor ) . checkedPersonIds
1190+ : [ ] ;
1191+
1192+ return { issues, auditedPersonIds } ;
11401193}
11411194
11421195// ============================================================================
0 commit comments