Skip to main content

projectatlas/runtime/
graph_projection.rs

1//! Normalize parser-owned symbol facts into one generation-bound repository graph.
2
3use super::{
4    CliError, INDEX_FRESHNESS_SAMPLE_LIMIT, IndexReadStatus, IndexRefreshReason,
5    IndexRefreshRequired, IndexRefreshScope, IndexWorkControl, IndexWorkFailure, IndexWorkResource,
6    IndexWorkStage, MAX_SYMBOL_FILE_BYTES, Node, NodeKind, SourceReadFailure, SymbolBuildStage,
7    SymbolProjectionChange, lossless_project_root_display, normalize_native_path_display,
8    read_source_bytes_controlled, source_changed_during_derivation,
9};
10use projectatlas_core::IndexGeneration;
11use projectatlas_core::graph::{
12    CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
13    CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
14    ExtendedRelationKind, ExternalSelector, GraphContractError, GraphEntity, GraphIdentityField,
15    GraphIdentityRejection, GraphIdentityRejectionReason, GraphIdentityText, GraphLimitKind,
16    GraphLimits, GraphRelationKind, LogicalRelation, LogicalRelationKey, MAX_GRAPH_IDENTITY_BYTES,
17    PackageSelector, ProjectInstanceId, QUALIFIED_SYMBOL_SCOPE_PREFIX, RelationDependencyKey,
18    RelationOccurrence, RelationResolution, RepositoryFilePath, RepositoryNodePath,
19    ResolutionKeyDomain, SourceSpan, SymbolSelector,
20};
21use projectatlas_core::language::{SemanticProviderOwner, SymbolParserOwner, language_capability};
22use projectatlas_core::symbols::{
23    MODULE_RELATION_SOURCE, ParserKind, RelationKind, SymbolGraph, SymbolKind, SymbolRelation,
24};
25use projectatlas_db::{
26    AtlasStore, IndexPublicationGuard, RepositoryAffectedSourceFootprint,
27    RepositoryResolutionCandidate,
28};
29use projectatlas_fs::RootScanPolicy;
30#[cfg(test)]
31use projectatlas_fs::ScanOptions;
32use projectatlas_symbols::{
33    ConfiguredModuleResolution, MAX_RESOLUTION_KEYS_PER_FACT, MarkdownFactCompleteness,
34    MarkdownFactLimit, MarkdownFacts, ResolutionKeyProjection, ResolutionProjectionContext,
35    ResolutionProjectionError, ResolutionProjectionFact, derive_resolution_keys_with_context,
36    extract_markdown_facts_controlled, parse_import_references,
37};
38use std::borrow::{Borrow, Cow};
39use std::cell::RefCell;
40use std::cmp::Reverse;
41use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, btree_map::Entry};
42use std::fs::{self, File, OpenOptions};
43use std::num::NonZeroU32;
44use std::path::{Path, PathBuf};
45use tempfile::{Builder as TempDirBuilder, TempDir};
46
47/// Maximum canonical keys or distinct source paths admitted by one incremental closure.
48const MAX_INCREMENTAL_RESOLUTION_ITEMS: u32 = GraphLimits::MAX_ROWS;
49/// Maximum simultaneous parser/entity/key bytes before rows spill to typed `SQLite` staging.
50const MAX_IN_MEMORY_GRAPH_WORK_BYTES: u64 = 512 * 1_024 * 1_024;
51/// Maximum aggregate normalized graph rows admitted by one incremental closure.
52const MAX_INCREMENTAL_GRAPH_ROWS: u64 = GraphLimits::MAX_ROWS as u64;
53/// Maximum conservative graph bytes admitted before requesting a complete refresh.
54const MAX_INCREMENTAL_GRAPH_BYTES: u64 = MAX_IN_MEMORY_GRAPH_WORK_BYTES;
55/// Maximum persisted key bindings retained by one complete in-memory graph projection.
56const MAX_GRAPH_KEY_BINDINGS: u64 = 8_000_000;
57/// Conservative fixed bytes counted for each staged graph or binding row.
58const STAGED_GRAPH_ROW_BYTES: u64 = 128;
59/// Conservative bytes for one generated document relation and its retained indexes.
60///
61/// This covers the relation, occurrence, dependency, unresolved-reason map entry,
62/// final reason tuple, and duplicate-validation digest; candidate text is added
63/// separately from the already-counted Markdown fact bytes.
64const DOCUMENT_PROJECTION_ROW_BYTES: u64 = STAGED_GRAPH_ROW_BYTES * 8;
65/// Maximum graph-map rows processed between cooperative cancellation checks.
66const GRAPH_WORK_CHECK_INTERVAL: usize = 256;
67/// Borrowed entity references inserted per disposable staging call.
68const GRAPH_STAGE_ENTITY_BATCH_SIZE: usize = 1_024;
69/// Maximum normalized graph rows retained between disposable staging writes.
70const GRAPH_STAGE_ROW_BATCH_SIZE: usize = 8_192;
71/// Direct child prefix owned by disposable graph staging.
72const GRAPH_STAGE_DIRECTORY_PREFIX: &str = "graph-stage-";
73/// Stable cross-process lease protecting active staging and restart cleanup.
74const GRAPH_STAGE_LEASE_FILE_NAME: &str = "repository-graph-stage.lock";
75/// Typed disposable database inside every owned staging directory.
76const GRAPH_STAGE_DATABASE_FILE_NAME: &str = "projectatlas.db";
77/// Internal invariant failure for a staging owner already entering teardown.
78const GRAPH_STAGE_OWNER_UNAVAILABLE: &str = "repository graph staging owner is unavailable";
79/// Maximum persisted symbol-graph paths reconstructed between work checks.
80const PERSISTED_GRAPH_PATHS_PER_CHUNK: usize = 256;
81/// Stable fallback for a parser relation that omitted a usable display target.
82const UNKNOWN_REFERENCE: &str = "unknown-reference";
83/// Stable package manager for currently extracted Cargo manifest ownership.
84const CARGO_PACKAGE_MANAGER: &str = "cargo";
85/// Stable external namespace for the Rust standard-library distribution.
86const RUST_TOOLCHAIN_SYSTEM: &str = "rust-toolchain";
87/// Stable external namespace for explicitly qualified Node.js built-ins.
88const NODE_SYSTEM: &str = "node";
89/// Honest coverage diagnostic for fallback or structural relation extraction.
90const PARTIAL_COVERAGE_REASON: &str = "parser does not prove complete relationship coverage";
91/// External namespace for content-free configuration identities.
92const CONFIGURATION_SYSTEM: &str = "configuration";
93/// External namespace for static environment-variable identities.
94const ENVIRONMENT_SYSTEM: &str = "environment-variable";
95/// External namespace for content-free deployment platform identities.
96const DEPLOYMENT_SYSTEM: &str = "deployment-platform";
97/// Canonical provider owner for exact repository document targets.
98const DOCUMENT_PATH_PROVIDER: &str = "projectatlas-document";
99/// Canonical resolver language for repository-relative file and heading identities.
100const DOCUMENT_PATH_LANGUAGE: &str = "repository-path";
101/// Canonical resolver language for case-fold collision invalidation only.
102const DOCUMENT_CASEFOLD_LANGUAGE: &str = "repository-path-casefold";
103/// Honest content-free coverage diagnostic for bounded Markdown facts.
104const DOCUMENT_PARTIAL_COVERAGE_REASON: &str =
105    "markdown fact extraction reached a declared limit or unsupported structure";
106/// Maximum typed identity rejection details retained for one publication.
107const MAX_GRAPH_IDENTITY_REJECTIONS: usize = GraphLimits::MAX_ROWS as usize;
108/// Stable namespace for invalid symbol/package parser facts.
109const SYMBOL_FACT_INDEX_NAMESPACE: u64 = 1_u64 << 56;
110/// Stable namespace for invalid source-relation parser facts.
111const RELATION_FACT_INDEX_NAMESPACE: u64 = 2_u64 << 56;
112/// Stable namespace for invalid derived-relation parser facts.
113const DERIVED_RELATION_FACT_INDEX_NAMESPACE: u64 = 3_u64 << 56;
114/// Stable namespace for invalid Markdown selector parser facts.
115const MARKDOWN_FACT_INDEX_NAMESPACE: u64 = 4_u64 << 56;
116
117/// Build a deterministic internal identity without retaining parser text.
118fn parser_fact_index(namespace: u64, index: usize) -> u64 {
119    match u64::try_from(index) {
120        Ok(index) => namespace.saturating_add(index),
121        Err(_) => u64::MAX,
122    }
123}
124
125/// Relation observation paired with each symbol in one parser graph.
126struct PairedImportRelations {
127    /// Relation index for each paired import symbol.
128    by_symbol: Vec<Option<usize>>,
129    /// Test-only proof that pairing scans each bounded input row once.
130    #[cfg(test)]
131    work_items: usize,
132}
133
134/// Pair import symbols and relations once by source line and occurrence.
135///
136/// Tree-sitter emits an import declaration as both a symbol and an import
137/// relation. Pairing by source line and occurrence keeps those observations
138/// tied to one parser fact while retaining distinct same-line imports.
139fn paired_import_relations(
140    graph: &SymbolGraph,
141    control: &IndexWorkControl,
142) -> Result<PairedImportRelations, CliError> {
143    let mut relations_by_line = HashMap::<usize, (Vec<usize>, usize)>::new();
144    #[cfg(test)]
145    let mut work_items = 0_usize;
146    for (relation_index, relation) in graph.relations.iter().enumerate() {
147        check_graph_work(control, relation_index)?;
148        #[cfg(test)]
149        {
150            work_items = work_items.saturating_add(1);
151        }
152        if relation.kind == RelationKind::Imports {
153            relations_by_line
154                .entry(relation.line)
155                .or_default()
156                .0
157                .push(relation_index);
158        }
159    }
160    let mut by_symbol = vec![None; graph.symbols.len()];
161    for (symbol_index, symbol) in graph.symbols.iter().enumerate() {
162        check_graph_work(control, symbol_index)?;
163        #[cfg(test)]
164        {
165            work_items = work_items.saturating_add(1);
166        }
167        if symbol.kind != SymbolKind::Import {
168            continue;
169        }
170        let Some((relation_indices, next_ordinal)) = relations_by_line.get_mut(&symbol.line_start)
171        else {
172            continue;
173        };
174        by_symbol[symbol_index] = relation_indices.get(*next_ordinal).copied();
175        *next_ordinal = (*next_ordinal).saturating_add(1);
176    }
177    Ok(PairedImportRelations {
178        by_symbol,
179        #[cfg(test)]
180        work_items,
181    })
182}
183
184/// Reuse the relation ordinal for its paired import symbol observation.
185fn symbol_parser_fact_index(symbol_index: usize, paired_relation_index: Option<usize>) -> u64 {
186    paired_relation_index.map_or_else(
187        || parser_fact_index(SYMBOL_FACT_INDEX_NAMESPACE, symbol_index),
188        |relation_index| parser_fact_index(RELATION_FACT_INDEX_NAMESPACE, relation_index),
189    )
190}
191
192/// Bounded source span attached to one rejected parser identity.
193#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
194struct IdentitySpan {
195    /// One-based first line containing the parser fact.
196    start_line: usize,
197    /// Zero-based first column when the parser provides one.
198    start_column: usize,
199    /// One-based last line containing the parser fact.
200    end_line: usize,
201    /// Zero-based exclusive end column when the parser provides one.
202    end_column: usize,
203}
204
205/// Use one source span for paired import symbol and relation observations.
206fn symbol_identity_span(
207    graph: &SymbolGraph,
208    symbol_index: usize,
209    paired_relation_index: Option<usize>,
210) -> IdentitySpan {
211    let Some(symbol) = graph.symbols.get(symbol_index) else {
212        return IdentitySpan {
213            start_line: 1,
214            start_column: 0,
215            end_line: 1,
216            end_column: 0,
217        };
218    };
219    let start_line = symbol.line_start.max(1);
220    let end_line = symbol.line_end.max(symbol.line_start).max(1);
221    if let Some(relation_index) = paired_relation_index
222        && let Some(relation) = graph.relations.get(relation_index)
223    {
224        let line = relation.line.max(1);
225        return IdentitySpan {
226            start_line: line,
227            start_column: 0,
228            end_line: line,
229            end_column: 0,
230        };
231    }
232    IdentitySpan {
233        start_line,
234        start_column: 0,
235        end_line,
236        end_column: 0,
237    }
238}
239
240/// Internal identity for one observed parser fact, independent of retained
241/// detail rows. The detail vector is deliberately capped, but omission
242/// counts must still deduplicate repeated projections after that cap.
243#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
244struct IdentityFactKey {
245    /// Exact bounded source span of the parser fact.
246    span: IdentitySpan,
247    /// Stable local discriminator for the parser strategy.
248    parser: u8,
249    /// Admission owner for distinguishing parser identity from its derived
250    /// resolution-key outcome when both share one parser fact ordinal.
251    owner: u8,
252    /// Internal parser-fact ordinal.
253    fact_index: u64,
254}
255
256/// Exact membership key for one retained typed identity rejection detail.
257///
258/// The ordered key is kept beside the deterministic detail vector so replay
259/// checks do not scan every previously retained row.
260#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
261struct IdentityRejectionKey {
262    /// Repository-relative source path containing the rejected fact.
263    path: RepositoryNodePath,
264    /// Exact bounded source span of the rejected fact.
265    span: IdentitySpan,
266    /// Parser strategy that produced the rejected fact.
267    parser: u8,
268    /// Identity field that failed admission.
269    field: GraphIdentityField,
270    /// Stable rejection category.
271    reason: GraphIdentityRejectionReason,
272    /// Internal parser-fact ordinal.
273    fact_index: u64,
274}
275
276impl From<&GraphIdentityRejection> for IdentityRejectionKey {
277    fn from(rejection: &GraphIdentityRejection) -> Self {
278        Self {
279            path: rejection.path.clone(),
280            span: IdentitySpan {
281                start_line: rejection.span.start_line() as usize,
282                start_column: rejection.span.start_column() as usize,
283                end_line: rejection.span.end_line() as usize,
284                end_column: rejection.span.end_column() as usize,
285            },
286            parser: parser_fact_kind(rejection.parser),
287            field: rejection.field,
288            reason: rejection.reason,
289            fact_index: rejection.fact_index,
290        }
291    }
292}
293
294/// Map parser strategy to a compact local fact-key discriminator.
295fn parser_fact_kind(parser: ParserKind) -> u8 {
296    match parser {
297        ParserKind::TreeSitter => 0,
298        ParserKind::Manifest => 1,
299        ParserKind::Structural => 2,
300        ParserKind::Fallback => 3,
301    }
302}
303
304/// Map one rejection field to the admission owner that re-derives it.
305fn identity_fact_owner(fields: &[(GraphIdentityField, GraphIdentityRejectionReason)]) -> u8 {
306    u8::from(
307        fields
308            .iter()
309            .any(|(field, _reason)| *field == GraphIdentityField::ResolutionKey),
310    )
311}
312
313/// Return whether a persisted fact is replaced by the current derivation.
314fn is_rederived_identity_fact(fact: &IdentityFactKey) -> bool {
315    fact.owner == 1
316        || matches!(
317            fact.fact_index & !((1_u64 << 56) - 1),
318            DERIVED_RELATION_FACT_INDEX_NAMESPACE | MARKDOWN_FACT_INDEX_NAMESPACE
319        )
320}
321
322/// Conservative bytes for one observed fact and its path/count map entries.
323fn identity_fact_retained_bytes(
324    path: &str,
325    new_observed_path: bool,
326    new_count_path: bool,
327) -> Result<u64, CliError> {
328    let path_bytes = u64::try_from(path.len()).map_err(|error| {
329        CliError::InvalidInput(format!(
330            "identity rejection path length overflowed: {error}"
331        ))
332    })?;
333    let path_entries = u64::from(u8::from(new_observed_path)) + u64::from(u8::from(new_count_path));
334    let path_bytes = path_bytes
335        .checked_mul(path_entries)
336        .and_then(|bytes| bytes.checked_add(STAGED_GRAPH_ROW_BYTES.checked_mul(path_entries)?))
337        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))?;
338    path_bytes
339        .checked_add(STAGED_GRAPH_ROW_BYTES)
340        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
341}
342
343/// Conservative bytes for a count-only path retained after detail rows cap.
344fn identity_count_path_retained_bytes(path: &str) -> Result<u64, CliError> {
345    let path_bytes = u64::try_from(path.len()).map_err(|error| {
346        CliError::InvalidInput(format!(
347            "identity rejection path length overflowed: {error}"
348        ))
349    })?;
350    path_bytes
351        .checked_add(STAGED_GRAPH_ROW_BYTES)
352        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
353}
354
355/// Count one persisted identity fact set and its owning path entry.
356fn identity_fact_set_retained_bytes(
357    path: &str,
358    facts: &BTreeSet<IdentityFactKey>,
359) -> Result<u64, CliError> {
360    let path_bytes = u64::try_from(path.len()).map_err(|error| {
361        CliError::InvalidInput(format!(
362            "identity rejection path length overflowed: {error}"
363        ))
364    })?;
365    let fact_bytes = STAGED_GRAPH_ROW_BYTES
366        .checked_mul(u64::try_from(facts.len()).map_err(|error| {
367            CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
368        })?)
369        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))?;
370    path_bytes
371        .checked_add(STAGED_GRAPH_ROW_BYTES)
372        .and_then(|bytes| bytes.checked_add(fact_bytes))
373        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
374}
375
376/// Count one observed-fact map path and its retained facts.
377fn identity_observed_path_retained_bytes(path: &str, fact_count: usize) -> Result<u64, CliError> {
378    let path_bytes = u64::try_from(path.len()).map_err(|error| {
379        CliError::InvalidInput(format!(
380            "identity rejection path length overflowed: {error}"
381        ))
382    })?;
383    let fact_bytes = STAGED_GRAPH_ROW_BYTES
384        .checked_mul(u64::try_from(fact_count).map_err(|error| {
385            CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
386        })?)
387        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))?;
388    path_bytes
389        .checked_add(STAGED_GRAPH_ROW_BYTES)
390        .and_then(|bytes| bytes.checked_add(fact_bytes))
391        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
392}
393
394/// Count the bounded map/set entries retained for observed identity facts.
395fn identity_maps_retained_bytes(
396    observed_facts: &BTreeMap<String, BTreeSet<IdentityFactKey>>,
397    rejected_facts_by_path: &BTreeMap<String, u64>,
398) -> Result<u64, CliError> {
399    let mut retained = 0_u64;
400    for (path, facts) in observed_facts {
401        retained = retained
402            .checked_add(identity_observed_path_retained_bytes(path, facts.len())?)
403            .ok_or_else(|| {
404                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
405            })?;
406    }
407    for path in rejected_facts_by_path.keys() {
408        retained = retained
409            .checked_add(identity_count_path_retained_bytes(path)?)
410            .ok_or_else(|| {
411                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
412            })?;
413    }
414    Ok(retained)
415}
416
417/// Count one retained rejection-membership key and its owned path text.
418fn identity_rejection_key_retained_bytes(path: &str) -> Result<u64, CliError> {
419    let path_bytes = u64::try_from(path.len()).map_err(|error| {
420        CliError::InvalidInput(format!(
421            "identity rejection path length overflowed: {error}"
422        ))
423    })?;
424    path_bytes
425        .checked_add(STAGED_GRAPH_ROW_BYTES)
426        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
427}
428
429/// Count the bounded keyed membership state for retained rejection details.
430fn identity_rejection_keys_retained_bytes(
431    keys: &BTreeSet<IdentityRejectionKey>,
432) -> Result<u64, CliError> {
433    keys.iter().try_fold(0_u64, |retained, key| {
434        retained
435            .checked_add(identity_rejection_key_retained_bytes(key.path.as_str())?)
436            .ok_or_else(|| {
437                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
438            })
439    })
440}
441
442/// Count the path markers retained for publication-time detail evictions.
443fn identity_rejection_drop_paths_retained_bytes(paths: &BTreeSet<String>) -> Result<u64, CliError> {
444    paths.iter().try_fold(0_u64, |retained, path| {
445        retained
446            .checked_add(identity_count_path_retained_bytes(path)?)
447            .ok_or_else(|| {
448                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
449            })
450    })
451}
452
453/// Return the exact incremental identity-state total owned by one report.
454fn identity_admission_retained_bytes(report: &GraphIdentityAdmission) -> u64 {
455    report.observed_fact_bytes
456}
457
458/// Return the typed graph-work limit reached before retaining identity state.
459fn identity_fact_budget_failure_for_limit(limit: u64, retained_bytes: u64) -> CliError {
460    IndexWorkFailure::resource_limit(
461        IndexWorkStage::SymbolParsing,
462        IndexWorkResource::OutputBytes,
463        limit,
464        retained_bytes,
465    )
466    .into()
467}
468
469/// Return the production graph-work limit reached before retaining identity state.
470fn identity_fact_budget_failure(retained_bytes: u64) -> CliError {
471    identity_fact_budget_failure_for_limit(MAX_IN_MEMORY_GRAPH_WORK_BYTES, retained_bytes)
472}
473
474/// Check complete retained identity state against an injected or production limit.
475fn checked_identity_admission_budget(
476    report: &GraphIdentityAdmission,
477    control: &IndexWorkControl,
478    limit: u64,
479) -> Result<u64, CliError> {
480    control.check(IndexWorkStage::SymbolParsing)?;
481    let retained_bytes = identity_admission_retained_bytes(report);
482    if retained_bytes > limit {
483        return Err(identity_fact_budget_failure_for_limit(
484            limit,
485            retained_bytes,
486        ));
487    }
488    Ok(retained_bytes)
489}
490
491/// Bounded admission report shared by entity, relation, and key projection.
492#[derive(Clone, Debug, Default)]
493pub(super) struct GraphIdentityAdmission {
494    /// Whether the parser-output graphs have passed the shared source boundary.
495    source_admitted: bool,
496    /// Retained typed details, capped independently from the count.
497    rejections: Vec<GraphIdentityRejection>,
498    /// Exact membership for the retained typed detail vector.
499    rejection_keys: BTreeSet<IdentityRejectionKey>,
500    /// Number of parser facts rejected per source path.
501    rejected_facts_by_path: BTreeMap<String, u64>,
502    /// Every observed rejected parser fact, retained independently of detail
503    /// rows so the global detail ceiling cannot make replays overcount.
504    observed_facts: BTreeMap<String, BTreeSet<IdentityFactKey>>,
505    /// Exact bytes retained by every identity map, detail key, and
506    /// reconciliation entry, charged to the graph-work budget incrementally.
507    observed_fact_bytes: u64,
508    /// Persisted typed fact identities retained separately from current
509    /// observations so removed outcomes can be subtracted exactly.
510    reused_rejection_facts: BTreeMap<String, BTreeSet<IdentityFactKey>>,
511    /// Original parser relation ordinal for each admitted relation after
512    /// source-identity filtering. This remains private because it is only
513    /// needed to keep derived rejection facts tied to parser observations.
514    relation_fact_indices: BTreeMap<String, Vec<usize>>,
515    /// Resolution keys derived once at admission and carried into projection.
516    resolution_projections: BTreeMap<String, ResolutionKeyProjection>,
517    /// Omission counts retained from the current generation for safely reused
518    /// graphs and reconciled with the current pass after it regenerates any
519    /// key, Markdown, or derived details it owns.
520    reused_rejection_counts: BTreeMap<String, u64>,
521    /// Parser-baseline omission retained for reused structural/fallback
522    /// graphs. This stays separate from identity omissions because parser
523    /// coverage counts only relations when no identity fact was rejected.
524    reused_parser_rejection_counts: BTreeMap<String, u64>,
525    /// Number of persisted typed facts retained for each reused path. This is
526    /// the baseline for exact post-rederivation count reconciliation.
527    reused_rejection_detail_counts: BTreeMap<String, u64>,
528    /// Reused paths whose persisted detail ceiling leaves old identities
529    /// unknowable. These paths require a complete source reparse before a
530    /// replacement publication can be exact.
531    reused_rejection_details_incomplete: BTreeSet<String>,
532    /// Paths where a distinct typed rejection detail was evicted by the
533    /// publication-wide detail ceiling.
534    rejection_details_dropped_by_path: BTreeSet<String>,
535    /// Test-only proof of one derivation per source path and generation.
536    #[cfg(test)]
537    resolution_derivations: BTreeMap<(String, IndexGeneration), usize>,
538    /// Test-only count of source rows inspected while pairing import facts.
539    #[cfg(test)]
540    paired_import_pairing_work: usize,
541}
542
543impl GraphIdentityAdmission {
544    /// Record one parser fact and every failed identity field without retaining raw input.
545    fn record(
546        &mut self,
547        path: &str,
548        span: IdentitySpan,
549        parser: ParserKind,
550        fact_index: u64,
551        fields: &[(GraphIdentityField, GraphIdentityRejectionReason)],
552        control: &IndexWorkControl,
553    ) -> Result<(), CliError> {
554        control.check(IndexWorkStage::SymbolParsing)?;
555        let path = RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract)?;
556        let fact_span = span;
557        let span = SourceSpan::new(
558            u32::try_from(span.start_line).map_err(|error| {
559                CliError::InvalidInput(format!("identity rejection start line overflowed: {error}"))
560            })?,
561            u32::try_from(span.start_column).map_err(|error| {
562                CliError::InvalidInput(format!(
563                    "identity rejection start column overflowed: {error}"
564                ))
565            })?,
566            u32::try_from(span.end_line).map_err(|error| {
567                CliError::InvalidInput(format!("identity rejection end line overflowed: {error}"))
568            })?,
569            u32::try_from(span.end_column).map_err(|error| {
570                CliError::InvalidInput(format!("identity rejection end column overflowed: {error}"))
571            })?,
572        )
573        .map_err(invalid_graph_contract)?;
574        let fact_key = IdentityFactKey {
575            span: fact_span,
576            parser: parser_fact_kind(parser),
577            owner: identity_fact_owner(fields),
578            fact_index,
579        };
580        let path_key = path.as_str().to_owned();
581        let observed_path_is_new = !self.observed_facts.contains_key(&path_key);
582        let count_path_is_new = !self.rejected_facts_by_path.contains_key(&path_key);
583        let fact_is_new = self
584            .observed_facts
585            .get(&path_key)
586            .is_none_or(|facts| !facts.contains(&fact_key));
587        let mut new_rejection_keys = Vec::new();
588        let mut dropped_rejection_detail = false;
589        for (field, reason) in fields {
590            let key = IdentityRejectionKey {
591                path: path.clone(),
592                span: fact_span,
593                parser: parser_fact_kind(parser),
594                field: *field,
595                reason: *reason,
596                fact_index,
597            };
598            if !self.rejection_keys.contains(&key)
599                && !new_rejection_keys.iter().any(|existing| existing == &key)
600            {
601                if self.rejections.len() + new_rejection_keys.len() >= MAX_GRAPH_IDENTITY_REJECTIONS
602                {
603                    dropped_rejection_detail = true;
604                } else {
605                    new_rejection_keys.push(key);
606                }
607            }
608        }
609        let dropped_path_is_new = dropped_rejection_detail
610            && !self
611                .rejection_details_dropped_by_path
612                .contains(path.as_str());
613        let mut additional_bytes = if fact_is_new {
614            identity_fact_retained_bytes(path.as_str(), observed_path_is_new, count_path_is_new)?
615        } else {
616            0
617        };
618        for key in &new_rejection_keys {
619            additional_bytes = additional_bytes
620                .checked_add(identity_rejection_key_retained_bytes(key.path.as_str())?)
621                .ok_or_else(|| {
622                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
623                })?;
624        }
625        if dropped_path_is_new {
626            additional_bytes = additional_bytes
627                .checked_add(identity_count_path_retained_bytes(path.as_str())?)
628                .ok_or_else(|| {
629                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
630                })?;
631        }
632        self.reserve_identity_bytes(additional_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
633        if dropped_rejection_detail {
634            self.rejection_details_dropped_by_path
635                .insert(path.as_str().to_owned());
636        }
637        if fact_is_new {
638            self.observed_facts
639                .entry(path_key.clone())
640                .or_default()
641                .insert(fact_key);
642            let count = self.rejected_facts_by_path.entry(path_key).or_default();
643            *count = count.checked_add(1).ok_or_else(|| {
644                CliError::InvalidInput("identity rejection count overflowed".to_string())
645            })?;
646        }
647        for key in new_rejection_keys {
648            let field = key.field;
649            let reason = key.reason;
650            self.rejection_keys.insert(key);
651            self.rejections.push(GraphIdentityRejection {
652                path: path.clone(),
653                span,
654                parser,
655                field,
656                reason,
657                fact_index,
658            });
659        }
660        Ok(())
661    }
662
663    /// Reserve one newly owned identity-state entry before retaining it.
664    fn reserve_identity_bytes(
665        &mut self,
666        additional_bytes: u64,
667        control: &IndexWorkControl,
668        limit: u64,
669    ) -> Result<(), CliError> {
670        control.check(IndexWorkStage::SymbolParsing)?;
671        let retained_bytes = self
672            .observed_fact_bytes
673            .checked_add(additional_bytes)
674            .ok_or_else(|| {
675                identity_fact_budget_failure_for_limit(limit, self.observed_fact_bytes)
676            })?;
677        if retained_bytes > limit {
678            return Err(identity_fact_budget_failure_for_limit(
679                limit,
680                retained_bytes,
681            ));
682        }
683        self.observed_fact_bytes = retained_bytes;
684        Ok(())
685    }
686
687    /// Adjust the cached identity-state total when a merge replaces entries.
688    fn adjust_identity_bytes(&mut self, before: u64, after: u64) -> Result<(), CliError> {
689        if after >= before {
690            self.observed_fact_bytes = self
691                .observed_fact_bytes
692                .checked_add(after - before)
693                .ok_or_else(|| {
694                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
695                })?;
696        } else {
697            self.observed_fact_bytes = self
698                .observed_fact_bytes
699                .checked_sub(before - after)
700                .ok_or_else(|| {
701                    CliError::InvalidInput("identity rejection bytes underflowed".to_string())
702                })?;
703        }
704        Ok(())
705    }
706
707    /// Merge one incoming observed-fact/count path without recounting the
708    /// existing report.
709    fn merge_observed_identity_path(
710        &mut self,
711        path: &str,
712        incoming_facts: Option<&BTreeSet<IdentityFactKey>>,
713        incoming_total: u64,
714        control: &IndexWorkControl,
715    ) -> Result<(), CliError> {
716        control.check(IndexWorkStage::SymbolParsing)?;
717        let existing_total = self.rejected_facts_by_path.get(path).copied().unwrap_or(0);
718        let existing_observed = self.observed_facts.get(path).map_or(0, BTreeSet::len);
719        let existing_unobserved = existing_total
720            .checked_sub(u64::try_from(existing_observed).map_err(|error| {
721                CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
722            })?)
723            .ok_or_else(|| {
724                CliError::InvalidInput(
725                    "observed identity facts exceeded rejection count".to_string(),
726                )
727            })?;
728        let incoming_observed = incoming_facts.map_or(0, BTreeSet::len);
729        let incoming_unobserved = incoming_total
730            .checked_sub(u64::try_from(incoming_observed).map_err(|error| {
731                CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
732            })?)
733            .ok_or_else(|| {
734                CliError::InvalidInput(
735                    "observed identity facts exceeded rejection count".to_string(),
736                )
737            })?;
738        let new_observed = incoming_facts.map_or(0, |facts| {
739            facts
740                .iter()
741                .filter(|fact| {
742                    self.observed_facts
743                        .get(path)
744                        .is_none_or(|existing| !existing.contains(*fact))
745                })
746                .count()
747        });
748        let observed = existing_observed
749            .checked_add(new_observed)
750            .ok_or_else(|| CliError::InvalidInput("identity fact count overflowed".to_string()))?;
751        let total = u64::try_from(observed)
752            .map_err(|error| {
753                CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
754            })?
755            .checked_add(existing_unobserved)
756            .and_then(|count| count.checked_add(incoming_unobserved))
757            .ok_or_else(|| {
758                CliError::InvalidInput("identity rejection count overflowed".to_string())
759            })?;
760
761        let mut before_bytes = 0_u64;
762        if self.observed_facts.contains_key(path) {
763            before_bytes = before_bytes
764                .checked_add(identity_observed_path_retained_bytes(
765                    path,
766                    existing_observed,
767                )?)
768                .ok_or_else(|| {
769                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
770                })?;
771        }
772        if self.rejected_facts_by_path.contains_key(path) {
773            before_bytes = before_bytes
774                .checked_add(identity_count_path_retained_bytes(path)?)
775                .ok_or_else(|| {
776                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
777                })?;
778        }
779        let mut after_bytes = 0_u64;
780        if observed > 0 {
781            after_bytes = after_bytes
782                .checked_add(identity_observed_path_retained_bytes(path, observed)?)
783                .ok_or_else(|| {
784                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
785                })?;
786        }
787        if total > 0 {
788            after_bytes = after_bytes
789                .checked_add(identity_count_path_retained_bytes(path)?)
790                .ok_or_else(|| {
791                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
792                })?;
793        }
794        self.adjust_identity_bytes(before_bytes, after_bytes)?;
795
796        if observed == 0 {
797            self.observed_facts.remove(path);
798        } else if let Some(facts) = incoming_facts
799            && !facts.is_empty()
800        {
801            self.observed_facts
802                .entry(path.to_string())
803                .or_default()
804                .extend(facts.iter().cloned());
805        }
806        if total == 0 {
807            self.rejected_facts_by_path.remove(path);
808            self.observed_facts.remove(path);
809        } else {
810            self.rejected_facts_by_path.insert(path.to_string(), total);
811        }
812        Ok(())
813    }
814
815    /// Reserve a per-path reconciliation entry before retaining its path key.
816    fn reserve_reused_path_bytes(
817        &mut self,
818        path: &str,
819        already_retained: bool,
820        control: &IndexWorkControl,
821        limit: u64,
822    ) -> Result<(), CliError> {
823        control.check(IndexWorkStage::SymbolParsing)?;
824        if !already_retained {
825            self.reserve_identity_bytes(identity_count_path_retained_bytes(path)?, control, limit)?;
826        }
827        Ok(())
828    }
829
830    /// Add rejected-fact count that exceeded the bounded typed-detail rows.
831    fn record_rejected_fact_count(
832        &mut self,
833        path: &str,
834        count: usize,
835        control: &IndexWorkControl,
836    ) -> Result<(), CliError> {
837        if count == 0 {
838            control.check(IndexWorkStage::SymbolParsing)?;
839            return Ok(());
840        }
841        control.check(IndexWorkStage::SymbolParsing)?;
842        let path = RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract)?;
843        let count = u64::try_from(count).map_err(|error| {
844            CliError::InvalidInput(format!("identity rejection count overflowed: {error}"))
845        })?;
846        let path = path.as_str().to_owned();
847        if !self.rejected_facts_by_path.contains_key(&path) {
848            self.reserve_reused_path_bytes(&path, false, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
849        }
850        let entry = self.rejected_facts_by_path.entry(path).or_default();
851        *entry = entry.checked_add(count).ok_or_else(|| {
852            CliError::InvalidInput("identity rejection count overflowed".to_string())
853        })?;
854        Ok(())
855    }
856
857    /// Merge one report while retaining the global detail bound.
858    fn merge(&mut self, other: Self, control: &IndexWorkControl) -> Result<(), CliError> {
859        control.check(IndexWorkStage::SymbolParsing)?;
860        let peak_retained_bytes = self
861            .observed_fact_bytes
862            .checked_add(other.observed_fact_bytes)
863            .ok_or_else(|| identity_fact_budget_failure(self.observed_fact_bytes))?;
864        if peak_retained_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
865            return Err(identity_fact_budget_failure(peak_retained_bytes));
866        }
867        #[cfg(test)]
868        {
869            self.paired_import_pairing_work = self
870                .paired_import_pairing_work
871                .checked_add(other.paired_import_pairing_work)
872                .ok_or_else(|| {
873                    CliError::InvalidInput("paired import work count overflowed".to_string())
874                })?;
875        }
876        self.source_admitted |= other.source_admitted;
877        if other
878            .resolution_projections
879            .keys()
880            .any(|path| self.resolution_projections.contains_key(path))
881        {
882            return Err(CliError::InvalidInput(
883                "duplicate resolution projection admission".to_string(),
884            ));
885        }
886        let other_observed_facts = other.observed_facts;
887        let other_rejected_facts_by_path = other.rejected_facts_by_path;
888        let other_reused_rejection_facts = other.reused_rejection_facts;
889        let other_rejection_details_dropped_by_path = other.rejection_details_dropped_by_path;
890        for (path, incoming_total) in &other_rejected_facts_by_path {
891            self.merge_observed_identity_path(
892                path,
893                other_observed_facts.get(path),
894                *incoming_total,
895                control,
896            )?;
897        }
898        for (path, facts) in &other_observed_facts {
899            if !other_rejected_facts_by_path.contains_key(path) {
900                self.merge_observed_identity_path(path, Some(facts), 0, control)?;
901            }
902        }
903        for (path, facts) in other_reused_rejection_facts {
904            control.check(IndexWorkStage::SymbolParsing)?;
905            let existing_facts = self.reused_rejection_facts.get(&path);
906            let existing_count = existing_facts.map_or(0, BTreeSet::len);
907            let new_count = facts
908                .iter()
909                .filter(|fact| existing_facts.is_none_or(|existing| !existing.contains(*fact)))
910                .count();
911            let after_count = existing_count.checked_add(new_count).ok_or_else(|| {
912                CliError::InvalidInput("identity fact count overflowed".to_string())
913            })?;
914            let before_bytes = existing_facts.map_or(Ok(0), |facts| {
915                identity_fact_set_retained_bytes(&path, facts)
916            })?;
917            let after_bytes = if after_count == 0 {
918                0
919            } else {
920                identity_observed_path_retained_bytes(&path, after_count)?
921            };
922            self.adjust_identity_bytes(before_bytes, after_bytes)?;
923            if after_count == 0 {
924                self.reused_rejection_facts.remove(&path);
925            } else {
926                self.reused_rejection_facts
927                    .entry(path)
928                    .or_default()
929                    .extend(facts);
930            }
931        }
932        for (path, indices) in other.relation_fact_indices {
933            control.check(IndexWorkStage::SymbolParsing)?;
934            if let Some(existing) = self.relation_fact_indices.get(&path) {
935                if existing != &indices {
936                    return Err(CliError::InvalidInput(
937                        "conflicting relation fact admission".to_string(),
938                    ));
939                }
940            } else {
941                self.relation_fact_indices.insert(path, indices);
942            }
943        }
944        control.check(IndexWorkStage::SymbolParsing)?;
945        let rejection_key_bytes = extend_bounded_identity_rejections_with_drop_paths(
946            &mut self.rejections,
947            &mut self.rejection_keys,
948            other.rejections,
949            &mut self.rejection_details_dropped_by_path,
950        )?;
951        self.observed_fact_bytes = self
952            .observed_fact_bytes
953            .checked_add(rejection_key_bytes)
954            .ok_or_else(|| {
955                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
956            })?;
957        for path in other_rejection_details_dropped_by_path {
958            control.check(IndexWorkStage::SymbolParsing)?;
959            if self.rejection_details_dropped_by_path.insert(path.clone()) {
960                self.reserve_identity_bytes(
961                    identity_count_path_retained_bytes(&path)?,
962                    control,
963                    MAX_IN_MEMORY_GRAPH_WORK_BYTES,
964                )?;
965            }
966        }
967        self.resolution_projections
968            .extend(other.resolution_projections);
969        for (path, count) in other.reused_rejection_counts {
970            control.check(IndexWorkStage::SymbolParsing)?;
971            let new_path = !self.reused_rejection_counts.contains_key(&path);
972            let path_bytes = if new_path {
973                identity_count_path_retained_bytes(&path)?
974            } else {
975                0
976            };
977            if self
978                .reused_rejection_counts
979                .insert(path.clone(), count)
980                .is_some()
981            {
982                return Err(CliError::InvalidInput(
983                    "duplicate reused identity rejection count".to_string(),
984                ));
985            }
986            if new_path {
987                self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
988            }
989        }
990        for (path, count) in other.reused_parser_rejection_counts {
991            control.check(IndexWorkStage::SymbolParsing)?;
992            let new_path = !self.reused_parser_rejection_counts.contains_key(&path);
993            let path_bytes = if new_path {
994                identity_count_path_retained_bytes(&path)?
995            } else {
996                0
997            };
998            if self
999                .reused_parser_rejection_counts
1000                .insert(path.clone(), count)
1001                .is_some()
1002            {
1003                return Err(CliError::InvalidInput(
1004                    "duplicate reused parser rejection count".to_string(),
1005                ));
1006            }
1007            if new_path {
1008                self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1009            }
1010        }
1011        for (path, count) in other.reused_rejection_detail_counts {
1012            control.check(IndexWorkStage::SymbolParsing)?;
1013            let new_path = !self.reused_rejection_detail_counts.contains_key(&path);
1014            let path_bytes = if new_path {
1015                identity_count_path_retained_bytes(&path)?
1016            } else {
1017                0
1018            };
1019            if self
1020                .reused_rejection_detail_counts
1021                .insert(path.clone(), count)
1022                .is_some()
1023            {
1024                return Err(CliError::InvalidInput(
1025                    "duplicate reused identity rejection detail count".to_string(),
1026                ));
1027            }
1028            if new_path {
1029                self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1030            }
1031        }
1032        control.check(IndexWorkStage::SymbolParsing)?;
1033        for path in other.reused_rejection_details_incomplete {
1034            let path_bytes = identity_count_path_retained_bytes(&path)?;
1035            if self.reused_rejection_details_incomplete.insert(path) {
1036                self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1037            }
1038        }
1039        if self.observed_fact_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
1040            return Err(identity_fact_budget_failure(self.observed_fact_bytes));
1041        }
1042        #[cfg(test)]
1043        for (key, count) in other.resolution_derivations {
1044            let entry = self.resolution_derivations.entry(key).or_default();
1045            *entry = entry.saturating_add(count);
1046        }
1047        Ok(())
1048    }
1049
1050    /// Return the number of rejected parser facts for one path.
1051    fn rejected_facts_for(&self, path: &str) -> u64 {
1052        self.rejected_facts_by_path.get(path).copied().unwrap_or(0)
1053    }
1054
1055    /// Return the source-admission details owned by one publication path set.
1056    fn for_paths(&self, paths: &BTreeSet<String>) -> Result<Self, CliError> {
1057        let rejected_facts_by_path = self
1058            .rejected_facts_by_path
1059            .iter()
1060            .filter(|(path, _count)| paths.contains(path.as_str()))
1061            .map(|(path, count)| (path.clone(), *count))
1062            .collect::<BTreeMap<_, _>>();
1063        let observed_facts = self
1064            .observed_facts
1065            .iter()
1066            .filter(|(path, _facts)| paths.contains(*path))
1067            .map(|(path, facts)| (path.clone(), facts.clone()))
1068            .collect::<BTreeMap<_, _>>();
1069        let rejections = self
1070            .rejections
1071            .iter()
1072            .filter(|rejection| paths.contains(rejection.path.as_str()))
1073            .cloned()
1074            .collect::<Vec<_>>();
1075        let rejection_keys = self
1076            .rejection_keys
1077            .iter()
1078            .filter(|key| paths.contains(key.path.as_str()))
1079            .cloned()
1080            .collect::<BTreeSet<_>>();
1081        let rejection_details_dropped_by_path = self
1082            .rejection_details_dropped_by_path
1083            .iter()
1084            .filter(|path| paths.contains(path.as_str()))
1085            .cloned()
1086            .collect::<BTreeSet<_>>();
1087        let marker_bytes =
1088            identity_rejection_drop_paths_retained_bytes(&rejection_details_dropped_by_path)?;
1089        let observed_fact_bytes =
1090            identity_maps_retained_bytes(&observed_facts, &rejected_facts_by_path)?
1091                .checked_add(identity_rejection_keys_retained_bytes(&rejection_keys)?)
1092                .and_then(|bytes| bytes.checked_add(marker_bytes))
1093                .ok_or_else(|| {
1094                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1095                })?;
1096        Ok(Self {
1097            rejection_keys,
1098            rejections,
1099            source_admitted: self.source_admitted,
1100            observed_fact_bytes,
1101            reused_rejection_facts: BTreeMap::new(),
1102            rejected_facts_by_path,
1103            observed_facts,
1104            relation_fact_indices: self
1105                .relation_fact_indices
1106                .iter()
1107                .filter(|(path, _indices)| paths.contains(path.as_str()))
1108                .map(|(path, indices)| (path.clone(), indices.clone()))
1109                .collect(),
1110            resolution_projections: BTreeMap::new(),
1111            reused_rejection_counts: BTreeMap::new(),
1112            reused_parser_rejection_counts: BTreeMap::new(),
1113            reused_rejection_detail_counts: BTreeMap::new(),
1114            reused_rejection_details_incomplete: BTreeSet::new(),
1115            rejection_details_dropped_by_path,
1116            #[cfg(test)]
1117            resolution_derivations: BTreeMap::new(),
1118            #[cfg(test)]
1119            paired_import_pairing_work: 0,
1120        })
1121    }
1122
1123    /// Return whether this report contains any rejected source facts.
1124    fn has_rejections(&self) -> bool {
1125        !self.rejected_facts_by_path.is_empty()
1126    }
1127
1128    /// Return reused paths that cannot be reconciled without reparsing source.
1129    fn incomplete_reused_rejection_paths(&self) -> &BTreeSet<String> {
1130        &self.reused_rejection_details_incomplete
1131    }
1132
1133    /// Return whether a path lost a distinct typed rejection detail at the
1134    /// publication-wide ceiling.
1135    fn rejection_details_dropped_for(&self, path: &str) -> bool {
1136        self.rejection_details_dropped_by_path.contains(path)
1137    }
1138
1139    /// Reconcile a reused path's persisted total with current re-derived facts.
1140    fn rejected_facts_for_graph(&self, path: &str, derived: &Self) -> Result<u64, CliError> {
1141        let Some(persisted) = self.reused_rejection_counts.get(path).copied() else {
1142            return self
1143                .rejected_facts_for(path)
1144                .checked_add(derived.rejected_facts_for(path))
1145                .ok_or_else(|| {
1146                    CliError::InvalidInput("identity rejection count overflowed".to_string())
1147                });
1148        };
1149        let persisted_parser = self
1150            .reused_parser_rejection_counts
1151            .get(path)
1152            .copied()
1153            .unwrap_or(0);
1154        let persisted_identity = persisted.checked_sub(persisted_parser).ok_or_else(|| {
1155            CliError::InvalidInput("persisted parser rejection count exceeded total".to_string())
1156        })?;
1157        let mut current_facts = BTreeSet::<&IdentityFactKey>::new();
1158        if let Some(facts) = self.observed_facts.get(path) {
1159            current_facts.extend(facts);
1160        }
1161        if let Some(facts) = derived.observed_facts.get(path) {
1162            current_facts.extend(facts);
1163        }
1164        let current_known = u64::try_from(current_facts.len()).map_err(|error| {
1165            CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
1166        })?;
1167        let current_total = self
1168            .rejected_facts_for(path)
1169            .checked_add(derived.rejected_facts_for(path))
1170            .ok_or_else(|| {
1171                CliError::InvalidInput("identity rejection count overflowed".to_string())
1172            })?;
1173        let current_unknown = current_total.checked_sub(current_known).ok_or_else(|| {
1174            CliError::InvalidInput("observed identity facts exceeded rejection count".to_string())
1175        })?;
1176        let persisted_facts = self.reused_rejection_facts.get(path);
1177        let persisted_known = persisted_facts.map_or(0, BTreeSet::len);
1178        let persisted_known = u64::try_from(persisted_known).map_err(|error| {
1179            CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
1180        })?;
1181        let persisted_unknown =
1182            persisted_identity
1183                .checked_sub(persisted_known)
1184                .ok_or_else(|| {
1185                    CliError::InvalidInput(
1186                        "persisted identity facts exceeded rejection count".to_string(),
1187                    )
1188                })?;
1189        let mut all_facts = current_facts;
1190        if let Some(facts) = persisted_facts {
1191            all_facts.extend(
1192                facts
1193                    .iter()
1194                    .filter(|fact| !is_rederived_identity_fact(fact)),
1195            );
1196        }
1197        u64::try_from(all_facts.len())
1198            .map_err(|error| {
1199                CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
1200            })?
1201            .checked_add(persisted_unknown)
1202            .and_then(|count| count.checked_add(current_unknown))
1203            .ok_or_else(|| {
1204                CliError::InvalidInput("identity rejection count overflowed".to_string())
1205            })
1206    }
1207
1208    /// Return the parser-baseline omission retained for one reused path.
1209    fn parser_rejection_for_graph(&self, path: &str) -> u64 {
1210        self.reused_parser_rejection_counts
1211            .get(path)
1212            .copied()
1213            .unwrap_or(0)
1214    }
1215
1216    /// Return whether parser-output graphs have passed the shared source boundary.
1217    pub(super) fn source_admitted(&self) -> bool {
1218        self.source_admitted
1219    }
1220
1221    /// Return paths that need a graph publication because admission changed them.
1222    fn paths(&self) -> impl Iterator<Item = &str> {
1223        self.rejected_facts_by_path.keys().map(String::as_str)
1224    }
1225
1226    /// Return the already-admitted resolution projection for one graph path.
1227    fn resolution_projection(&self, path: &str) -> Option<&ResolutionKeyProjection> {
1228        self.resolution_projections.get(path)
1229    }
1230
1231    /// Translate an admitted relation index back to its parser ordinal.
1232    fn relation_parser_index(&self, path: &str, admitted_index: usize) -> usize {
1233        self.relation_fact_indices
1234            .get(path)
1235            .and_then(|indices| indices.get(admitted_index))
1236            .copied()
1237            .unwrap_or(admitted_index)
1238    }
1239
1240    /// Count one resolution-key derivation for the test-visible ownership proof.
1241    #[cfg(test)]
1242    fn record_resolution_derivation(&mut self, path: &str, generation: IndexGeneration) {
1243        let count = self
1244            .resolution_derivations
1245            .entry((path.to_string(), generation))
1246            .or_default();
1247        *count = count.saturating_add(1);
1248    }
1249}
1250
1251/// Build keyed membership for a retained typed detail vector.
1252fn identity_rejection_key_set(
1253    rejections: &[GraphIdentityRejection],
1254) -> BTreeSet<IdentityRejectionKey> {
1255    rejections.iter().map(IdentityRejectionKey::from).collect()
1256}
1257
1258/// Merge typed details under the one-publication storage ceiling.
1259#[cfg(test)]
1260fn extend_bounded_identity_rejections(
1261    target: &mut Vec<GraphIdentityRejection>,
1262    target_keys: &mut BTreeSet<IdentityRejectionKey>,
1263    incoming: impl IntoIterator<Item = GraphIdentityRejection>,
1264) -> Result<u64, CliError> {
1265    let mut dropped_paths = BTreeSet::new();
1266    extend_bounded_identity_rejections_with_drop_paths(
1267        target,
1268        target_keys,
1269        incoming,
1270        &mut dropped_paths,
1271    )
1272}
1273
1274/// Merge typed details and retain the exact paths that lost a distinct row.
1275fn extend_bounded_identity_rejections_with_drop_paths(
1276    target: &mut Vec<GraphIdentityRejection>,
1277    target_keys: &mut BTreeSet<IdentityRejectionKey>,
1278    incoming: impl IntoIterator<Item = GraphIdentityRejection>,
1279    dropped_paths: &mut BTreeSet<String>,
1280) -> Result<u64, CliError> {
1281    let mut retained_bytes = 0_u64;
1282    for rejection in incoming {
1283        let key = IdentityRejectionKey::from(&rejection);
1284        if target_keys.contains(&key) {
1285            continue;
1286        }
1287        if target.len() >= MAX_GRAPH_IDENTITY_REJECTIONS {
1288            if dropped_paths.insert(rejection.path.as_str().to_owned()) {
1289                retained_bytes = retained_bytes
1290                    .checked_add(identity_count_path_retained_bytes(rejection.path.as_str())?)
1291                    .ok_or_else(|| {
1292                        CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1293                    })?;
1294            }
1295            continue;
1296        }
1297        target_keys.insert(key);
1298        retained_bytes = retained_bytes
1299            .checked_add(identity_rejection_key_retained_bytes(
1300                rejection.path.as_str(),
1301            )?)
1302            .ok_or_else(|| {
1303                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1304            })?;
1305        target.push(rejection);
1306    }
1307    Ok(retained_bytes)
1308}
1309
1310/// Mark graph-scoped coverage when publication evicted a typed identity row.
1311fn mark_identity_rejection_coverage_limits(
1312    coverage: &mut [CoverageRecord],
1313    dropped_paths: &BTreeSet<String>,
1314) -> Result<(), CliError> {
1315    for row in coverage {
1316        let CoverageScope::Path { path } = row.scope() else {
1317            continue;
1318        };
1319        if row.relation().is_some()
1320            || !dropped_paths.contains(path.as_str())
1321            || row.reached_limit() == Some(GraphLimitKind::Rows)
1322        {
1323            continue;
1324        }
1325        *row = CoverageRecord::new(
1326            row.scope().clone(),
1327            row.relation(),
1328            row.state(),
1329            row.covered(),
1330            row.omitted(),
1331            row.generation(),
1332            row.reason().cloned(),
1333            Some(GraphLimitKind::Rows),
1334        )
1335        .map_err(invalid_graph_contract)?;
1336    }
1337    Ok(())
1338}
1339
1340/// One graph mutation staged outside the database writer transaction.
1341pub(super) enum RepositoryGraphMutation {
1342    /// Replace the complete normalized graph.
1343    Full,
1344    /// Replace the exact admitted source closure while retaining unrelated rows.
1345    AffectedPaths(Vec<String>),
1346}
1347
1348/// Complete normalized graph and canonical-key rows waiting for publication.
1349pub(super) struct StagedRepositoryGraph {
1350    /// Project identity owning every staged graph fact.
1351    project: ProjectInstanceId,
1352    /// Full or affected-path replacement selected by the staging caller.
1353    mutation: RepositoryGraphMutation,
1354    /// Stable typed entities ready for normalized persistence.
1355    entities: Vec<GraphEntity>,
1356    /// Deduplicated logical relationships ready for persistence.
1357    relations: Vec<LogicalRelation>,
1358    /// Exact source occurrences retained separately from logical relations.
1359    occurrences: Vec<RelationOccurrence>,
1360    /// Parser and relation coverage facts for staged source paths.
1361    coverage: Vec<CoverageRecord>,
1362    /// Canonical resolution keys exported by staged entities.
1363    entity_exports: Vec<EntityResolutionKey>,
1364    /// Canonical dependency keys retained by staged relations.
1365    relation_dependencies: Vec<RelationDependencyKey>,
1366    /// Closed reasons retained only for unresolved canonical document relations.
1367    document_unresolved_reasons: Vec<(LogicalRelationKey, DocumentTargetUnresolvedReason)>,
1368    /// Bounded typed parser-identity rejections committed with this generation.
1369    identity_rejections: Vec<GraphIdentityRejection>,
1370    /// Test-only resolution derivation counts used to prove incremental reuse.
1371    #[cfg(test)]
1372    resolution_derivations: BTreeMap<(String, IndexGeneration), usize>,
1373    /// Test-visible peak of the entity projection's map-plus-entity estimate.
1374    #[cfg(test)]
1375    peak_retained_bytes: u64,
1376    /// Test-visible order proving projections moved before entity allocation.
1377    #[cfg(test)]
1378    projection_removals_before_entities: Vec<String>,
1379    /// Effective scan policy used to recheck non-indexed document targets before commit.
1380    scan_policy: RootScanPolicy,
1381    /// Non-indexed target states observed while resolving document candidates.
1382    document_target_states: Vec<(String, DocumentTargetUnresolvedReason)>,
1383    /// Optional disposable database replacing the in-memory row vectors.
1384    database: Option<StagedGraphDatabase>,
1385    /// Conservative bytes retained until the parent publication completes.
1386    retained_bytes: u64,
1387}
1388
1389/// File-backed graph rows staged outside the main database writer transaction.
1390struct StagedGraphDatabase {
1391    /// Open typed store copied into the main publication.
1392    store: Option<AtlasStore>,
1393    /// Owning directory removed after the store closes.
1394    directory: Option<TempDir>,
1395    /// Cross-process lease preventing restart cleanup while the stage is active.
1396    _lease: File,
1397}
1398
1399impl StagedGraphDatabase {
1400    /// Return the live typed staging store.
1401    fn store(&self) -> Result<&AtlasStore, CliError> {
1402        self.store
1403            .as_ref()
1404            .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
1405    }
1406
1407    /// Return the live typed staging store mutably during preparation.
1408    fn store_mut(&mut self) -> Result<&mut AtlasStore, CliError> {
1409        self.store
1410            .as_mut()
1411            .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
1412    }
1413
1414    /// Return the live disposable staging directory.
1415    fn directory(&self) -> Result<&TempDir, CliError> {
1416        self.directory
1417            .as_ref()
1418            .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
1419    }
1420}
1421
1422impl Drop for StagedGraphDatabase {
1423    fn drop(&mut self) {
1424        let prepared = self.store.take().is_some();
1425        let Some(directory) = self.directory.take() else {
1426            return;
1427        };
1428        let database_path = directory.path().join(GRAPH_STAGE_DATABASE_FILE_NAME);
1429        let direct_database = fs::symlink_metadata(&database_path).is_ok_and(|metadata| {
1430            metadata.file_type().is_file() && !metadata.file_type().is_symlink()
1431        });
1432        if prepared
1433            && direct_database
1434            && remove_owned_graph_stage_payload(directory.path(), &database_path, None).is_ok()
1435        {
1436            drop(directory);
1437        } else {
1438            let _retained_path: PathBuf = directory.keep();
1439        }
1440    }
1441}
1442
1443impl StagedRepositoryGraph {
1444    /// Return conservative retained bytes counted toward the parent staging budget.
1445    pub(super) const fn retained_bytes(&self) -> u64 {
1446        self.retained_bytes
1447    }
1448
1449    /// Refuse publication if a consulted non-indexed target changed after resolution.
1450    pub(super) fn revalidate_document_targets(&self, root: &Path) -> Result<(), CliError> {
1451        if self.document_target_states.is_empty() {
1452            return Ok(());
1453        }
1454        let current = DocumentResolutionIndex::new(root, &[], &self.scan_policy)?;
1455        for (path, expected) in &self.document_target_states {
1456            if current.absent_reason(path)? != *expected {
1457                return Err(source_changed_during_derivation(root, path));
1458            }
1459        }
1460        Ok(())
1461    }
1462
1463    /// Apply the complete staged graph through the parent publication transaction.
1464    pub(super) fn apply(
1465        &self,
1466        publication: &mut IndexPublicationGuard<'_>,
1467        control: &IndexWorkControl,
1468    ) -> Result<(), CliError> {
1469        control.check(IndexWorkStage::Publication)?;
1470        if let Some(database) = &self.database {
1471            if !database.directory()?.path().is_dir() {
1472                return Err(CliError::InvalidInput(
1473                    "repository graph staging directory is unavailable".to_string(),
1474                ));
1475            }
1476            if !matches!(self.mutation, RepositoryGraphMutation::Full) {
1477                return Err(CliError::InvalidInput(
1478                    "database-backed graph staging only supports full replacement".to_string(),
1479                ));
1480            }
1481            publication.replace_repository_graph_from_staging(
1482                self.project,
1483                database.store()?,
1484                Some(control),
1485            )?;
1486            publication
1487                .replace_graph_identity_rejections(self.project, &self.identity_rejections)?;
1488            if !self.document_unresolved_reasons.is_empty() {
1489                publication.set_document_unresolved_reasons_controlled(
1490                    &self.document_unresolved_reasons,
1491                    control,
1492                )?;
1493            }
1494            control.check(IndexWorkStage::Publication)?;
1495            return Ok(());
1496        }
1497        match &self.mutation {
1498            RepositoryGraphMutation::Full => {
1499                publication.replace_repository_graph_with_resolution_keys(
1500                    self.project,
1501                    &self.entities,
1502                    &self.relations,
1503                    &self.occurrences,
1504                    &self.coverage,
1505                    &self.entity_exports,
1506                    &self.relation_dependencies,
1507                )?;
1508                publication
1509                    .replace_graph_identity_rejections(self.project, &self.identity_rejections)?;
1510            }
1511            RepositoryGraphMutation::AffectedPaths(paths) => {
1512                publication.replace_repository_graph_for_paths_with_resolution_keys(
1513                    self.project,
1514                    paths,
1515                    &self.entities,
1516                    &self.relations,
1517                    &self.occurrences,
1518                    &self.coverage,
1519                    &self.entity_exports,
1520                    &self.relation_dependencies,
1521                )?;
1522                publication
1523                    .replace_graph_identity_rejections(self.project, &self.identity_rejections)?;
1524            }
1525        }
1526        if !self.document_unresolved_reasons.is_empty() {
1527            publication.set_document_unresolved_reasons_controlled(
1528                &self.document_unresolved_reasons,
1529                control,
1530            )?;
1531        }
1532        control.check(IndexWorkStage::Publication)?;
1533        Ok(())
1534    }
1535}
1536
1537/// Stage a complete repository graph from current parser output plus safe reused graphs.
1538pub(super) fn stage_full_repository_graph(
1539    store: &AtlasStore,
1540    root: &Path,
1541    base_generation: IndexGeneration,
1542    nodes: &[Node],
1543    scan_policy: &RootScanPolicy,
1544    symbols: &SymbolBuildStage,
1545    control: &IndexWorkControl,
1546) -> Result<StagedRepositoryGraph, CliError> {
1547    cleanup_abandoned_repository_graph_staging(store, root, control)?;
1548    let project = selected_project(store)?;
1549    let generation = next_generation(base_generation)?;
1550    let paths = nodes
1551        .iter()
1552        .filter(|node| node.kind == NodeKind::File)
1553        .map(|node| node.path.clone())
1554        .collect::<BTreeSet<_>>();
1555    let loaded_graphs = complete_symbol_graphs(store, &paths, symbols, control)?;
1556    let (graphs, mut identity_admission) = admit_symbol_graphs(loaded_graphs, control)?;
1557    let changed_paths = symbols
1558        .changes
1559        .iter()
1560        .map(|change| match change {
1561            SymbolProjectionChange::Parsed(parsed) => parsed.path.as_str(),
1562            SymbolProjectionChange::Clear { path, .. } => path.as_str(),
1563        })
1564        .collect::<BTreeSet<_>>();
1565    let reused_paths = paths
1566        .iter()
1567        .filter(|path| !changed_paths.contains(path.as_str()))
1568        .cloned()
1569        .collect::<BTreeSet<_>>();
1570    hydrate_reused_identity_admission(
1571        store,
1572        project,
1573        &reused_paths,
1574        &graphs,
1575        &mut identity_admission,
1576        control,
1577    )?;
1578    if !identity_admission
1579        .incomplete_reused_rejection_paths()
1580        .is_empty()
1581    {
1582        return Err(dependency_closure_limit(
1583            root,
1584            identity_admission
1585                .incomplete_reused_rejection_paths()
1586                .iter()
1587                .cloned(),
1588            identity_admission.incomplete_reused_rejection_paths().len(),
1589        ));
1590    }
1591    identity_admission.merge(symbols.identity_admission.for_paths(&paths)?, control)?;
1592    let mut document_facts = complete_markdown_facts(root, nodes, &graphs, symbols, control)?;
1593    admit_markdown_facts(
1594        &mut document_facts,
1595        &graphs,
1596        &mut identity_admission,
1597        control,
1598    )?;
1599    control.check(IndexWorkStage::SymbolParsing)?;
1600    let configured_modules =
1601        super::module_resolution::load_configured_module_resolution(root, nodes, control)?;
1602    let packages = PackageIndex::from_graphs(&graphs)?;
1603    admit_resolution_key_failures(
1604        project,
1605        generation,
1606        &graphs,
1607        &packages,
1608        &configured_modules,
1609        &mut identity_admission,
1610        control,
1611    )?;
1612    ensure_admitted_resolution_projections(&graphs, &identity_admission.resolution_projections)?;
1613    let entity_projection = build_entity_projection_with_config(
1614        project,
1615        generation,
1616        nodes,
1617        &graphs,
1618        &packages,
1619        &configured_modules,
1620        Some(&mut identity_admission.resolution_projections),
1621        true,
1622        control,
1623    )?;
1624    debug_assert!(identity_admission.resolution_projections.is_empty());
1625    let candidates = resolution_registry_from_exports(&entity_projection, control)?;
1626    enforce_resolution_staging_budget(&entity_projection, &candidates)?;
1627    let document_projection_bytes = document_projection_retained_bytes(&document_facts, control)?;
1628    let graph_work_bytes = symbols
1629        .retained_bytes
1630        .saturating_add(identity_admission.observed_fact_bytes)
1631        .saturating_add(entity_projection.retained_bytes)
1632        .saturating_add(candidates.retained_bytes)
1633        .saturating_add(document_fact_map_retained_bytes(&document_facts))
1634        .saturating_add(document_projection_bytes);
1635    if graph_work_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
1636        finish_projection_in_database_with_documents(
1637            root,
1638            nodes,
1639            project,
1640            generation,
1641            &graphs,
1642            &document_facts,
1643            &identity_admission,
1644            entity_projection,
1645            &candidates,
1646            scan_policy,
1647            control,
1648        )
1649    } else {
1650        finish_projection_with_documents(
1651            project,
1652            generation,
1653            RepositoryGraphMutation::Full,
1654            &graphs,
1655            root,
1656            nodes,
1657            &document_facts,
1658            &identity_admission,
1659            entity_projection,
1660            &candidates,
1661            scan_policy,
1662            control,
1663        )
1664    }
1665}
1666
1667/// Stage one bounded dependency-aware graph closure for an incremental publication.
1668pub(super) fn stage_incremental_repository_graph(
1669    store: &AtlasStore,
1670    root: &Path,
1671    base_generation: IndexGeneration,
1672    expected_nodes: &[Node],
1673    direct_paths: &[String],
1674    scan_policy: &RootScanPolicy,
1675    symbols: &SymbolBuildStage,
1676    control: &IndexWorkControl,
1677) -> Result<StagedRepositoryGraph, CliError> {
1678    stage_incremental_repository_graph_with_limit(
1679        store,
1680        root,
1681        base_generation,
1682        expected_nodes,
1683        direct_paths,
1684        scan_policy,
1685        symbols,
1686        control,
1687        super::MAX_PUBLICATION_STAGING_BYTES,
1688    )
1689}
1690
1691#[cfg(test)]
1692fn stage_incremental_repository_graph_with_test_limit(
1693    store: &AtlasStore,
1694    root: &Path,
1695    base_generation: IndexGeneration,
1696    expected_nodes: &[Node],
1697    direct_paths: &[String],
1698    scan_policy: &RootScanPolicy,
1699    symbols: &SymbolBuildStage,
1700    control: &IndexWorkControl,
1701    staging_limit: u64,
1702) -> Result<StagedRepositoryGraph, CliError> {
1703    stage_incremental_repository_graph_with_limit(
1704        store,
1705        root,
1706        base_generation,
1707        expected_nodes,
1708        direct_paths,
1709        scan_policy,
1710        symbols,
1711        control,
1712        staging_limit,
1713    )
1714}
1715
1716#[allow(clippy::too_many_arguments)]
1717/// Stage an incremental graph with the supplied projection staging limit.
1718fn stage_incremental_repository_graph_with_limit(
1719    store: &AtlasStore,
1720    root: &Path,
1721    base_generation: IndexGeneration,
1722    expected_nodes: &[Node],
1723    direct_paths: &[String],
1724    scan_policy: &RootScanPolicy,
1725    symbols: &SymbolBuildStage,
1726    control: &IndexWorkControl,
1727    staging_limit: u64,
1728) -> Result<StagedRepositoryGraph, CliError> {
1729    let project = selected_project(store)?;
1730    let generation = next_generation(base_generation)?;
1731    let configured_modules =
1732        super::module_resolution::load_configured_module_resolution(root, expected_nodes, control)?;
1733    let direct_paths = direct_paths.iter().cloned().collect::<BTreeSet<_>>();
1734    enforce_incremental_count(
1735        root,
1736        "direct source paths",
1737        direct_paths.len(),
1738        &direct_paths,
1739    )?;
1740    let _direct_footprint =
1741        admitted_persisted_footprint(store, project, root, &direct_paths, control)?;
1742
1743    let current_file_paths = expected_nodes
1744        .iter()
1745        .filter(|node| node.kind == NodeKind::File)
1746        .map(|node| node.path.clone())
1747        .collect::<BTreeSet<_>>();
1748    let direct_graph_paths = direct_paths
1749        .intersection(&current_file_paths)
1750        .cloned()
1751        .collect::<BTreeSet<_>>();
1752    let manifest_paths = expected_nodes
1753        .iter()
1754        .filter(|node| node.kind == NodeKind::File && is_cargo_manifest_path(&node.path))
1755        .map(|node| node.path.clone())
1756        .collect::<BTreeSet<_>>();
1757    let package_only_paths = manifest_paths
1758        .difference(&direct_graph_paths)
1759        .cloned()
1760        .collect::<BTreeSet<_>>();
1761    let loaded_direct_graphs =
1762        complete_symbol_graphs(store, &direct_graph_paths, symbols, control)?;
1763    let (direct_graphs, mut direct_identity_admission) =
1764        admit_symbol_graphs(loaded_direct_graphs, control)?;
1765    direct_identity_admission.merge(
1766        symbols.identity_admission.for_paths(&direct_graph_paths)?,
1767        control,
1768    )?;
1769    let package_graphs = complete_symbol_graphs(store, &package_only_paths, symbols, control)?;
1770    let (package_graphs, _package_context_admission) =
1771        admit_symbol_graphs(package_graphs, control)?;
1772    let package_index_graphs = direct_graphs
1773        .iter()
1774        .map(Cow::as_ref)
1775        .chain(package_graphs.iter().map(Cow::as_ref))
1776        .collect::<Vec<_>>();
1777    let direct_packages = PackageIndex::from_graphs(&package_index_graphs)?;
1778    admit_resolution_key_failures(
1779        project,
1780        generation,
1781        &direct_graphs,
1782        &direct_packages,
1783        &configured_modules,
1784        &mut direct_identity_admission,
1785        control,
1786    )?;
1787
1788    let old_exports = store.repository_export_keys_for_paths(
1789        project,
1790        &direct_paths.iter().cloned().collect::<Vec<_>>(),
1791        MAX_INCREMENTAL_RESOLUTION_ITEMS,
1792    )?;
1793    if old_exports.truncated {
1794        return Err(dependency_closure_limit(
1795            root,
1796            direct_paths.iter().cloned(),
1797            usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
1798        ));
1799    }
1800    let mut changed_keys = old_exports.rows.into_iter().collect::<BTreeSet<_>>();
1801    for path in &direct_paths {
1802        changed_keys.insert(document_file_resolution_key(project, path)?);
1803        changed_keys.insert(document_casefold_resolution_key(project, path)?);
1804    }
1805    for graph in &direct_graphs {
1806        control.check(IndexWorkStage::SymbolParsing)?;
1807        let projection = direct_identity_admission
1808            .resolution_projection(&graph.path)
1809            .ok_or_else(|| {
1810                CliError::InvalidInput(format!(
1811                    "resolution keys were not admitted for graph {}",
1812                    graph.path
1813                ))
1814            })?;
1815        changed_keys.extend(projection.source_keys().iter().cloned());
1816        for symbol in projection.symbol_keys() {
1817            changed_keys.extend(symbol.keys().iter().cloned());
1818        }
1819        for symbol in &graph.symbols {
1820            if symbol.kind == SymbolKind::Heading {
1821                changed_keys.insert(document_heading_resolution_key(
1822                    project,
1823                    &graph.path,
1824                    &symbol.signature,
1825                )?);
1826            }
1827        }
1828    }
1829    enforce_incremental_count(
1830        root,
1831        "old and new export keys",
1832        changed_keys.len(),
1833        &direct_paths,
1834    )?;
1835
1836    let inbound = store.repository_affected_source_paths(
1837        project,
1838        &changed_keys.iter().cloned().collect::<Vec<_>>(),
1839        MAX_INCREMENTAL_RESOLUTION_ITEMS,
1840    )?;
1841    if inbound.truncated {
1842        return Err(dependency_closure_limit(
1843            root,
1844            inbound.rows.iter().map(|path| path.as_str().to_string()),
1845            usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
1846        ));
1847    }
1848    let mut affected_paths = direct_paths;
1849    affected_paths.extend(inbound.rows.into_iter().map(String::from));
1850    affected_paths.extend(direct_identity_admission.paths().map(ToString::to_string));
1851    enforce_incremental_count(
1852        root,
1853        "affected source paths",
1854        affected_paths.len(),
1855        &affected_paths,
1856    )?;
1857    control.check(IndexWorkStage::SymbolParsing)?;
1858    let persisted_footprint =
1859        admitted_persisted_footprint(store, project, root, &affected_paths, control)?;
1860
1861    let affected_graph_paths = affected_paths
1862        .intersection(&current_file_paths)
1863        .cloned()
1864        .collect::<BTreeSet<_>>();
1865    let newly_affected_graph_paths = affected_graph_paths
1866        .difference(&direct_graph_paths)
1867        .cloned()
1868        .collect::<BTreeSet<_>>();
1869    let loaded_affected_graphs =
1870        complete_symbol_graphs(store, &newly_affected_graph_paths, symbols, control)?;
1871    let (newly_affected_graphs, mut affected_identity_admission) =
1872        admit_symbol_graphs(loaded_affected_graphs, control)?;
1873    hydrate_reused_identity_admission(
1874        store,
1875        project,
1876        &newly_affected_graph_paths,
1877        &newly_affected_graphs,
1878        &mut affected_identity_admission,
1879        control,
1880    )?;
1881    if !affected_identity_admission
1882        .incomplete_reused_rejection_paths()
1883        .is_empty()
1884    {
1885        return Err(dependency_closure_limit(
1886            root,
1887            affected_identity_admission
1888                .incomplete_reused_rejection_paths()
1889                .iter()
1890                .cloned(),
1891            affected_identity_admission
1892                .incomplete_reused_rejection_paths()
1893                .len(),
1894        ));
1895    }
1896    let admitted_graphs = direct_graphs
1897        .iter()
1898        .map(Cow::as_ref)
1899        .chain(newly_affected_graphs.iter().map(Cow::as_ref))
1900        .chain(package_graphs.iter().map(Cow::as_ref))
1901        .collect::<Vec<_>>();
1902    let packages = PackageIndex::from_graphs(&admitted_graphs)?;
1903    admit_resolution_key_failures(
1904        project,
1905        generation,
1906        &newly_affected_graphs,
1907        &packages,
1908        &configured_modules,
1909        &mut affected_identity_admission,
1910        control,
1911    )?;
1912    direct_identity_admission.merge(affected_identity_admission, control)?;
1913    enforce_incremental_projection_budget(
1914        root,
1915        &affected_paths,
1916        0,
1917        direct_identity_admission.observed_fact_bytes,
1918    )?;
1919    let affected_graphs = direct_graphs
1920        .iter()
1921        .filter(|graph| affected_graph_paths.contains(&graph.path))
1922        .map(Cow::as_ref)
1923        .chain(newly_affected_graphs.iter().map(Cow::as_ref))
1924        .collect::<Vec<_>>();
1925    ensure_admitted_resolution_projections(
1926        &affected_graphs,
1927        &direct_identity_admission.resolution_projections,
1928    )?;
1929    let mut document_facts =
1930        complete_markdown_facts(root, expected_nodes, &affected_graphs, symbols, control)?;
1931    admit_markdown_facts(
1932        &mut document_facts,
1933        &affected_graphs,
1934        &mut direct_identity_admission,
1935        control,
1936    )?;
1937    let affected_nodes = expected_nodes
1938        .iter()
1939        .filter(|node| affected_paths.contains(&node.path))
1940        .cloned()
1941        .collect::<Vec<_>>();
1942    let entity_projection = build_entity_projection_with_config_limit(
1943        project,
1944        generation,
1945        &affected_nodes,
1946        &affected_graphs,
1947        &packages,
1948        &configured_modules,
1949        Some(&mut direct_identity_admission.resolution_projections),
1950        false,
1951        control,
1952        staging_limit,
1953    )?;
1954    debug_assert!(direct_identity_admission.resolution_projections.is_empty());
1955
1956    let mut dependency_keys = entity_projection
1957        .keys_by_graph
1958        .values()
1959        .flat_map(ResolutionKeyProjection::relation_keys)
1960        .flat_map(|relation| relation.keys().iter().cloned())
1961        .collect::<BTreeSet<_>>();
1962    dependency_keys.extend(document_dependency_keys(project, &document_facts)?);
1963    enforce_incremental_count(
1964        root,
1965        "affected dependency keys",
1966        dependency_keys.len(),
1967        &affected_paths,
1968    )?;
1969    let persisted = store.repository_resolution_candidates_for_keys(
1970        project,
1971        &dependency_keys.iter().cloned().collect::<Vec<_>>(),
1972        MAX_INCREMENTAL_RESOLUTION_ITEMS,
1973    )?;
1974    if persisted.truncated {
1975        return Err(dependency_closure_limit(
1976            root,
1977            affected_paths.iter().cloned(),
1978            usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
1979        ));
1980    }
1981    let mut candidates = resolution_registry_from_persisted(
1982        project,
1983        generation,
1984        persisted.rows,
1985        &affected_paths,
1986        control,
1987    )?;
1988    merge_resolution_registries(
1989        &mut candidates,
1990        resolution_registry_from_exports(&entity_projection, control)?,
1991        control,
1992    )?;
1993    let document_projection_bytes = document_projection_retained_bytes(&document_facts, control)?;
1994    enforce_incremental_projection_budget(
1995        root,
1996        &affected_paths,
1997        0,
1998        entity_projection
1999            .retained_bytes
2000            .saturating_add(candidates.retained_bytes)
2001            .saturating_add(document_fact_map_retained_bytes(&document_facts))
2002            .saturating_add(document_projection_bytes)
2003            .saturating_add(direct_identity_admission.observed_fact_bytes),
2004    )?;
2005    let staged = finish_projection_with_documents(
2006        project,
2007        generation,
2008        RepositoryGraphMutation::AffectedPaths(affected_paths.iter().cloned().collect()),
2009        &affected_graphs,
2010        root,
2011        expected_nodes,
2012        &document_facts,
2013        &direct_identity_admission,
2014        entity_projection,
2015        &candidates,
2016        scan_policy,
2017        control,
2018    )?;
2019    enforce_incremental_projection_limits(root, &affected_paths, persisted_footprint, &staged)?;
2020    Ok(staged)
2021}
2022
2023/// Entity/key facts prepared before relation resolution.
2024struct EntityProjection {
2025    /// Entities keyed by their compact digest for collision-safe lookup.
2026    entity_by_digest: BTreeMap<String, GraphEntity>,
2027    /// File and symbol owners keyed by parser graph path.
2028    owners_by_graph: BTreeMap<String, GraphOwners>,
2029    /// Canonical resolution-key projections keyed by parser graph path.
2030    keys_by_graph: BTreeMap<String, ResolutionKeyProjection>,
2031    /// Canonical resolution keys exported by staged entities.
2032    entity_exports: Vec<EntityResolutionKey>,
2033    /// Conservative bytes retained by entities and export keys.
2034    retained_bytes: u64,
2035    /// Test-visible peak of the map-plus-entity staging estimate.
2036    #[cfg(test)]
2037    peak_retained_bytes: u64,
2038    /// Test-visible order proving each projection moved before entity allocation.
2039    #[cfg(test)]
2040    projection_removals_before_entities: Vec<String>,
2041}
2042
2043/// File and symbol entities associated with one parser graph.
2044struct GraphOwners {
2045    /// Digest of the file entity owning the parser graph.
2046    file_digest: String,
2047    /// Optional stable entity digest corresponding to each parser symbol row.
2048    symbol_digests: Vec<Option<String>>,
2049}
2050
2051/// Recognize include syntax without treating unknown or legacy import text as alias-free.
2052fn php_file_include_context(context: &str) -> bool {
2053    let context = context.trim_start_matches(|character: char| character.is_ascii_whitespace());
2054    ["include", "include_once", "require", "require_once"]
2055        .iter()
2056        .any(|keyword| {
2057            context
2058                .get(..keyword.len())
2059                .is_some_and(|prefix| prefix.eq_ignore_ascii_case(keyword))
2060                && context.get(keyword.len()..).is_some_and(|suffix| {
2061                    suffix.starts_with(|character: char| character.is_ascii_whitespace())
2062                        || suffix.starts_with(['(', '\'', '"', '#'])
2063                        || suffix.starts_with("/*")
2064                        || suffix.starts_with("//")
2065                })
2066        })
2067}
2068
2069/// Borrowed per-graph symbol lookup used by every relation in that file.
2070struct GraphSymbolIndex<'graph> {
2071    /// Parser symbol indices grouped by exact source name.
2072    indices_by_name: BTreeMap<&'graph str, Vec<usize>>,
2073    /// PHP call target indices grouped by their case-insensitive source name.
2074    php_call_indices_by_name: BTreeMap<String, Vec<usize>>,
2075    /// Import positions retained separately across repeated namespace declarations.
2076    php_import_positions: BTreeMap<String, BTreeMap<usize, Option<usize>>>,
2077    /// Named namespace declaration boundaries; tied line-only blocks stay conservative.
2078    php_namespace_start_lines: BTreeMap<String, BTreeSet<usize>>,
2079    /// Omitted import declarations cannot prove a namespace-local alias boundary.
2080    php_imports_have_unknown_scope: bool,
2081}
2082
2083impl<'graph> GraphSymbolIndex<'graph> {
2084    /// Build one bounded name index instead of rescanning all symbols per relation.
2085    fn new(graph: &'graph SymbolGraph, control: &IndexWorkControl) -> Result<Self, CliError> {
2086        let mut indices_by_name = BTreeMap::new();
2087        let mut php_call_indices_by_name = BTreeMap::new();
2088        let mut php_import_positions = BTreeMap::<String, BTreeMap<usize, Option<usize>>>::new();
2089        let mut php_namespace_start_lines = BTreeMap::<String, BTreeSet<usize>>::new();
2090        let mut php_imports_have_unknown_scope = false;
2091        let is_php = graph
2092            .language
2093            .as_deref()
2094            .is_some_and(|language| language.eq_ignore_ascii_case("php"));
2095        // Grouped PHP uses emit one symbol but several relations. Only equal
2096        // per-line counts prove that occurrence pairing preserves source order.
2097        let mut import_counts = HashMap::<usize, (usize, usize)>::new();
2098        let paired_imports = if is_php {
2099            for (index, symbol) in graph.symbols.iter().enumerate() {
2100                check_graph_work(control, index)?;
2101                if symbol.kind == SymbolKind::Import {
2102                    import_counts.entry(symbol.line_start).or_default().0 += 1;
2103                }
2104            }
2105            for (index, relation) in graph.relations.iter().enumerate() {
2106                check_graph_work(control, index)?;
2107                if relation.kind == RelationKind::Imports {
2108                    import_counts.entry(relation.line).or_default().1 += 1;
2109                }
2110            }
2111            Some(paired_import_relations(graph, control)?)
2112        } else {
2113            None
2114        };
2115        for (index, symbol) in graph.symbols.iter().enumerate() {
2116            check_graph_work(control, index)?;
2117            // PHP emits import symbols only for namespace use, not includes or traits.
2118            if is_php && symbol.kind == SymbolKind::Import {
2119                let ordinal = import_counts
2120                    .get(&symbol.line_start)
2121                    .filter(|(symbols, relations)| symbols == relations)
2122                    .and(paired_imports.as_ref())
2123                    .and_then(|paired| paired.by_symbol.get(index).copied().flatten());
2124                php_import_positions
2125                    .entry(symbol.parent.as_deref().unwrap_or("").to_ascii_lowercase())
2126                    .or_default()
2127                    .entry(symbol.line_start)
2128                    .or_insert(ordinal);
2129            }
2130            if is_php && symbol.kind == SymbolKind::Module {
2131                php_namespace_start_lines
2132                    .entry(symbol.name.to_ascii_lowercase())
2133                    .or_default()
2134                    .insert(symbol.line_start);
2135            }
2136            indices_by_name
2137                .entry(symbol.name.as_str())
2138                .or_insert_with(Vec::new)
2139                .push(index);
2140            if is_php && matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method) {
2141                php_call_indices_by_name
2142                    .entry(symbol.name.to_ascii_lowercase())
2143                    .or_insert_with(Vec::new)
2144                    .push(index);
2145            }
2146        }
2147        // Partial extraction may retain an import relation after its symbol was omitted.
2148        if is_php && graph.parser != ParserKind::TreeSitter {
2149            for (index, relation) in graph.relations.iter().enumerate() {
2150                check_graph_work(control, index)?;
2151                if relation.kind == RelationKind::Imports
2152                    && relation.source_name == MODULE_RELATION_SOURCE
2153                    && !php_file_include_context(&relation.context)
2154                {
2155                    php_imports_have_unknown_scope = true;
2156                    break;
2157                }
2158            }
2159        }
2160        Ok(Self {
2161            indices_by_name,
2162            php_call_indices_by_name,
2163            php_import_positions,
2164            php_namespace_start_lines,
2165            php_imports_have_unknown_scope,
2166        })
2167    }
2168
2169    /// Return exact parser symbol rows for one source name.
2170    fn get(&self, name: &str) -> &[usize] {
2171        self.indices_by_name.get(name).map_or(&[], Vec::as_slice)
2172    }
2173
2174    /// Return PHP parser symbol rows for one case-insensitive call name.
2175    fn get_php_call(&self, name: &str) -> &[usize] {
2176        self.php_call_indices_by_name
2177            .get(&name.to_ascii_lowercase())
2178            .map_or(&[], Vec::as_slice)
2179    }
2180}
2181
2182/// One conservative additive relation derived from already bounded parser facts.
2183struct DerivedRelationFact {
2184    /// Additive graph relation kind.
2185    kind: ExtendedRelationKind,
2186    /// Parser-compatible source, target, span, context, and trust facts.
2187    relation: SymbolRelation,
2188    /// Resolution strategy for the derived target.
2189    target: DerivedRelationTarget,
2190}
2191
2192/// Closed target classes accepted by additive relation projection.
2193enum DerivedRelationTarget {
2194    /// Resolve through the same typed provider keys as one parser relation.
2195    Parser {
2196        /// Canonical provider-owned dependency keys.
2197        keys: Vec<CanonicalResolutionKey>,
2198    },
2199    /// Resolve one statically visible repository-relative file path.
2200    RepositoryPath,
2201    /// Bind one content-free external namespace and identity.
2202    External {
2203        /// Closed external namespace.
2204        system: &'static str,
2205    },
2206}
2207
2208/// Package names assigned by longest manifest-directory ownership.
2209struct PackageIndex {
2210    /// Cargo packages ordered from the most-specific repository root.
2211    packages: Vec<PackageOwner>,
2212}
2213
2214/// One Cargo package and its owning repository prefix.
2215struct PackageOwner {
2216    /// Repository prefix owned by this package.
2217    root: String,
2218    /// Canonical package name from the manifest graph.
2219    name: String,
2220    /// Repository-relative manifest path proving ownership.
2221    manifest: String,
2222}
2223
2224impl PackageIndex {
2225    /// Build deterministic longest-prefix package ownership from manifest graphs.
2226    fn from_graphs(graphs: &[impl Borrow<SymbolGraph>]) -> Result<Self, CliError> {
2227        let mut packages = Vec::new();
2228        for graph in graphs {
2229            let graph = graph.borrow();
2230            for symbol in &graph.symbols {
2231                if symbol.kind != SymbolKind::Package {
2232                    continue;
2233                }
2234                RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?;
2235                let root = graph
2236                    .path
2237                    .rsplit_once('/')
2238                    .map_or(String::new(), |(parent, _manifest)| parent.to_string());
2239                packages.push(PackageOwner {
2240                    root,
2241                    name: symbol.name.clone(),
2242                    manifest: graph.path.clone(),
2243                });
2244            }
2245        }
2246        packages.sort_by(|left, right| {
2247            right
2248                .root
2249                .len()
2250                .cmp(&left.root.len())
2251                .then_with(|| left.root.cmp(&right.root))
2252                .then_with(|| left.name.cmp(&right.name))
2253                .then_with(|| left.manifest.cmp(&right.manifest))
2254        });
2255        packages.dedup_by(|left, right| {
2256            left.root == right.root && left.name == right.name && left.manifest == right.manifest
2257        });
2258        Ok(Self { packages })
2259    }
2260
2261    /// Return the most-specific Cargo package owning one repository path.
2262    fn package_name(&self, path: &str) -> Option<&str> {
2263        self.packages
2264            .iter()
2265            .find(|package| repository_path_belongs_to(path, &package.root))
2266            .map(|package| package.name.as_str())
2267    }
2268}
2269
2270/// Return whether one path belongs to a normalized repository prefix.
2271fn repository_path_belongs_to(path: &str, root: &str) -> bool {
2272    root.is_empty()
2273        || path == root
2274        || path
2275            .strip_prefix(root)
2276            .is_some_and(|suffix| suffix.starts_with('/'))
2277}
2278
2279/// Return whether one normalized repository path is a Cargo package manifest.
2280fn is_cargo_manifest_path(path: &str) -> bool {
2281    path == "Cargo.toml" || path.ends_with("/Cargo.toml")
2282}
2283
2284/// Qualify graph-only parent identity from the parser's existing containment rows.
2285///
2286/// Sorting and active-scope lookup are `O(n log n)`. Identity work is
2287/// `O(n * MAX_GRAPH_IDENTITY_BYTES)`: every retained parent is bounded, and an
2288/// overflowing candidate is hashed without materializing more than one bounded
2289/// parent plus one admitted component.
2290fn qualified_symbol_parents(
2291    graph: &SymbolGraph,
2292) -> Result<Vec<Option<GraphIdentityText>>, CliError> {
2293    let is_php = graph
2294        .language
2295        .as_deref()
2296        .is_some_and(|language| language.eq_ignore_ascii_case("php"));
2297    let mut order = (0..graph.symbols.len()).collect::<Vec<_>>();
2298    order.sort_by_key(|&index| (graph.symbols[index].line_start, index));
2299    let mut active_by_name = BTreeMap::<&str, Vec<usize>>::new();
2300    let mut qualified_names = vec![None::<GraphIdentityText>; graph.symbols.len()];
2301    let mut parents = vec![None; graph.symbols.len()];
2302    for index in order {
2303        let symbol = &graph.symbols[index];
2304        let name = source_symbol_identity(symbol.name.clone())?;
2305        let mut parent = symbol
2306            .parent
2307            .clone()
2308            .map(source_symbol_identity)
2309            .transpose()?;
2310        if let Some(immediate_parent) = parent.as_ref()
2311            && let Some(candidates) = active_by_name.get_mut(immediate_parent.as_str())
2312        {
2313            while candidates.last().is_some_and(|&candidate_index| {
2314                let candidate = &graph.symbols[candidate_index];
2315                // Namespace headers may precede their body; concrete PHP scopes
2316                // have exact ends even when the next declaration shares a line.
2317                if is_php
2318                    && candidate.kind != SymbolKind::Module
2319                    && let Some((outer, inner)) =
2320                        candidate.source_selector.zip(symbol.source_selector)
2321                {
2322                    outer.byte_end < inner.byte_end
2323                } else {
2324                    candidate.line_end < symbol.line_end
2325                }
2326            }) {
2327                candidates.pop();
2328            }
2329            if let Some(qualified_parent) = candidates
2330                .last()
2331                .and_then(|&candidate_index| qualified_names[candidate_index].clone())
2332            {
2333                parent = Some(qualified_parent);
2334            }
2335        }
2336        qualified_names[index] = Some(match parent.as_ref() {
2337            Some(parent) => qualified_symbol_identity(parent, &name)?,
2338            None => name,
2339        });
2340        parents[index] = parent;
2341        active_by_name
2342            .entry(symbol.name.as_str())
2343            .or_default()
2344            .push(index);
2345    }
2346    Ok(parents)
2347}
2348
2349/// Admit one parser-owned symbol identity outside the derived-scope namespace.
2350fn source_symbol_identity(value: String) -> Result<GraphIdentityText, CliError> {
2351    let identity = GraphIdentityText::new(value).map_err(invalid_graph_contract)?;
2352    if identity.as_str().starts_with(QUALIFIED_SYMBOL_SCOPE_PREFIX) {
2353        return Err(invalid_graph_contract(
2354            GraphContractError::InvalidIdentityText {
2355                reason: "source symbol identity uses the reserved derived-scope namespace",
2356            },
2357        ));
2358    }
2359    Ok(identity)
2360}
2361
2362/// Derive one exact or compact qualified scope from validated components.
2363fn qualified_symbol_identity(
2364    parent: &GraphIdentityText,
2365    name: &GraphIdentityText,
2366) -> Result<GraphIdentityText, CliError> {
2367    const SEPARATOR: &str = "::";
2368    const DIGEST_DOMAIN: &str = "projectatlas.graph.qualified-symbol-scope.v1";
2369    let qualified_len = parent.as_str().len() + SEPARATOR.len() + name.as_str().len();
2370    if qualified_len <= MAX_GRAPH_IDENTITY_BYTES {
2371        let mut qualified = String::with_capacity(qualified_len);
2372        qualified.push_str(parent.as_str());
2373        qualified.push_str(SEPARATOR);
2374        qualified.push_str(name.as_str());
2375        return GraphIdentityText::new(qualified).map_err(invalid_graph_contract);
2376    }
2377
2378    let compact_len =
2379        QUALIFIED_SYMBOL_SCOPE_PREFIX.len() + 64 + SEPARATOR.len() + name.as_str().len();
2380    if compact_len > MAX_GRAPH_IDENTITY_BYTES {
2381        return Err(invalid_graph_contract(
2382            GraphContractError::InvalidIdentityText {
2383                reason: "derived scope cannot retain its nearest admitted symbol name",
2384            },
2385        ));
2386    }
2387    let mut hasher = blake3::Hasher::new_derive_key(DIGEST_DOMAIN);
2388    hasher.update(parent.as_str().as_bytes());
2389    hasher.update(SEPARATOR.as_bytes());
2390    hasher.update(name.as_str().as_bytes());
2391    let digest = hasher.finalize().to_hex();
2392    let mut compact = String::with_capacity(compact_len);
2393    compact.push_str(QUALIFIED_SYMBOL_SCOPE_PREFIX);
2394    compact.push_str(digest.as_str());
2395    compact.push_str(SEPARATOR);
2396    compact.push_str(name.as_str());
2397    GraphIdentityText::new(compact).map_err(invalid_graph_contract)
2398}
2399
2400/// Project file, symbol, package, and canonical export facts from parser graphs.
2401#[cfg(test)]
2402fn build_entity_projection(
2403    project: ProjectInstanceId,
2404    generation: IndexGeneration,
2405    nodes: &[Node],
2406    graphs: &[impl Borrow<SymbolGraph>],
2407    packages: &PackageIndex,
2408    include_project: bool,
2409    control: &IndexWorkControl,
2410) -> Result<EntityProjection, CliError> {
2411    build_entity_projection_with_config(
2412        project,
2413        generation,
2414        nodes,
2415        graphs,
2416        packages,
2417        &ConfiguredModuleResolution::default(),
2418        None,
2419        include_project,
2420        control,
2421    )
2422}
2423
2424/// Project entity/key facts with one shared configured-module snapshot.
2425#[allow(clippy::too_many_arguments)]
2426fn build_entity_projection_with_config(
2427    project: ProjectInstanceId,
2428    generation: IndexGeneration,
2429    nodes: &[Node],
2430    graphs: &[impl Borrow<SymbolGraph>],
2431    packages: &PackageIndex,
2432    configured_modules: &ConfiguredModuleResolution,
2433    admitted_projections: Option<&mut BTreeMap<String, ResolutionKeyProjection>>,
2434    include_project: bool,
2435    control: &IndexWorkControl,
2436) -> Result<EntityProjection, CliError> {
2437    build_entity_projection_with_config_limit(
2438        project,
2439        generation,
2440        nodes,
2441        graphs,
2442        packages,
2443        configured_modules,
2444        admitted_projections,
2445        include_project,
2446        control,
2447        super::MAX_PUBLICATION_STAGING_BYTES,
2448    )
2449}
2450
2451/// Project entity/key facts with a caller-selected staging budget for proof seams.
2452#[allow(clippy::too_many_arguments)]
2453fn build_entity_projection_with_config_limit(
2454    project: ProjectInstanceId,
2455    generation: IndexGeneration,
2456    nodes: &[Node],
2457    graphs: &[impl Borrow<SymbolGraph>],
2458    packages: &PackageIndex,
2459    configured_modules: &ConfiguredModuleResolution,
2460    mut admitted_projections: Option<&mut BTreeMap<String, ResolutionKeyProjection>>,
2461    include_project: bool,
2462    control: &IndexWorkControl,
2463    staging_limit: u64,
2464) -> Result<EntityProjection, CliError> {
2465    let mut entity_by_digest = BTreeMap::new();
2466    let mut entity_exports = Vec::new();
2467    let mut entity_bytes = 0_u64;
2468    if include_project {
2469        let entity = GraphEntity::new(project, EntitySelector::Project, generation)
2470            .map_err(invalid_graph_contract)?;
2471        entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&entity));
2472        insert_entity(&mut entity_by_digest, entity)?;
2473    }
2474    for node in nodes {
2475        control.check(IndexWorkStage::SymbolParsing)?;
2476        let selector = match node.kind {
2477            NodeKind::Folder => EntitySelector::Folder {
2478                path: RepositoryNodePath::new(Path::new(&node.path))
2479                    .map_err(invalid_graph_contract)?,
2480            },
2481            NodeKind::File => EntitySelector::File {
2482                path: RepositoryFilePath::new(Path::new(&node.path))
2483                    .map_err(invalid_graph_contract)?,
2484            },
2485        };
2486        let entity =
2487            GraphEntity::new(project, selector, generation).map_err(invalid_graph_contract)?;
2488        entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&entity));
2489        if node.kind == NodeKind::File {
2490            for key in [
2491                document_file_resolution_key(project, &node.path)?,
2492                document_casefold_resolution_key(project, &node.path)?,
2493            ] {
2494                entity_exports.push(
2495                    EntityResolutionKey::new(entity.key().clone(), key)
2496                        .map_err(invalid_graph_contract)?,
2497                );
2498                entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
2499            }
2500        }
2501        insert_entity(&mut entity_by_digest, entity)?;
2502    }
2503
2504    let mut owners_by_graph = BTreeMap::new();
2505    let mut keys_by_graph = BTreeMap::new();
2506    let mut retained_bytes = 0_u64;
2507    let mut admitted_map_bytes = admitted_projections
2508        .as_deref()
2509        .map(resolution_projection_map_retained_bytes)
2510        .unwrap_or_default();
2511    enforce_resolution_registry_budget_with_limit(
2512        admitted_map_bytes.saturating_add(entity_bytes),
2513        staging_limit,
2514    )?;
2515    #[cfg(test)]
2516    let mut peak_retained_bytes = admitted_map_bytes.saturating_add(entity_bytes);
2517    #[cfg(test)]
2518    let mut projection_removals_before_entities = Vec::new();
2519    for graph in graphs {
2520        let graph = graph.borrow();
2521        control.check(IndexWorkStage::SymbolParsing)?;
2522        let resolution = if let Some(projections) = admitted_projections.as_deref_mut() {
2523            let resolution = projections.remove(&graph.path).ok_or_else(|| {
2524                CliError::InvalidInput(format!(
2525                    "resolution keys were not admitted for graph {}",
2526                    graph.path
2527                ))
2528            })?;
2529            admitted_map_bytes = admitted_map_bytes.saturating_sub(
2530                resolution_projection_map_entry_retained_bytes(&graph.path, &resolution),
2531            );
2532            #[cfg(test)]
2533            projection_removals_before_entities.push(graph.path.clone());
2534            resolution
2535        } else {
2536            resolution_projection_with_config(
2537                project,
2538                packages.package_name(&graph.path),
2539                graph,
2540                configured_modules,
2541            )?
2542        };
2543        let resolution_bytes = resolution_retained_bytes(&resolution);
2544        entity_bytes = entity_bytes.saturating_add(resolution_bytes);
2545        #[cfg(test)]
2546        {
2547            peak_retained_bytes =
2548                peak_retained_bytes.max(admitted_map_bytes.saturating_add(entity_bytes));
2549        }
2550        enforce_resolution_registry_budget_with_limit(
2551            admitted_map_bytes.saturating_add(entity_bytes),
2552            staging_limit,
2553        )?;
2554        let file = GraphEntity::new(
2555            project,
2556            EntitySelector::File {
2557                path: RepositoryFilePath::new(Path::new(&graph.path))
2558                    .map_err(invalid_graph_contract)?,
2559            },
2560            generation,
2561        )
2562        .map_err(invalid_graph_contract)?;
2563        let file_digest = file.key().digest().to_string();
2564        entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&file));
2565        insert_entity(&mut entity_by_digest, file)?;
2566        let mut symbol_digests = Vec::with_capacity(graph.symbols.len());
2567        entity_bytes = entity_bytes.saturating_add(
2568            STAGED_GRAPH_ROW_BYTES
2569                .saturating_mul(u64::try_from(graph.symbols.len()).unwrap_or(u64::MAX)),
2570        );
2571        let qualified_parents = qualified_symbol_parents(graph)?;
2572        for (symbol, qualified_parent) in graph.symbols.iter().zip(qualified_parents) {
2573            control.check(IndexWorkStage::SymbolParsing)?;
2574            let entity = match symbol.kind {
2575                SymbolKind::Import | SymbolKind::Dependency | SymbolKind::Workspace => None,
2576                SymbolKind::Package => Some(
2577                    GraphEntity::new(
2578                        project,
2579                        EntitySelector::Package {
2580                            package: PackageSelector {
2581                                manager: GraphIdentityText::new(CARGO_PACKAGE_MANAGER)
2582                                    .map_err(invalid_graph_contract)?,
2583                                name: GraphIdentityText::new(symbol.name.clone())
2584                                    .map_err(invalid_graph_contract)?,
2585                                manifest: RepositoryFilePath::new(Path::new(&graph.path))
2586                                    .map_err(invalid_graph_contract)?,
2587                            },
2588                        },
2589                        generation,
2590                    )
2591                    .map_err(invalid_graph_contract)?,
2592                ),
2593                _ => Some(
2594                    GraphEntity::new(
2595                        project,
2596                        EntitySelector::Symbol {
2597                            symbol: SymbolSelector {
2598                                file: RepositoryFilePath::new(Path::new(&graph.path))
2599                                    .map_err(invalid_graph_contract)?,
2600                                name: GraphIdentityText::new(symbol.name.clone())
2601                                    .map_err(invalid_graph_contract)?,
2602                                kind: symbol.kind,
2603                                parent: qualified_parent,
2604                                signature: GraphIdentityText::new(
2605                                    if symbol.signature.trim().is_empty() {
2606                                        symbol.name.clone()
2607                                    } else {
2608                                        symbol.signature.trim().to_string()
2609                                    },
2610                                )
2611                                .map_err(invalid_graph_contract)?,
2612                            },
2613                        },
2614                        generation,
2615                    )
2616                    .map_err(invalid_graph_contract)?,
2617                ),
2618            };
2619            let entity_digest = entity
2620                .as_ref()
2621                .map(|entity| entity.key().digest().to_string());
2622            if let Some(entity) = entity {
2623                entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&entity));
2624                if symbol.kind == SymbolKind::Heading {
2625                    entity_exports.push(
2626                        EntityResolutionKey::new(
2627                            entity.key().clone(),
2628                            document_heading_resolution_key(
2629                                project,
2630                                &graph.path,
2631                                &symbol.signature,
2632                            )?,
2633                        )
2634                        .map_err(invalid_graph_contract)?,
2635                    );
2636                    entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
2637                }
2638                insert_entity(&mut entity_by_digest, entity)?;
2639            }
2640            symbol_digests.push(entity_digest);
2641        }
2642        let file = entity_by_digest
2643            .get(&file_digest)
2644            .ok_or_else(|| CliError::InvalidInput("graph file owner was not staged".to_string()))?;
2645        for key in resolution.source_keys() {
2646            entity_exports.push(
2647                EntityResolutionKey::new(file.key().clone(), key.clone())
2648                    .map_err(invalid_graph_contract)?,
2649            );
2650            entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
2651        }
2652        for symbol_keys in resolution.symbol_keys() {
2653            let Some(entity) = symbol_digests
2654                .get(symbol_keys.symbol_index())
2655                .and_then(Option::as_ref)
2656                .and_then(|digest| entity_by_digest.get(digest))
2657            else {
2658                continue;
2659            };
2660            for key in symbol_keys.keys() {
2661                entity_exports.push(
2662                    EntityResolutionKey::new(entity.key().clone(), key.clone())
2663                        .map_err(invalid_graph_contract)?,
2664                );
2665                entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
2666            }
2667        }
2668        entity_bytes = entity_bytes
2669            .saturating_add(STAGED_GRAPH_ROW_BYTES)
2670            .saturating_add(graph.path.len() as u64);
2671        #[cfg(test)]
2672        {
2673            peak_retained_bytes =
2674                peak_retained_bytes.max(admitted_map_bytes.saturating_add(entity_bytes));
2675        }
2676        enforce_resolution_registry_budget_with_limit(
2677            admitted_map_bytes.saturating_add(entity_bytes),
2678            staging_limit,
2679        )?;
2680        retained_bytes = retained_bytes.saturating_add(resolution_retained_bytes(&resolution));
2681        owners_by_graph.insert(
2682            graph.path.clone(),
2683            GraphOwners {
2684                file_digest,
2685                symbol_digests,
2686            },
2687        );
2688        keys_by_graph.insert(graph.path.clone(), resolution);
2689    }
2690    sort_dedup_exports(&mut entity_exports);
2691    enforce_key_binding_limit(entity_exports.len())?;
2692    retained_bytes = entity_by_digest
2693        .values()
2694        .fold(retained_bytes, |bytes, entity| {
2695            bytes.saturating_add(entity_retained_bytes(entity))
2696        })
2697        .saturating_add(
2698            STAGED_GRAPH_ROW_BYTES
2699                .saturating_mul(u64::try_from(entity_exports.len()).unwrap_or(u64::MAX)),
2700        );
2701    #[cfg(test)]
2702    {
2703        peak_retained_bytes = peak_retained_bytes.max(retained_bytes);
2704    }
2705    enforce_resolution_registry_budget_with_limit(retained_bytes, staging_limit)?;
2706    Ok(EntityProjection {
2707        entity_by_digest,
2708        owners_by_graph,
2709        keys_by_graph,
2710        entity_exports,
2711        retained_bytes,
2712        #[cfg(test)]
2713        peak_retained_bytes,
2714        #[cfg(test)]
2715        projection_removals_before_entities,
2716    })
2717}
2718
2719/// Acquire the one project-local graph-staging lease without waiting.
2720fn try_graph_stage_lease(staging_parent: &Path) -> Result<Option<File>, CliError> {
2721    let path = staging_parent.join(GRAPH_STAGE_LEASE_FILE_NAME);
2722    if let Ok(metadata) = fs::symlink_metadata(&path)
2723        && (!metadata.file_type().is_file() || metadata.file_type().is_symlink())
2724    {
2725        return Err(CliError::InvalidInput(format!(
2726            "repository graph staging lease is not a direct file: {}",
2727            normalize_native_path_display(&path)
2728        )));
2729    }
2730    let file = OpenOptions::new()
2731        .read(true)
2732        .write(true)
2733        .create(true)
2734        .truncate(false)
2735        .open(&path)
2736        .map_err(|source| CliError::Io {
2737            path: path.clone(),
2738            source,
2739        })?;
2740    match file.try_lock() {
2741        Ok(()) => Ok(Some(file)),
2742        Err(fs::TryLockError::WouldBlock) => Ok(None),
2743        Err(fs::TryLockError::Error(source)) => Err(CliError::Io { path, source }),
2744    }
2745}
2746
2747/// Remove inactive disposable graph stages owned by the selected project.
2748pub(super) fn cleanup_abandoned_repository_graph_staging(
2749    store: &AtlasStore,
2750    root: &Path,
2751    control: &IndexWorkControl,
2752) -> Result<(), CliError> {
2753    cleanup_abandoned_graph_staging(root, selected_project(store)?, control)
2754}
2755
2756/// Remove only validated, inactive disposable graph stages left by an earlier process.
2757fn cleanup_abandoned_graph_staging(
2758    root: &Path,
2759    project: ProjectInstanceId,
2760    control: &IndexWorkControl,
2761) -> Result<(), CliError> {
2762    control.check(IndexWorkStage::Publication)?;
2763    let staging_parent = root.join(".projectatlas");
2764    if !staging_parent.is_dir() {
2765        return Ok(());
2766    }
2767    let Some(_lease) = try_graph_stage_lease(&staging_parent)? else {
2768        return Ok(());
2769    };
2770    cleanup_abandoned_graph_staging_while_locked(&staging_parent, root, project, control)
2771}
2772
2773/// Remove stage payloads while retaining the validated ownership database as a crash marker.
2774fn remove_owned_graph_stage_payload(
2775    stage: &Path,
2776    database_path: &Path,
2777    control: Option<&IndexWorkControl>,
2778) -> Result<(), CliError> {
2779    let entries = fs::read_dir(stage).map_err(|source| CliError::Io {
2780        path: stage.to_path_buf(),
2781        source,
2782    })?;
2783    for entry in entries {
2784        if let Some(control) = control {
2785            control.check(IndexWorkStage::Publication)?;
2786        }
2787        let entry = entry.map_err(|source| CliError::Io {
2788            path: stage.to_path_buf(),
2789            source,
2790        })?;
2791        let path = entry.path();
2792        if path == database_path {
2793            continue;
2794        }
2795        let metadata = fs::symlink_metadata(&path).map_err(|source| CliError::Io {
2796            path: path.clone(),
2797            source,
2798        })?;
2799        let result = if metadata.file_type().is_symlink() {
2800            remove_graph_stage_symlink(&path)
2801        } else if metadata.file_type().is_dir() {
2802            fs::remove_dir_all(&path)
2803        } else {
2804            fs::remove_file(&path)
2805        };
2806        result.map_err(|source| CliError::Io { path, source })?;
2807    }
2808    Ok(())
2809}
2810
2811/// Remove only a graph-stage symlink leaf, never its target.
2812#[cfg(windows)]
2813fn remove_graph_stage_symlink(path: &Path) -> std::io::Result<()> {
2814    fs::remove_dir(path).or_else(|_directory_error| fs::remove_file(path))
2815}
2816
2817/// Remove only a graph-stage symlink leaf, never its target.
2818#[cfg(not(windows))]
2819fn remove_graph_stage_symlink(path: &Path) -> std::io::Result<()> {
2820    fs::remove_file(path)
2821}
2822
2823/// Remove direct child stages whose typed database binds the exact project.
2824fn cleanup_abandoned_graph_staging_while_locked(
2825    staging_parent: &Path,
2826    root: &Path,
2827    project: ProjectInstanceId,
2828    control: &IndexWorkControl,
2829) -> Result<(), CliError> {
2830    let entries = fs::read_dir(staging_parent).map_err(|source| CliError::Io {
2831        path: staging_parent.to_path_buf(),
2832        source,
2833    })?;
2834    for entry in entries {
2835        control.check(IndexWorkStage::Publication)?;
2836        let entry = entry.map_err(|source| CliError::Io {
2837            path: staging_parent.to_path_buf(),
2838            source,
2839        })?;
2840        let file_name = entry.file_name();
2841        let Some(file_name) = file_name.to_str() else {
2842            continue;
2843        };
2844        if !file_name.starts_with(GRAPH_STAGE_DIRECTORY_PREFIX)
2845            || file_name.len() == GRAPH_STAGE_DIRECTORY_PREFIX.len()
2846        {
2847            continue;
2848        }
2849        let path = entry.path();
2850        let Ok(metadata) = fs::symlink_metadata(&path) else {
2851            continue;
2852        };
2853        if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
2854            continue;
2855        }
2856        let database_path = path.join(GRAPH_STAGE_DATABASE_FILE_NAME);
2857        let database_metadata = match fs::symlink_metadata(&database_path) {
2858            Ok(metadata) => metadata,
2859            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
2860                let _remove_empty_shell = fs::remove_dir(&path);
2861                continue;
2862            }
2863            Err(_) => continue,
2864        };
2865        if !database_metadata.file_type().is_file() || database_metadata.file_type().is_symlink() {
2866            continue;
2867        }
2868        let owned = AtlasStore::repository_graph_staging_belongs_to(&database_path, root, project)
2869            .unwrap_or(false);
2870        if !owned {
2871            continue;
2872        }
2873        control.check(IndexWorkStage::Publication)?;
2874        remove_owned_graph_stage_payload(&path, &database_path, Some(control))?;
2875        control.check(IndexWorkStage::Publication)?;
2876        fs::remove_file(&database_path).map_err(|source| CliError::Io {
2877            path: database_path,
2878            source,
2879        })?;
2880        fs::remove_dir(&path).map_err(|source| CliError::Io { path, source })?;
2881    }
2882    Ok(())
2883}
2884
2885/// Spill a large full graph to typed disposable `SQLite` rows before main publication.
2886fn finish_projection_in_database_with_documents(
2887    root: &Path,
2888    nodes: &[Node],
2889    project: ProjectInstanceId,
2890    generation: IndexGeneration,
2891    graphs: &[impl Borrow<SymbolGraph>],
2892    document_facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
2893    identity_admission: &GraphIdentityAdmission,
2894    mut entities: EntityProjection,
2895    candidates: &ProjectResolutionRegistry,
2896    scan_policy: &RootScanPolicy,
2897    control: &IndexWorkControl,
2898) -> Result<StagedRepositoryGraph, CliError> {
2899    #[cfg(test)]
2900    let peak_retained_bytes = entities.peak_retained_bytes;
2901    #[cfg(test)]
2902    let projection_removals_before_entities = entities.projection_removals_before_entities.clone();
2903    let document_index = DocumentResolutionIndex::new(root, nodes, scan_policy)?;
2904    let staging_parent = root.join(".projectatlas");
2905    fs::create_dir_all(&staging_parent).map_err(|source| CliError::Io {
2906        path: staging_parent.clone(),
2907        source,
2908    })?;
2909    let lease = try_graph_stage_lease(&staging_parent)?.ok_or_else(|| {
2910        CliError::InvalidInput(
2911            "another repository graph staging operation is active for this project".to_string(),
2912        )
2913    })?;
2914    cleanup_abandoned_graph_staging_while_locked(&staging_parent, root, project, control)?;
2915    let directory = TempDirBuilder::new()
2916        .prefix(GRAPH_STAGE_DIRECTORY_PREFIX)
2917        .tempdir_in(&staging_parent)
2918        .map_err(|source| CliError::Io {
2919            path: staging_parent,
2920            source,
2921        })?;
2922    let mut database = StagedGraphDatabase {
2923        store: None,
2924        directory: Some(directory),
2925        _lease: lease,
2926    };
2927    let database_path = database
2928        .directory()?
2929        .path()
2930        .join(GRAPH_STAGE_DATABASE_FILE_NAME);
2931    database.store = Some(AtlasStore::create_repository_graph_staging(
2932        &database_path,
2933        root,
2934        project,
2935    )?);
2936    database.store_mut()?.replace_scan(nodes)?;
2937    let mut identity_rejections = identity_admission.rejections.clone();
2938    let mut identity_rejection_keys = identity_rejection_key_set(&identity_rejections);
2939    let mut identity_rejection_bytes =
2940        identity_rejection_keys_retained_bytes(&identity_rejection_keys)?
2941            .checked_add(identity_rejection_drop_paths_retained_bytes(
2942                &identity_admission.rejection_details_dropped_by_path,
2943            )?)
2944            .ok_or_else(|| {
2945                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
2946            })?;
2947    let mut rejection_details_dropped_by_path =
2948        identity_admission.rejection_details_dropped_by_path.clone();
2949    {
2950        let mut staging = database
2951            .store_mut()?
2952            .begin_repository_graph_staging(project, generation)?;
2953        let mut entity_batch = Vec::with_capacity(GRAPH_STAGE_ENTITY_BATCH_SIZE);
2954        for entity in entities.entity_by_digest.values() {
2955            entity_batch.push(entity);
2956            if entity_batch.len() == GRAPH_STAGE_ENTITY_BATCH_SIZE {
2957                control.check(IndexWorkStage::Publication)?;
2958                staging.append_entity_refs(&entity_batch)?;
2959                entity_batch.clear();
2960            }
2961        }
2962        if !entity_batch.is_empty() {
2963            staging.append_entity_refs(&entity_batch)?;
2964            entity_batch.clear();
2965        }
2966        staging.append_batch(&[], &[], &[], &[], &entities.entity_exports, &[])?;
2967        let mut staged_rows = ProjectedGraphRows::default();
2968        for graph in graphs {
2969            let graph = graph.borrow();
2970            let rows = project_graph_rows(
2971                project,
2972                generation,
2973                graph,
2974                document_facts.get(&graph.path).map(Cow::as_ref),
2975                &document_index,
2976                &mut entities,
2977                candidates,
2978                identity_admission,
2979                control,
2980            )?;
2981            staged_rows.append(rows);
2982            identity_rejection_bytes = identity_rejection_bytes
2983                .checked_add(extend_bounded_identity_rejections_with_drop_paths(
2984                    &mut identity_rejections,
2985                    &mut identity_rejection_keys,
2986                    staged_rows.identity_rejections.iter().cloned(),
2987                    &mut rejection_details_dropped_by_path,
2988                )?)
2989                .ok_or_else(|| {
2990                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
2991                })?;
2992            mark_identity_rejection_coverage_limits(
2993                &mut staged_rows.coverage,
2994                &rejection_details_dropped_by_path,
2995            )?;
2996            staged_rows.identity_rejections.clear();
2997            if staged_rows.row_count() < GRAPH_STAGE_ROW_BATCH_SIZE {
2998                continue;
2999            }
3000            staging.append_batch(
3001                &staged_rows.external_entities,
3002                &staged_rows.relations,
3003                &staged_rows.occurrences,
3004                &staged_rows.coverage,
3005                &[],
3006                &staged_rows.relation_dependencies,
3007            )?;
3008            if !staged_rows.document_unresolved_reasons.is_empty() {
3009                staging.set_document_unresolved_reasons_controlled(
3010                    &staged_rows.document_unresolved_reasons,
3011                    control,
3012                )?;
3013            }
3014            staged_rows.clear();
3015        }
3016        if !staged_rows.is_empty() {
3017            staging.append_batch(
3018                &staged_rows.external_entities,
3019                &staged_rows.relations,
3020                &staged_rows.occurrences,
3021                &staged_rows.coverage,
3022                &[],
3023                &staged_rows.relation_dependencies,
3024            )?;
3025            if !staged_rows.document_unresolved_reasons.is_empty() {
3026                staging.set_document_unresolved_reasons_controlled(
3027                    &staged_rows.document_unresolved_reasons,
3028                    control,
3029                )?;
3030            }
3031        }
3032        staging.complete()?;
3033    }
3034    database.store()?.checkpoint_repository_graph_staging()?;
3035    database.store()?.begin_index_read_snapshot()?;
3036    let _staged_generation = database.store()?.repository_graph_generation()?;
3037    let document_target_states = document_index.observed_absent_states();
3038    let retained_bytes = database_path.as_os_str().as_encoded_bytes().len() as u64
3039        + document_target_states
3040            .iter()
3041            .map(|(path, _reason)| path.len() as u64 + STAGED_GRAPH_ROW_BYTES)
3042            .sum::<u64>()
3043        + identity_rejection_bytes;
3044    Ok(StagedRepositoryGraph {
3045        project,
3046        mutation: RepositoryGraphMutation::Full,
3047        entities: Vec::new(),
3048        relations: Vec::new(),
3049        occurrences: Vec::new(),
3050        coverage: Vec::new(),
3051        entity_exports: Vec::new(),
3052        relation_dependencies: Vec::new(),
3053        document_unresolved_reasons: Vec::new(),
3054        identity_rejections,
3055        #[cfg(test)]
3056        resolution_derivations: identity_admission.resolution_derivations.clone(),
3057        #[cfg(test)]
3058        peak_retained_bytes,
3059        #[cfg(test)]
3060        projection_removals_before_entities,
3061        scan_policy: scan_policy.clone(),
3062        document_target_states,
3063        database: Some(database),
3064        retained_bytes,
3065    })
3066}
3067
3068/// Test-only compatibility wrapper for graph fixtures without Markdown facts.
3069#[cfg(test)]
3070fn finish_projection_in_database(
3071    root: &Path,
3072    nodes: &[Node],
3073    project: ProjectInstanceId,
3074    generation: IndexGeneration,
3075    graphs: &[impl Borrow<SymbolGraph>],
3076    entities: EntityProjection,
3077    candidates: &ProjectResolutionRegistry,
3078    scan_policy: &RootScanPolicy,
3079    control: &IndexWorkControl,
3080) -> Result<StagedRepositoryGraph, CliError> {
3081    finish_projection_in_database_with_documents(
3082        root,
3083        nodes,
3084        project,
3085        generation,
3086        graphs,
3087        &BTreeMap::new(),
3088        &GraphIdentityAdmission::default(),
3089        entities,
3090        candidates,
3091        scan_policy,
3092        control,
3093    )
3094}
3095
3096/// Resolve staged relationships and finish one complete normalized graph batch.
3097fn finish_projection_with_documents(
3098    project: ProjectInstanceId,
3099    generation: IndexGeneration,
3100    mutation: RepositoryGraphMutation,
3101    graphs: &[impl Borrow<SymbolGraph>],
3102    root: &Path,
3103    nodes: &[Node],
3104    document_facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
3105    identity_admission: &GraphIdentityAdmission,
3106    mut entities: EntityProjection,
3107    candidates: &ProjectResolutionRegistry,
3108    scan_policy: &RootScanPolicy,
3109    control: &IndexWorkControl,
3110) -> Result<StagedRepositoryGraph, CliError> {
3111    #[cfg(test)]
3112    let peak_retained_bytes = entities.peak_retained_bytes;
3113    #[cfg(test)]
3114    let projection_removals_before_entities = entities.projection_removals_before_entities.clone();
3115    let document_index = DocumentResolutionIndex::new(root, nodes, scan_policy)?;
3116    let mut relations_by_digest = BTreeMap::new();
3117    let mut occurrences = Vec::new();
3118    let mut relation_dependencies = Vec::new();
3119    let mut coverage = Vec::new();
3120    let mut document_unresolved_reasons = BTreeMap::new();
3121    let mut identity_rejections = identity_admission.rejections.clone();
3122    let mut identity_rejection_keys = identity_rejection_key_set(&identity_rejections);
3123    let mut identity_rejection_bytes =
3124        identity_rejection_keys_retained_bytes(&identity_rejection_keys)?
3125            .checked_add(identity_rejection_drop_paths_retained_bytes(
3126                &identity_admission.rejection_details_dropped_by_path,
3127            )?)
3128            .ok_or_else(|| {
3129                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
3130            })?;
3131    let mut rejection_details_dropped_by_path =
3132        identity_admission.rejection_details_dropped_by_path.clone();
3133    for graph in graphs {
3134        let graph = graph.borrow();
3135        let rows = project_graph_rows(
3136            project,
3137            generation,
3138            graph,
3139            document_facts.get(&graph.path).map(Cow::as_ref),
3140            &document_index,
3141            &mut entities,
3142            candidates,
3143            identity_admission,
3144            control,
3145        )?;
3146        for (relation_index, relation) in rows.relations.into_iter().enumerate() {
3147            insert_relation(
3148                &mut relations_by_digest,
3149                relation,
3150                &graph.path,
3151                "projected",
3152                relation_index,
3153            )?;
3154        }
3155        occurrences.extend(rows.occurrences);
3156        relation_dependencies.extend(rows.relation_dependencies);
3157        for (key, reason) in rows.document_unresolved_reasons {
3158            insert_document_unresolved_reason(&mut document_unresolved_reasons, key, reason)?;
3159        }
3160        coverage.extend(rows.coverage);
3161        identity_rejection_bytes = identity_rejection_bytes
3162            .checked_add(extend_bounded_identity_rejections_with_drop_paths(
3163                &mut identity_rejections,
3164                &mut identity_rejection_keys,
3165                rows.identity_rejections,
3166                &mut rejection_details_dropped_by_path,
3167            )?)
3168            .ok_or_else(|| {
3169                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
3170            })?;
3171        mark_identity_rejection_coverage_limits(&mut coverage, &rejection_details_dropped_by_path)?;
3172        entities.retained_bytes = rows
3173            .external_entities
3174            .iter()
3175            .fold(entities.retained_bytes, |bytes, entity| {
3176                bytes.saturating_add(entity_retained_bytes(entity))
3177            });
3178        for entity in rows.external_entities {
3179            insert_entity(&mut entities.entity_by_digest, entity)?;
3180        }
3181    }
3182    let mut relations = relations_by_digest.into_values().collect::<Vec<_>>();
3183    relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
3184    occurrences.sort_by(|left, right| {
3185        left.relation()
3186            .digest()
3187            .cmp(right.relation().digest())
3188            .then_with(|| left.file().as_str().cmp(right.file().as_str()))
3189            .then_with(|| left.span().start_line().cmp(&right.span().start_line()))
3190            .then_with(|| left.span().start_column().cmp(&right.span().start_column()))
3191    });
3192    occurrences.dedup();
3193    sort_dedup_dependencies(&mut relation_dependencies);
3194    enforce_key_binding_limit(relation_dependencies.len())?;
3195    let added_rows = relations
3196        .len()
3197        .saturating_add(occurrences.len())
3198        .saturating_add(coverage.len())
3199        .saturating_add(relation_dependencies.len());
3200    let added_rows = added_rows.saturating_add(document_unresolved_reasons.len());
3201    let document_target_states = document_index.observed_absent_states();
3202    entities.retained_bytes = entities
3203        .retained_bytes
3204        .saturating_add(
3205            STAGED_GRAPH_ROW_BYTES.saturating_mul(u64::try_from(added_rows).unwrap_or(u64::MAX)),
3206        )
3207        .saturating_add(
3208            document_target_states
3209                .iter()
3210                .fold(0_u64, |bytes, (path, _reason)| {
3211                    bytes
3212                        .saturating_add(STAGED_GRAPH_ROW_BYTES)
3213                        .saturating_add(path.len() as u64)
3214                }),
3215        );
3216    Ok(StagedRepositoryGraph {
3217        project,
3218        mutation,
3219        entities: entities.entity_by_digest.into_values().collect(),
3220        relations,
3221        occurrences,
3222        coverage,
3223        entity_exports: entities.entity_exports,
3224        relation_dependencies,
3225        document_unresolved_reasons: document_unresolved_reasons.into_values().collect(),
3226        identity_rejections,
3227        #[cfg(test)]
3228        resolution_derivations: identity_admission.resolution_derivations.clone(),
3229        #[cfg(test)]
3230        peak_retained_bytes,
3231        #[cfg(test)]
3232        projection_removals_before_entities,
3233        scan_policy: scan_policy.clone(),
3234        document_target_states,
3235        database: None,
3236        retained_bytes: entities
3237            .retained_bytes
3238            .saturating_add(identity_rejection_bytes),
3239    })
3240}
3241
3242/// Test-only compatibility wrapper for graph fixtures without Markdown facts.
3243#[cfg(test)]
3244fn finish_projection(
3245    project: ProjectInstanceId,
3246    generation: IndexGeneration,
3247    mutation: RepositoryGraphMutation,
3248    graphs: &[impl Borrow<SymbolGraph>],
3249    entities: EntityProjection,
3250    candidates: &ProjectResolutionRegistry,
3251    control: &IndexWorkControl,
3252) -> Result<StagedRepositoryGraph, CliError> {
3253    let scan_policy = RootScanPolicy::discover(Path::new("."), &ScanOptions::default(), control)
3254        .map_err(|source| CliError::InvalidInput(source.to_string()))?;
3255    finish_projection_with_documents(
3256        project,
3257        generation,
3258        mutation,
3259        graphs,
3260        Path::new("."),
3261        &[],
3262        &BTreeMap::new(),
3263        &GraphIdentityAdmission::default(),
3264        entities,
3265        candidates,
3266        &scan_policy,
3267        control,
3268    )
3269}
3270
3271/// One graph's normalized rows, bounded by the parser graph already in memory.
3272#[derive(Default)]
3273struct ProjectedGraphRows {
3274    /// Deduplicated logical relations owned by the graph.
3275    relations: Vec<LogicalRelation>,
3276    /// Exact supporting source occurrences.
3277    occurrences: Vec<RelationOccurrence>,
3278    /// Coverage rows for the graph.
3279    coverage: Vec<CoverageRecord>,
3280    /// Content-free external entities referenced by the graph.
3281    external_entities: Vec<GraphEntity>,
3282    /// Canonical dependency keys retained by the graph relations.
3283    relation_dependencies: Vec<RelationDependencyKey>,
3284    /// Closed reasons for unresolved canonical document relations.
3285    document_unresolved_reasons: Vec<(LogicalRelationKey, DocumentTargetUnresolvedReason)>,
3286    /// Derived relation identity rejections attached to this graph.
3287    identity_rejections: Vec<GraphIdentityRejection>,
3288}
3289
3290impl ProjectedGraphRows {
3291    /// Append one graph while retaining a single bounded staging batch.
3292    fn append(&mut self, rows: Self) {
3293        self.relations.extend(rows.relations);
3294        self.occurrences.extend(rows.occurrences);
3295        self.coverage.extend(rows.coverage);
3296        self.external_entities.extend(rows.external_entities);
3297        self.relation_dependencies
3298            .extend(rows.relation_dependencies);
3299        self.document_unresolved_reasons
3300            .extend(rows.document_unresolved_reasons);
3301        self.identity_rejections.extend(rows.identity_rejections);
3302    }
3303
3304    /// Return aggregate normalized rows retained by this staging batch.
3305    fn row_count(&self) -> usize {
3306        self.relations
3307            .len()
3308            .saturating_add(self.occurrences.len())
3309            .saturating_add(self.coverage.len())
3310            .saturating_add(self.external_entities.len())
3311            .saturating_add(self.relation_dependencies.len())
3312            .saturating_add(self.document_unresolved_reasons.len())
3313    }
3314
3315    /// Return whether the staging batch is empty.
3316    fn is_empty(&self) -> bool {
3317        self.row_count() == 0
3318    }
3319
3320    /// Release all successfully staged rows while preserving allocated capacity.
3321    fn clear(&mut self) {
3322        self.relations.clear();
3323        self.occurrences.clear();
3324        self.coverage.clear();
3325        self.external_entities.clear();
3326        self.relation_dependencies.clear();
3327        self.document_unresolved_reasons.clear();
3328        self.identity_rejections.clear();
3329    }
3330}
3331
3332/// Exact staged file inventory plus platform-aware case and root checks.
3333struct DocumentResolutionIndex<'a> {
3334    /// Selected source root used only for bounded filesystem checks.
3335    root: PathBuf,
3336    /// Canonical selected root used to reject symlink escape.
3337    canonical_root: PathBuf,
3338    /// Exact case-preserving staged identities.
3339    kinds_by_path: HashMap<&'a str, NodeKind>,
3340    /// Unicode-lowercase identity counts used to refuse guesses and collisions.
3341    casefold_path_counts: HashMap<String, u32>,
3342    /// Effective repository admission policy shared with full and incremental scans.
3343    scan_policy: &'a RootScanPolicy,
3344    /// Consulted non-indexed states, memoized for repeated high-fan-out references.
3345    absent_states: RefCell<BTreeMap<String, DocumentTargetUnresolvedReason>>,
3346}
3347
3348impl<'a> DocumentResolutionIndex<'a> {
3349    /// Build one bounded exact-identity view from the staged source inventory.
3350    fn new(
3351        root: &Path,
3352        nodes: &'a [Node],
3353        scan_policy: &'a RootScanPolicy,
3354    ) -> Result<Self, CliError> {
3355        let canonical_root = fs::canonicalize(root).map_err(|source| CliError::Io {
3356            path: root.to_path_buf(),
3357            source,
3358        })?;
3359        let kinds_by_path = nodes
3360            .iter()
3361            .map(|node| (node.path.as_str(), node.kind))
3362            .collect::<HashMap<_, _>>();
3363        let mut casefold_path_counts = HashMap::new();
3364        for node in nodes {
3365            let count = casefold_path_counts
3366                .entry(node.path.to_lowercase())
3367                .or_insert(0_u32);
3368            *count = count.saturating_add(1);
3369        }
3370        let retained_bytes = nodes.iter().fold(0_u64, |bytes, node| {
3371            bytes
3372                .saturating_add(STAGED_GRAPH_ROW_BYTES.saturating_mul(2))
3373                .saturating_add(node.path.len() as u64)
3374        });
3375        enforce_resolution_registry_budget(retained_bytes)?;
3376        Ok(Self {
3377            root: root.to_path_buf(),
3378            canonical_root,
3379            kinds_by_path,
3380            casefold_path_counts,
3381            scan_policy,
3382            absent_states: RefCell::new(BTreeMap::new()),
3383        })
3384    }
3385
3386    /// Return a closed unresolved reason, or `None` for one exact admitted file.
3387    fn unresolved_reason(
3388        &self,
3389        path: &str,
3390    ) -> Result<Option<DocumentTargetUnresolvedReason>, CliError> {
3391        let casefold_count = self
3392            .casefold_path_counts
3393            .get(&path.to_lowercase())
3394            .copied()
3395            .unwrap_or(0);
3396        if casefold_count > 1 {
3397            return Ok(Some(DocumentTargetUnresolvedReason::CaseConflict));
3398        }
3399        Ok(match self.kinds_by_path.get(path).copied() {
3400            Some(NodeKind::File) => None,
3401            Some(NodeKind::Folder) => Some(DocumentTargetUnresolvedReason::Unsupported),
3402            None if casefold_count == 1 => Some(DocumentTargetUnresolvedReason::CaseConflict),
3403            None => Some(self.absent_reason(path)?),
3404        })
3405    }
3406
3407    /// Distinguish absent source from excluded or escaping filesystem state.
3408    fn absent_reason(&self, path: &str) -> Result<DocumentTargetUnresolvedReason, CliError> {
3409        if let Some(reason) = self.absent_states.borrow().get(path).copied() {
3410            return Ok(reason);
3411        }
3412        let native = self.root.join(Path::new(path));
3413        let reason = if self
3414            .scan_policy
3415            .excludes_path(&native)
3416            .map_err(|source| CliError::InvalidInput(source.to_string()))?
3417        {
3418            DocumentTargetUnresolvedReason::Ignored
3419        } else {
3420            match fs::symlink_metadata(&native) {
3421                Ok(metadata) if metadata.file_type().is_dir() => {
3422                    DocumentTargetUnresolvedReason::Unsupported
3423                }
3424                Ok(_metadata) => match fs::canonicalize(&native) {
3425                    Ok(canonical) if !canonical.starts_with(&self.canonical_root) => {
3426                        DocumentTargetUnresolvedReason::OutsideRoot
3427                    }
3428                    Ok(_canonical) => DocumentTargetUnresolvedReason::Unsupported,
3429                    Err(_source) => DocumentTargetUnresolvedReason::Unsupported,
3430                },
3431                Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
3432                    self.missing_or_escaping_ancestor(&native)
3433                }
3434                Err(_source) => DocumentTargetUnresolvedReason::Unsupported,
3435            }
3436        };
3437        self.absent_states
3438            .borrow_mut()
3439            .insert(path.to_string(), reason);
3440        Ok(reason)
3441    }
3442
3443    /// Return the bounded states actually consulted during document resolution.
3444    fn observed_absent_states(&self) -> Vec<(String, DocumentTargetUnresolvedReason)> {
3445        self.absent_states
3446            .borrow()
3447            .iter()
3448            .map(|(path, reason)| (path.clone(), *reason))
3449            .collect()
3450    }
3451
3452    /// Check the nearest existing ancestor so a missing child cannot hide symlink escape.
3453    fn missing_or_escaping_ancestor(&self, native: &Path) -> DocumentTargetUnresolvedReason {
3454        let mut ancestor = native.parent();
3455        while let Some(path) = ancestor {
3456            if !path.starts_with(&self.root) {
3457                break;
3458            }
3459            match fs::canonicalize(path) {
3460                Ok(canonical) if !canonical.starts_with(&self.canonical_root) => {
3461                    return DocumentTargetUnresolvedReason::OutsideRoot;
3462                }
3463                Ok(_canonical) => return DocumentTargetUnresolvedReason::Missing,
3464                Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
3465                    ancestor = path.parent();
3466                }
3467                Err(_source) => return DocumentTargetUnresolvedReason::Unsupported,
3468            }
3469        }
3470        DocumentTargetUnresolvedReason::Missing
3471    }
3472}
3473
3474/// One resolved or typed-unresolved document candidate plus invalidation keys.
3475struct DocumentResolutionOutcome {
3476    /// Existing normalized graph resolution envelope.
3477    resolution: RelationResolution,
3478    /// Closed reason required only for unresolved document relations.
3479    unresolved_reason: Option<DocumentTargetUnresolvedReason>,
3480    /// Exact file and optional heading keys that invalidate this relation.
3481    dependencies: Vec<CanonicalResolutionKey>,
3482}
3483
3484/// Project one Markdown fact batch into canonical document relations and coverage.
3485#[allow(clippy::too_many_arguments)]
3486fn project_document_rows(
3487    project: ProjectInstanceId,
3488    generation: IndexGeneration,
3489    graph: &SymbolGraph,
3490    facts: &MarkdownFacts,
3491    owners: &GraphOwners,
3492    document_index: &DocumentResolutionIndex<'_>,
3493    candidates: &ProjectResolutionRegistry,
3494    staged_entities: &BTreeMap<String, GraphEntity>,
3495    control: &IndexWorkControl,
3496) -> Result<ProjectedGraphRows, CliError> {
3497    let file_source = staged_entities.get(&owners.file_digest).ok_or_else(|| {
3498        CliError::InvalidInput("document graph file owner was not staged".to_string())
3499    })?;
3500    let completeness = match facts.coverage.completeness {
3501        MarkdownFactCompleteness::Complete => Completeness::Complete,
3502        MarkdownFactCompleteness::Partial => Completeness::Partial,
3503    };
3504    let mut relations_by_digest = BTreeMap::new();
3505    let mut occurrences = Vec::new();
3506    let mut relation_dependencies = Vec::new();
3507    let mut unresolved_reasons = BTreeMap::new();
3508    for (candidate_index, candidate) in facts.link_candidates.iter().enumerate() {
3509        check_graph_work(control, candidate_index)?;
3510        if normalize_document_target(&graph.path, &candidate.selector)
3511            .is_ok_and(|target| target.path == graph.path && target.fragment.is_none())
3512        {
3513            continue;
3514        }
3515        let source = file_source;
3516        let outcome = resolve_document_candidate(
3517            project,
3518            &graph.path,
3519            &candidate.selector,
3520            document_index,
3521            candidates,
3522            staged_entities,
3523            control,
3524        )?;
3525        if matches!(
3526            &outcome.resolution,
3527            RelationResolution::Resolved { target, .. } if target == source.key()
3528        ) {
3529            continue;
3530        }
3531        let relation = LogicalRelation::new(
3532            source,
3533            GraphRelationKind::Extended(ExtendedRelationKind::Documents),
3534            outcome.resolution,
3535            ConfidenceClass::High,
3536            completeness,
3537            generation,
3538        )
3539        .map_err(invalid_graph_contract)?;
3540        insert_relation(
3541            &mut relations_by_digest,
3542            relation.clone(),
3543            &graph.path,
3544            "document",
3545            candidate_index,
3546        )?;
3547        if let Some(reason) = outcome.unresolved_reason {
3548            insert_document_unresolved_reason(
3549                &mut unresolved_reasons,
3550                relation.key().clone(),
3551                reason,
3552            )?;
3553        }
3554        let start_line = u32::try_from(candidate.source.line_start).map_err(|error| {
3555            CliError::InvalidInput(format!("document source line exceeds graph range: {error}"))
3556        })?;
3557        let end_line = u32::try_from(candidate.source.line_end).map_err(|error| {
3558            CliError::InvalidInput(format!("document end line exceeds graph range: {error}"))
3559        })?;
3560        let start_column = u32::try_from(candidate.source.column_start).map_err(|error| {
3561            CliError::InvalidInput(format!(
3562                "document source column exceeds graph range: {error}"
3563            ))
3564        })?;
3565        let end_column = u32::try_from(candidate.source.column_end).map_err(|error| {
3566            CliError::InvalidInput(format!("document end column exceeds graph range: {error}"))
3567        })?;
3568        occurrences.push(
3569            RelationOccurrence::new(
3570                &relation,
3571                RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
3572                SourceSpan::new(start_line, start_column, end_line, end_column)
3573                    .map_err(invalid_graph_contract)?,
3574                generation,
3575            )
3576            .map_err(invalid_graph_contract)?,
3577        );
3578        for key in outcome.dependencies {
3579            relation_dependencies.push(
3580                RelationDependencyKey::new(relation.key().clone(), key)
3581                    .map_err(invalid_graph_contract)?,
3582            );
3583        }
3584    }
3585    Ok(ProjectedGraphRows {
3586        relations: relations_by_digest.into_values().collect(),
3587        occurrences,
3588        coverage: vec![document_coverage(&graph.path, facts, generation)?],
3589        external_entities: Vec::new(),
3590        relation_dependencies,
3591        document_unresolved_reasons: unresolved_reasons.into_values().collect(),
3592        identity_rejections: Vec::new(),
3593    })
3594}
3595
3596/// Resolve one normalized document target through exact staged/persisted identities.
3597#[allow(clippy::too_many_arguments)]
3598fn resolve_document_candidate(
3599    project: ProjectInstanceId,
3600    document_path: &str,
3601    selector: &str,
3602    document_index: &DocumentResolutionIndex<'_>,
3603    candidates: &ProjectResolutionRegistry,
3604    staged_entities: &BTreeMap<String, GraphEntity>,
3605    control: &IndexWorkControl,
3606) -> Result<DocumentResolutionOutcome, CliError> {
3607    let target = match normalize_document_target(document_path, selector) {
3608        Ok(target) => target,
3609        Err(reason) => {
3610            return unresolved_document_outcome(selector, reason, Vec::new());
3611        }
3612    };
3613    let file_key = document_file_resolution_key(project, &target.path)?;
3614    let mut dependencies = vec![
3615        file_key.clone(),
3616        document_casefold_resolution_key(project, &target.path)?,
3617    ];
3618    let heading_key = target
3619        .fragment
3620        .as_deref()
3621        .map(|fragment| document_heading_resolution_key(project, &target.path, fragment))
3622        .transpose()?;
3623    if let Some(key) = heading_key.as_ref() {
3624        dependencies.push(key.clone());
3625    }
3626    if let Some(reason) = document_index.unresolved_reason(&target.path)? {
3627        return unresolved_document_outcome(selector, reason, dependencies);
3628    }
3629    if let Some(key) = heading_key.as_ref() {
3630        let matches = registry_resolution_matches(
3631            std::slice::from_ref(key),
3632            candidates,
3633            staged_entities,
3634            control,
3635        )?;
3636        match matches.count {
3637            0 => {
3638                return unresolved_document_outcome(
3639                    selector,
3640                    DocumentTargetUnresolvedReason::Missing,
3641                    dependencies,
3642                );
3643            }
3644            1 => {
3645                let target = matches.first.ok_or_else(|| {
3646                    CliError::InvalidInput("resolved document heading disappeared".to_string())
3647                })?;
3648                return Ok(DocumentResolutionOutcome {
3649                    resolution: RelationResolution::resolved(target)
3650                        .map_err(invalid_graph_contract)?,
3651                    unresolved_reason: None,
3652                    dependencies,
3653                });
3654            }
3655            _ => {
3656                return unresolved_document_outcome(
3657                    selector,
3658                    DocumentTargetUnresolvedReason::CaseConflict,
3659                    dependencies,
3660                );
3661            }
3662        }
3663    }
3664    let matches = registry_resolution_matches(
3665        std::slice::from_ref(&file_key),
3666        candidates,
3667        staged_entities,
3668        control,
3669    )?;
3670    match matches.count {
3671        1 => Ok(DocumentResolutionOutcome {
3672            resolution: RelationResolution::resolved(matches.first.ok_or_else(|| {
3673                CliError::InvalidInput("resolved document file disappeared".to_string())
3674            })?)
3675            .map_err(invalid_graph_contract)?,
3676            unresolved_reason: None,
3677            dependencies,
3678        }),
3679        0 => Err(CliError::InvalidInput(format!(
3680            "admitted document target lacked its exact graph identity: {}",
3681            target.path
3682        ))),
3683        _ => unresolved_document_outcome(
3684            selector,
3685            DocumentTargetUnresolvedReason::CaseConflict,
3686            dependencies,
3687        ),
3688    }
3689}
3690
3691/// Construct one privacy-safe unresolved document outcome.
3692fn unresolved_document_outcome(
3693    selector: &str,
3694    reason: DocumentTargetUnresolvedReason,
3695    dependencies: Vec<CanonicalResolutionKey>,
3696) -> Result<DocumentResolutionOutcome, CliError> {
3697    Ok(DocumentResolutionOutcome {
3698        resolution: RelationResolution::Unresolved {
3699            reference: GraphIdentityText::new(selector).map_err(invalid_graph_contract)?,
3700        },
3701        unresolved_reason: Some(reason),
3702        dependencies,
3703    })
3704}
3705
3706/// Project bounded Markdown fact coverage into the existing relation coverage table.
3707fn document_coverage(
3708    path: &str,
3709    facts: &MarkdownFacts,
3710    generation: IndexGeneration,
3711) -> Result<CoverageRecord, CliError> {
3712    let scope = CoverageScope::Path {
3713        path: RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract)?,
3714    };
3715    let covered = u64::try_from(facts.link_candidates.len()).unwrap_or(u64::MAX);
3716    let reached_limit = facts.coverage.limits.first().map(|limit| match limit {
3717        MarkdownFactLimit::HeadingCount | MarkdownFactLimit::CandidateCount => GraphLimitKind::Rows,
3718        MarkdownFactLimit::InputBytes
3719        | MarkdownFactLimit::LabelBytes
3720        | MarkdownFactLimit::SelectorBytes
3721        | MarkdownFactLimit::EvidenceBytes => GraphLimitKind::IntermediateBytes,
3722    });
3723    let relation = Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents));
3724    match facts.coverage.completeness {
3725        MarkdownFactCompleteness::Complete if covered == 0 => CoverageRecord::new(
3726            scope,
3727            relation,
3728            CoverageState::NoCandidates,
3729            0,
3730            0,
3731            generation,
3732            None,
3733            None,
3734        )
3735        .map_err(invalid_graph_contract),
3736        MarkdownFactCompleteness::Complete => CoverageRecord::new(
3737            scope,
3738            relation,
3739            CoverageState::Complete,
3740            covered,
3741            0,
3742            generation,
3743            None,
3744            None,
3745        )
3746        .map_err(invalid_graph_contract),
3747        MarkdownFactCompleteness::Partial if covered > 0 => CoverageRecord::new(
3748            scope,
3749            relation,
3750            CoverageState::Partial,
3751            covered,
3752            1,
3753            generation,
3754            Some(
3755                GraphIdentityText::new(DOCUMENT_PARTIAL_COVERAGE_REASON)
3756                    .map_err(invalid_graph_contract)?,
3757            ),
3758            reached_limit,
3759        )
3760        .map_err(invalid_graph_contract),
3761        MarkdownFactCompleteness::Partial => CoverageRecord::new(
3762            scope,
3763            relation,
3764            if facts
3765                .coverage
3766                .limits
3767                .contains(&MarkdownFactLimit::InputBytes)
3768            {
3769                CoverageState::Oversized
3770            } else {
3771                CoverageState::Failed
3772            },
3773            0,
3774            1,
3775            generation,
3776            Some(
3777                GraphIdentityText::new(DOCUMENT_PARTIAL_COVERAGE_REASON)
3778                    .map_err(invalid_graph_contract)?,
3779            ),
3780            reached_limit,
3781        )
3782        .map_err(invalid_graph_contract),
3783    }
3784}
3785
3786/// Deduplicate unresolved reasons while rejecting one logical-key contradiction.
3787fn insert_document_unresolved_reason(
3788    reasons: &mut BTreeMap<String, (LogicalRelationKey, DocumentTargetUnresolvedReason)>,
3789    key: LogicalRelationKey,
3790    reason: DocumentTargetUnresolvedReason,
3791) -> Result<(), CliError> {
3792    let digest = key.digest().to_string();
3793    match reasons.entry(digest) {
3794        Entry::Vacant(entry) => {
3795            entry.insert((key, reason));
3796            Ok(())
3797        }
3798        Entry::Occupied(entry) if entry.get() == &(key, reason) => Ok(()),
3799        Entry::Occupied(_entry) => Err(CliError::InvalidInput(
3800            "document relation retained conflicting unresolved reasons".to_string(),
3801        )),
3802    }
3803}
3804
3805/// Resolve one parser graph and release its temporary owner/key workspace.
3806fn project_graph_rows(
3807    project: ProjectInstanceId,
3808    generation: IndexGeneration,
3809    graph: &SymbolGraph,
3810    document_facts: Option<&MarkdownFacts>,
3811    document_index: &DocumentResolutionIndex<'_>,
3812    entities: &mut EntityProjection,
3813    candidates: &ProjectResolutionRegistry,
3814    identity_admission: &GraphIdentityAdmission,
3815    control: &IndexWorkControl,
3816) -> Result<ProjectedGraphRows, CliError> {
3817    control.check(IndexWorkStage::SymbolParsing)?;
3818    let owners = entities
3819        .owners_by_graph
3820        .remove(&graph.path)
3821        .ok_or_else(|| CliError::InvalidInput("graph owners were not staged".to_string()))?;
3822    let resolution_keys = entities
3823        .keys_by_graph
3824        .remove(&graph.path)
3825        .ok_or_else(|| CliError::InvalidInput("graph keys were not staged".to_string()))?;
3826    let symbol_index = GraphSymbolIndex::new(graph, control)?;
3827    let keys_by_relation = resolution_keys
3828        .relation_keys()
3829        .iter()
3830        .map(|entry| (entry.relation_index(), entry.keys()))
3831        .collect::<BTreeMap<_, _>>();
3832    let mut relations_by_digest = BTreeMap::new();
3833    let mut occurrences = Vec::new();
3834    let mut relation_dependencies = Vec::new();
3835    let mut coverage = Vec::new();
3836    let mut external_entities = BTreeMap::new();
3837    let mut document_unresolved_reasons = BTreeMap::new();
3838    for (relation_index, source_relation) in graph.relations.iter().enumerate() {
3839        control.check(IndexWorkStage::SymbolParsing)?;
3840        let source = relation_source(
3841            &owners,
3842            &entities.entity_by_digest,
3843            graph,
3844            &symbol_index,
3845            source_relation,
3846            control,
3847        )?;
3848        let dependency_keys = keys_by_relation
3849            .get(&relation_index)
3850            .copied()
3851            .unwrap_or(&[]);
3852        let resolution = relation_resolution(
3853            project,
3854            generation,
3855            source_relation,
3856            Some(relation_index),
3857            &owners,
3858            graph,
3859            &symbol_index,
3860            dependency_keys,
3861            candidates,
3862            &entities.entity_by_digest,
3863            &mut external_entities,
3864            control,
3865        )?;
3866        let relation = LogicalRelation::new(
3867            source,
3868            GraphRelationKind::from_legacy(source_relation.kind),
3869            resolution,
3870            relation_confidence(source_relation.parser),
3871            relation_completeness(source_relation.parser),
3872            generation,
3873        )
3874        .map_err(invalid_graph_contract)?;
3875        insert_relation(
3876            &mut relations_by_digest,
3877            relation.clone(),
3878            &graph.path,
3879            "logical",
3880            relation_index,
3881        )?;
3882        let line = u32::try_from(source_relation.line).map_err(|error| {
3883            CliError::InvalidInput(format!("relation source line exceeds graph range: {error}"))
3884        })?;
3885        let end_column =
3886            u32::try_from(source_relation.context.chars().count()).map_err(|error| {
3887                CliError::InvalidInput(format!(
3888                    "relation source context exceeds graph range: {error}"
3889                ))
3890            })?;
3891        occurrences.push(
3892            RelationOccurrence::new(
3893                &relation,
3894                RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
3895                SourceSpan::new(line.max(1), 0, line.max(1), end_column)
3896                    .map_err(invalid_graph_contract)?,
3897                generation,
3898            )
3899            .map_err(invalid_graph_contract)?,
3900        );
3901        for key in dependency_keys {
3902            relation_dependencies.push(
3903                RelationDependencyKey::new(relation.key().clone(), key.clone())
3904                    .map_err(invalid_graph_contract)?,
3905            );
3906        }
3907    }
3908    let mut derived_identity_admission = GraphIdentityAdmission::default();
3909    let mut admitted_derived_facts = Vec::new();
3910    for (derived_index, fact) in derived_relation_facts(graph, &keys_by_relation)
3911        .into_iter()
3912        .enumerate()
3913    {
3914        let mut failures = Vec::new();
3915        record_identity_failure(
3916            &mut failures,
3917            GraphIdentityField::RelationSource,
3918            &fact.relation.source_name,
3919        );
3920        record_identity_failure(
3921            &mut failures,
3922            GraphIdentityField::RelationTarget,
3923            &fact.relation.target_name,
3924        );
3925        if failures.is_empty() {
3926            admitted_derived_facts.push(fact);
3927        } else {
3928            derived_identity_admission.record(
3929                &graph.path,
3930                IdentitySpan {
3931                    start_line: fact.relation.line.max(1),
3932                    start_column: 0,
3933                    end_line: fact.relation.line.max(1),
3934                    end_column: 0,
3935                },
3936                fact.relation.parser,
3937                parser_fact_index(DERIVED_RELATION_FACT_INDEX_NAMESPACE, derived_index),
3938                &failures,
3939                control,
3940            )?;
3941        }
3942    }
3943    for (fact_index, fact) in admitted_derived_facts.into_iter().enumerate() {
3944        control.check(IndexWorkStage::SymbolParsing)?;
3945        let source = relation_source(
3946            &owners,
3947            &entities.entity_by_digest,
3948            graph,
3949            &symbol_index,
3950            &fact.relation,
3951            control,
3952        )?;
3953        let resolution = derived_relation_resolution(
3954            project,
3955            generation,
3956            &fact,
3957            &owners,
3958            graph,
3959            &symbol_index,
3960            candidates,
3961            &entities.entity_by_digest,
3962            &mut external_entities,
3963            control,
3964        )?;
3965        let relation = LogicalRelation::new(
3966            source,
3967            GraphRelationKind::Extended(fact.kind),
3968            resolution,
3969            relation_confidence(fact.relation.parser),
3970            relation_completeness(fact.relation.parser),
3971            generation,
3972        )
3973        .map_err(invalid_graph_contract)?;
3974        insert_relation(
3975            &mut relations_by_digest,
3976            relation.clone(),
3977            &graph.path,
3978            "derived",
3979            fact_index,
3980        )?;
3981        let line = u32::try_from(fact.relation.line).map_err(|error| {
3982            CliError::InvalidInput(format!(
3983                "derived relation source line exceeds graph range: {error}"
3984            ))
3985        })?;
3986        let end_column = u32::try_from(fact.relation.context.chars().count()).map_err(|error| {
3987            CliError::InvalidInput(format!(
3988                "derived relation source context exceeds graph range: {error}"
3989            ))
3990        })?;
3991        occurrences.push(
3992            RelationOccurrence::new(
3993                &relation,
3994                RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
3995                SourceSpan::new(line.max(1), 0, line.max(1), end_column)
3996                    .map_err(invalid_graph_contract)?,
3997                generation,
3998            )
3999            .map_err(invalid_graph_contract)?,
4000        );
4001        if let DerivedRelationTarget::Parser { keys } = fact.target {
4002            for key in keys {
4003                relation_dependencies.push(
4004                    RelationDependencyKey::new(relation.key().clone(), key)
4005                        .map_err(invalid_graph_contract)?,
4006                );
4007            }
4008        }
4009    }
4010    if let Some(facts) = document_facts {
4011        let rows = project_document_rows(
4012            project,
4013            generation,
4014            graph,
4015            facts,
4016            &owners,
4017            document_index,
4018            candidates,
4019            &entities.entity_by_digest,
4020            control,
4021        )?;
4022        for (relation_index, relation) in rows.relations.into_iter().enumerate() {
4023            insert_relation(
4024                &mut relations_by_digest,
4025                relation,
4026                &graph.path,
4027                "document",
4028                relation_index,
4029            )?;
4030        }
4031        occurrences.extend(rows.occurrences);
4032        coverage.extend(rows.coverage);
4033        relation_dependencies.extend(rows.relation_dependencies);
4034        for (key, reason) in rows.document_unresolved_reasons {
4035            insert_document_unresolved_reason(&mut document_unresolved_reasons, key, reason)?;
4036        }
4037    }
4038    let mut relations = relations_by_digest.into_values().collect::<Vec<_>>();
4039    relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
4040    occurrences.sort_by(|left, right| {
4041        left.relation()
4042            .digest()
4043            .cmp(right.relation().digest())
4044            .then_with(|| left.file().as_str().cmp(right.file().as_str()))
4045            .then_with(|| left.span().start_line().cmp(&right.span().start_line()))
4046            .then_with(|| left.span().start_column().cmp(&right.span().start_column()))
4047    });
4048    occurrences.dedup();
4049    sort_dedup_dependencies(&mut relation_dependencies);
4050    enforce_key_binding_limit(relation_dependencies.len())?;
4051    entities.retained_bytes = entities
4052        .retained_bytes
4053        .saturating_sub(resolution_retained_bytes(&resolution_keys));
4054    let mut graph_coverage = vec![coverage_for_graph(
4055        graph,
4056        generation,
4057        identity_admission,
4058        &derived_identity_admission,
4059    )?];
4060    graph_coverage.extend(coverage);
4061    Ok(ProjectedGraphRows {
4062        relations,
4063        occurrences,
4064        coverage: graph_coverage,
4065        external_entities: external_entities.into_values().collect(),
4066        relation_dependencies,
4067        document_unresolved_reasons: document_unresolved_reasons.into_values().collect(),
4068        identity_rejections: derived_identity_admission.rejections,
4069    })
4070}
4071
4072/// Deduplicate relation occurrences while conservatively retaining ambiguity.
4073fn insert_relation(
4074    relations: &mut BTreeMap<String, LogicalRelation>,
4075    relation: LogicalRelation,
4076    graph_path: &str,
4077    relation_kind: &str,
4078    relation_index: usize,
4079) -> Result<(), CliError> {
4080    let digest = relation.key().digest().to_string();
4081    match relations.entry(digest) {
4082        Entry::Vacant(entry) => {
4083            entry.insert(relation);
4084            Ok(())
4085        }
4086        Entry::Occupied(mut entry) => {
4087            let existing = entry.get();
4088            if existing == &relation {
4089                return Ok(());
4090            }
4091            let mergeable = existing.key() == relation.key()
4092                && existing.source() == relation.source()
4093                && existing.kind() == relation.kind()
4094                && existing.confidence() == relation.confidence()
4095                && existing.completeness() == relation.completeness()
4096                && existing.generation() == relation.generation();
4097            if mergeable
4098                && let (
4099                    RelationResolution::Ambiguous {
4100                        reference: existing_reference,
4101                        candidates: existing_candidates,
4102                    },
4103                    RelationResolution::Ambiguous {
4104                        reference: incoming_reference,
4105                        candidates: incoming_candidates,
4106                    },
4107                ) = (existing.resolution(), relation.resolution())
4108                && existing_reference == incoming_reference
4109            {
4110                if incoming_candidates > existing_candidates {
4111                    entry.insert(relation);
4112                }
4113                return Ok(());
4114            }
4115            Err(CliError::InvalidInput(format!(
4116                "{relation_kind} relation digest retained conflicting facts for {graph_path} \
4117                 relation {relation_index}: existing={existing:?}, incoming={relation:?}"
4118            )))
4119        }
4120    }
4121}
4122
4123/// Project-wide stable entities and their canonical resolution-key bindings.
4124#[derive(Default)]
4125struct ProjectResolutionRegistry {
4126    /// Persisted candidates not already owned by the staged entity projection.
4127    supplemental_entities_by_digest: BTreeMap<String, GraphEntity>,
4128    /// Canonical keys mapped only to sorted, deduplicated entity digests.
4129    candidate_digests_by_key: BTreeMap<CanonicalResolutionKey, BTreeSet<String>>,
4130    /// Conservative peak bytes retained by this temporary resolution registry.
4131    retained_bytes: u64,
4132}
4133
4134impl ProjectResolutionRegistry {
4135    /// Insert one key-to-entity binding without cloning the entity per exported key.
4136    fn insert_candidate(
4137        &mut self,
4138        key: &CanonicalResolutionKey,
4139        entity: &GraphEntity,
4140    ) -> Result<(), CliError> {
4141        let digest = entity.key().digest().to_string();
4142        match self.supplemental_entities_by_digest.entry(digest.clone()) {
4143            Entry::Occupied(entry) if entry.get() != entity => {
4144                return Err(CliError::InvalidInput(
4145                    "graph entity digest retained conflicting selectors".to_string(),
4146                ));
4147            }
4148            Entry::Occupied(_entry) => {}
4149            Entry::Vacant(entry) => {
4150                self.retained_bytes = self
4151                    .retained_bytes
4152                    .saturating_add(entity_retained_bytes(entity))
4153                    .saturating_add(digest.len() as u64);
4154                entry.insert(entity.clone());
4155            }
4156        }
4157        self.insert_candidate_binding(key, digest)
4158    }
4159
4160    /// Bind an entity already owned by the staged projection without cloning it.
4161    fn insert_staged_candidate(
4162        &mut self,
4163        key: &CanonicalResolutionKey,
4164        entity: &GraphEntity,
4165    ) -> Result<(), CliError> {
4166        self.insert_candidate_binding(key, entity.key().digest().to_string())
4167    }
4168
4169    /// Insert one canonical-key binding for an entity owned by either registry.
4170    fn insert_candidate_binding(
4171        &mut self,
4172        key: &CanonicalResolutionKey,
4173        digest: String,
4174    ) -> Result<(), CliError> {
4175        if let Some(existing) = self.candidate_digests_by_key.get_mut(key) {
4176            if existing.insert(digest.clone()) {
4177                self.retained_bytes = self
4178                    .retained_bytes
4179                    .saturating_add(STAGED_GRAPH_ROW_BYTES)
4180                    .saturating_add(digest.len() as u64);
4181            }
4182        } else {
4183            self.retained_bytes = self
4184                .retained_bytes
4185                .saturating_add(STAGED_GRAPH_ROW_BYTES)
4186                .saturating_add(key.canonical_identity().len() as u64)
4187                .saturating_add(STAGED_GRAPH_ROW_BYTES)
4188                .saturating_add(digest.len() as u64);
4189            self.candidate_digests_by_key
4190                .insert(key.clone(), BTreeSet::from([digest]));
4191        }
4192        enforce_resolution_registry_budget(self.retained_bytes)?;
4193        Ok(())
4194    }
4195}
4196
4197/// Reject a temporary resolution registry before it can exceed publication memory.
4198fn enforce_resolution_registry_budget(retained_bytes: u64) -> Result<(), CliError> {
4199    enforce_resolution_registry_budget_with_limit(
4200        retained_bytes,
4201        super::MAX_PUBLICATION_STAGING_BYTES,
4202    )
4203}
4204
4205/// Apply the resolution staging bound with an explicit limit for deterministic tests.
4206fn enforce_resolution_registry_budget_with_limit(
4207    retained_bytes: u64,
4208    limit: u64,
4209) -> Result<(), CliError> {
4210    if retained_bytes > limit {
4211        return Err(IndexWorkFailure::resource_limit(
4212            IndexWorkStage::SymbolParsing,
4213            IndexWorkResource::OutputBytes,
4214            limit,
4215            retained_bytes,
4216        )
4217        .into());
4218    }
4219    Ok(())
4220}
4221
4222/// Reject the simultaneous entity projection and lookup registry peak.
4223fn enforce_resolution_staging_budget(
4224    projection: &EntityProjection,
4225    registry: &ProjectResolutionRegistry,
4226) -> Result<(), CliError> {
4227    enforce_resolution_registry_budget(
4228        projection
4229            .retained_bytes
4230            .saturating_add(registry.retained_bytes),
4231    )
4232}
4233
4234/// Count conservative fixed and variable bytes retained by one graph entity.
4235fn entity_retained_bytes(entity: &GraphEntity) -> u64 {
4236    let selector_bytes = match entity.selector() {
4237        EntitySelector::Project => 0,
4238        EntitySelector::Folder { path } => path.as_str().len() as u64,
4239        EntitySelector::File { path } => path.as_str().len() as u64,
4240        EntitySelector::Package { package } => {
4241            (package.manager.as_str().len()
4242                + package.name.as_str().len()
4243                + package.manifest.as_str().len()) as u64
4244        }
4245        EntitySelector::Symbol { symbol } => {
4246            (symbol.file.as_str().len()
4247                + symbol.name.as_str().len()
4248                + symbol
4249                    .parent
4250                    .as_ref()
4251                    .map_or(0, |parent| parent.as_str().len())
4252                + symbol.signature.as_str().len()) as u64
4253        }
4254        EntitySelector::External { external } => {
4255            (external.system.as_str().len() + external.identity.as_str().len()) as u64
4256        }
4257    };
4258    STAGED_GRAPH_ROW_BYTES
4259        .saturating_add(entity.key().digest().len() as u64)
4260        .saturating_add(entity.key().canonical_identity().len() as u64)
4261        .saturating_add(selector_bytes)
4262}
4263
4264/// Build current resolution candidates from newly staged entity exports.
4265fn resolution_registry_from_exports(
4266    projection: &EntityProjection,
4267    control: &IndexWorkControl,
4268) -> Result<ProjectResolutionRegistry, CliError> {
4269    let mut candidates = ProjectResolutionRegistry::default();
4270    for (index, binding) in projection.entity_exports.iter().enumerate() {
4271        check_graph_work(control, index)?;
4272        let digest = binding.entity().digest().to_string();
4273        let entity = projection.entity_by_digest.get(&digest).ok_or_else(|| {
4274            CliError::InvalidInput("resolution export owner was not staged".to_string())
4275        })?;
4276        candidates.insert_staged_candidate(binding.key(), entity)?;
4277    }
4278    Ok(candidates)
4279}
4280
4281/// Rebind unaffected persisted candidates to the pending graph generation.
4282fn resolution_registry_from_persisted(
4283    project: ProjectInstanceId,
4284    generation: IndexGeneration,
4285    candidates: Vec<RepositoryResolutionCandidate>,
4286    replaced_paths: &BTreeSet<String>,
4287    control: &IndexWorkControl,
4288) -> Result<ProjectResolutionRegistry, CliError> {
4289    let mut by_key = ProjectResolutionRegistry::default();
4290    for (index, candidate) in candidates.into_iter().enumerate() {
4291        check_graph_work(control, index)?;
4292        if entity_owner_path(candidate.entity()).is_some_and(|path| replaced_paths.contains(path)) {
4293            continue;
4294        }
4295        let entity = GraphEntity::new(project, candidate.entity().selector().clone(), generation)
4296            .map_err(invalid_graph_contract)?;
4297        by_key.insert_candidate(candidate.key(), &entity)?;
4298    }
4299    Ok(by_key)
4300}
4301
4302/// Merge normalized candidate registries while retaining one entity per digest.
4303fn merge_resolution_registries(
4304    target: &mut ProjectResolutionRegistry,
4305    source: ProjectResolutionRegistry,
4306    control: &IndexWorkControl,
4307) -> Result<(), CliError> {
4308    let ProjectResolutionRegistry {
4309        supplemental_entities_by_digest,
4310        candidate_digests_by_key,
4311        retained_bytes: _retained_bytes,
4312    } = source;
4313    for entity in supplemental_entities_by_digest.into_values() {
4314        check_graph_work(control, target.supplemental_entities_by_digest.len())?;
4315        let digest = entity.key().digest().to_string();
4316        match target.supplemental_entities_by_digest.entry(digest.clone()) {
4317            Entry::Occupied(entry) if entry.get() != &entity => {
4318                return Err(CliError::InvalidInput(
4319                    "resolution candidate entity retained conflicting selectors".to_string(),
4320                ));
4321            }
4322            Entry::Occupied(_entry) => {}
4323            Entry::Vacant(entry) => {
4324                target.retained_bytes = target
4325                    .retained_bytes
4326                    .saturating_add(entity_retained_bytes(&entity))
4327                    .saturating_add(digest.len() as u64);
4328                entry.insert(entity);
4329            }
4330        }
4331    }
4332    let mut bindings = 0_usize;
4333    for (key, candidates) in candidate_digests_by_key {
4334        for digest in candidates {
4335            check_graph_work(control, bindings)?;
4336            target.insert_candidate_binding(&key, digest)?;
4337            bindings = bindings.saturating_add(1);
4338        }
4339    }
4340    Ok(())
4341}
4342
4343/// Observe cancellation and deadline state at bounded graph-map intervals.
4344fn check_graph_work(control: &IndexWorkControl, index: usize) -> Result<(), CliError> {
4345    if index.is_multiple_of(GRAPH_WORK_CHECK_INTERVAL) {
4346        control.check(IndexWorkStage::SymbolParsing)?;
4347    }
4348    Ok(())
4349}
4350
4351/// Return the source-owner path for entities eligible to export graph keys.
4352fn entity_owner_path(entity: &GraphEntity) -> Option<&str> {
4353    match entity.selector() {
4354        EntitySelector::File { path } => Some(path.as_str()),
4355        EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
4356        EntitySelector::Package { package } => Some(package.manifest.as_str()),
4357        EntitySelector::Project
4358        | EntitySelector::Folder { .. }
4359        | EntitySelector::External { .. } => None,
4360    }
4361}
4362
4363/// Derive additive families only from exact bounded parser facts and path policy.
4364fn derived_relation_facts(
4365    graph: &SymbolGraph,
4366    keys_by_relation: &BTreeMap<usize, &[CanonicalResolutionKey]>,
4367) -> Vec<DerivedRelationFact> {
4368    let mut facts = Vec::new();
4369    let test_path = is_test_path(&graph.path);
4370    for (index, relation) in graph.relations.iter().enumerate() {
4371        if test_path && matches!(relation.kind, RelationKind::Imports | RelationKind::Calls) {
4372            let keys = keys_by_relation
4373                .get(&index)
4374                .copied()
4375                .unwrap_or_default()
4376                .to_vec();
4377            push_derived_relation(
4378                &mut facts,
4379                ExtendedRelationKind::Tests,
4380                relation.clone(),
4381                DerivedRelationTarget::Parser { keys },
4382            );
4383        }
4384        if relation.kind == RelationKind::Calls {
4385            if let Some(handler) = static_route_handler(relation) {
4386                push_derived_relation(
4387                    &mut facts,
4388                    ExtendedRelationKind::RoutesTo,
4389                    derived_parser_relation(relation, handler),
4390                    DerivedRelationTarget::Parser { keys: Vec::new() },
4391                );
4392            }
4393            if let Some(key) = static_environment_key(relation) {
4394                push_derived_relation(
4395                    &mut facts,
4396                    ExtendedRelationKind::Configures,
4397                    derived_parser_relation(relation, key),
4398                    DerivedRelationTarget::External {
4399                        system: ENVIRONMENT_SYSTEM,
4400                    },
4401                );
4402            }
4403            if let Some(kind) = static_data_access_kind(&relation.target_name)
4404                && let Some(path) = static_string_argument(&relation.context)
4405                    .and_then(normalize_static_repository_path)
4406            {
4407                push_derived_relation(
4408                    &mut facts,
4409                    kind,
4410                    derived_parser_relation(relation, path),
4411                    DerivedRelationTarget::RepositoryPath,
4412                );
4413            }
4414        }
4415    }
4416    if let Some(identity) = configuration_file_identity(&graph.path) {
4417        push_derived_relation(
4418            &mut facts,
4419            ExtendedRelationKind::Configures,
4420            file_owned_relation(graph, identity),
4421            DerivedRelationTarget::External {
4422                system: CONFIGURATION_SYSTEM,
4423            },
4424        );
4425    }
4426    if let Some(identity) = deployment_platform_identity(&graph.path) {
4427        push_derived_relation(
4428            &mut facts,
4429            ExtendedRelationKind::Deploys,
4430            file_owned_relation(graph, identity),
4431            DerivedRelationTarget::External {
4432                system: DEPLOYMENT_SYSTEM,
4433            },
4434        );
4435    }
4436    facts
4437}
4438
4439/// Retain one additive fact from the already bounded parser graph.
4440fn push_derived_relation(
4441    facts: &mut Vec<DerivedRelationFact>,
4442    kind: ExtendedRelationKind,
4443    relation: SymbolRelation,
4444    target: DerivedRelationTarget,
4445) {
4446    facts.push(DerivedRelationFact {
4447        kind,
4448        relation,
4449        target,
4450    });
4451}
4452
4453/// Copy source context while replacing only the statically selected target.
4454fn derived_parser_relation(relation: &SymbolRelation, target_name: String) -> SymbolRelation {
4455    SymbolRelation {
4456        path: relation.path.clone(),
4457        source_name: relation.source_name.clone(),
4458        target_name,
4459        kind: RelationKind::Calls,
4460        line: relation.line,
4461        context: relation.context.clone(),
4462        parser: relation.parser,
4463    }
4464}
4465
4466/// Create one file-owned content-free configuration or deployment fact.
4467fn file_owned_relation(graph: &SymbolGraph, target_name: String) -> SymbolRelation {
4468    SymbolRelation {
4469        path: graph.path.clone(),
4470        source_name: MODULE_RELATION_SOURCE.to_string(),
4471        target_name,
4472        kind: RelationKind::Calls,
4473        line: 1,
4474        context: graph.path.clone(),
4475        parser: graph.parser,
4476    }
4477}
4478
4479/// Resolve one additive relation through its closed target class.
4480fn derived_relation_resolution<'a>(
4481    project: ProjectInstanceId,
4482    generation: IndexGeneration,
4483    fact: &DerivedRelationFact,
4484    owners: &GraphOwners,
4485    graph: &SymbolGraph,
4486    symbol_index: &GraphSymbolIndex<'_>,
4487    candidates: &'a ProjectResolutionRegistry,
4488    entities: &'a BTreeMap<String, GraphEntity>,
4489    external_entities: &mut BTreeMap<String, GraphEntity>,
4490    control: &IndexWorkControl,
4491) -> Result<RelationResolution, CliError> {
4492    match &fact.target {
4493        DerivedRelationTarget::Parser { keys } => relation_resolution(
4494            project,
4495            generation,
4496            &fact.relation,
4497            None,
4498            owners,
4499            graph,
4500            symbol_index,
4501            keys,
4502            candidates,
4503            entities,
4504            external_entities,
4505            control,
4506        ),
4507        DerivedRelationTarget::RepositoryPath => {
4508            let candidate = GraphEntity::new(
4509                project,
4510                EntitySelector::File {
4511                    path: RepositoryFilePath::new(Path::new(&fact.relation.target_name))
4512                        .map_err(invalid_graph_contract)?,
4513                },
4514                generation,
4515            )
4516            .map_err(invalid_graph_contract)?;
4517            match entities.get(candidate.key().digest()) {
4518                Some(entity) if entity == &candidate => {
4519                    RelationResolution::resolved(entity).map_err(invalid_graph_contract)
4520                }
4521                Some(_conflict) => Err(CliError::InvalidInput(
4522                    "static repository-path target collided with another graph entity".to_string(),
4523                )),
4524                None => Ok(RelationResolution::Unresolved {
4525                    reference: GraphIdentityText::new(fact.relation.target_name.clone())
4526                        .map_err(invalid_graph_contract)?,
4527                }),
4528            }
4529        }
4530        DerivedRelationTarget::External { system } => {
4531            let entity = GraphEntity::new(
4532                project,
4533                EntitySelector::External {
4534                    external: ExternalSelector {
4535                        system: GraphIdentityText::new(*system).map_err(invalid_graph_contract)?,
4536                        identity: GraphIdentityText::new(fact.relation.target_name.clone())
4537                            .map_err(invalid_graph_contract)?,
4538                    },
4539                },
4540                generation,
4541            )
4542            .map_err(invalid_graph_contract)?;
4543            let resolution =
4544                RelationResolution::external(&entity).map_err(invalid_graph_contract)?;
4545            insert_entity(external_entities, entity)?;
4546            Ok(resolution)
4547        }
4548    }
4549}
4550
4551/// Return whether a repository path is an accepted test-source location.
4552fn is_test_path(path: &str) -> bool {
4553    let normalized = path.replace('\\', "/").to_ascii_lowercase();
4554    let file = normalized.rsplit('/').next().unwrap_or(&normalized);
4555    normalized
4556        .split('/')
4557        .any(|segment| matches!(segment, "test" | "tests" | "__tests__"))
4558        || file.contains(".test.")
4559        || file.contains(".spec.")
4560        || file
4561            .split_once('.')
4562            .is_some_and(|(stem, _extension)| stem.ends_with("_test") || stem.starts_with("test_"))
4563}
4564
4565/// Extract one exact handler identifier from a static route registration.
4566fn static_route_handler(relation: &SymbolRelation) -> Option<String> {
4567    let leaf = call_leaf(&relation.target_name);
4568    if !matches!(
4569        leaf,
4570        "route" | "add_route" | "map_get" | "map_post" | "map_put" | "map_patch" | "map_delete"
4571    ) {
4572        return None;
4573    }
4574    let route = static_string_argument(&relation.context)?;
4575    if !route.starts_with('/')
4576        || route.chars().any(char::is_control)
4577        || relation.context.matches(',').count() != 1
4578    {
4579        return None;
4580    }
4581    let handler = relation
4582        .context
4583        .rsplit_once(',')?
4584        .1
4585        .trim()
4586        .trim_end_matches([')', ';'])
4587        .trim();
4588    let valid_handler = !handler.is_empty()
4589        && handler != relation.source_name
4590        && handler
4591            .chars()
4592            .next()
4593            .is_some_and(|character| !character.is_ascii_digit())
4594        && handler.chars().all(|character| {
4595            character.is_ascii_alphanumeric() || matches!(character, '_' | ':' | '.' | '$')
4596        });
4597    valid_handler.then(|| handler.trim_matches('$').to_string())
4598}
4599
4600/// Extract one static environment key without retaining its value.
4601fn static_environment_key(relation: &SymbolRelation) -> Option<String> {
4602    let normalized = relation
4603        .target_name
4604        .trim()
4605        .trim_end_matches('!')
4606        .to_ascii_lowercase();
4607    if !(normalized.ends_with("env::var")
4608        || normalized.ends_with("env::var_os")
4609        || normalized.ends_with("os.getenv")
4610        || normalized.ends_with("getenvironmentvariable")
4611        || matches!(normalized.as_str(), "getenv" | "getenv_os"))
4612    {
4613        return None;
4614    }
4615    let key = static_string_argument(&relation.context)?;
4616    (!key.is_empty()
4617        && key.len() <= 128
4618        && key
4619            .bytes()
4620            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'))
4621    .then(|| key.to_string())
4622}
4623
4624/// Classify a call as one accepted bounded static read or write API.
4625fn static_data_access_kind(target: &str) -> Option<ExtendedRelationKind> {
4626    let normalized = target.trim().trim_end_matches('!').to_ascii_lowercase();
4627    let leaf = call_leaf(&normalized);
4628    if matches!(
4629        leaf,
4630        "read" | "read_to_string" | "readfile" | "readfilesync"
4631    ) || normalized.ends_with("file::open")
4632    {
4633        Some(ExtendedRelationKind::Reads)
4634    } else if matches!(leaf, "write" | "writefile" | "writefilesync" | "create")
4635        || normalized.ends_with("file::create")
4636    {
4637        Some(ExtendedRelationKind::Writes)
4638    } else {
4639        None
4640    }
4641}
4642
4643/// Return the normalized leaf of one qualified call target.
4644fn call_leaf(target: &str) -> &str {
4645    target
4646        .trim()
4647        .trim_end_matches('!')
4648        .rsplit([':', '.', '/'])
4649        .find(|part| !part.is_empty())
4650        .unwrap_or_default()
4651}
4652
4653/// Return the first argument only when it is one complete static string literal.
4654fn static_string_argument(context: &str) -> Option<&str> {
4655    let open = context.find('(')?;
4656    let argument = context[open + 1..].trim_start();
4657    let quote = argument.chars().next()?;
4658    if !matches!(quote, '\'' | '"') {
4659        return None;
4660    }
4661    let value = &argument[quote.len_utf8()..];
4662    let end = value.find(quote)?;
4663    let value = &value[..end];
4664    (!value.contains('\\')
4665        && !value.contains("${")
4666        && !value.contains("#{")
4667        && !value.chars().any(char::is_control))
4668    .then_some(value)
4669}
4670
4671/// Normalize one static relative source literal into a repository path.
4672fn normalize_static_repository_path(value: &str) -> Option<String> {
4673    let value = value.replace('\\', "/");
4674    if value.is_empty()
4675        || value.starts_with('/')
4676        || value.starts_with('~')
4677        || value.contains("://")
4678        || value.contains('$')
4679        || value.contains('{')
4680        || value.contains('}')
4681        || value
4682            .split('/')
4683            .next()
4684            .is_some_and(|part| part.contains(':'))
4685    {
4686        return None;
4687    }
4688    let mut parts = Vec::new();
4689    for part in value.split('/') {
4690        match part {
4691            "" | "." => {}
4692            ".." => {
4693                parts.pop()?;
4694            }
4695            part if part == "."
4696                || part == ".."
4697                || part.chars().any(char::is_control)
4698                || part.trim() != part =>
4699            {
4700                return None;
4701            }
4702            part => parts.push(part.to_string()),
4703        }
4704    }
4705    (!parts.is_empty()).then(|| parts.join("/"))
4706}
4707
4708/// Classify one exact repository configuration filename without reading values.
4709fn configuration_file_identity(path: &str) -> Option<String> {
4710    let normalized = path.replace('\\', "/").to_ascii_lowercase();
4711    let file = normalized.rsplit('/').next().unwrap_or(&normalized);
4712    let identity = if file == ".env" || file.starts_with(".env.") {
4713        "dotenv"
4714    } else if matches!(
4715        file,
4716        "config.json"
4717            | "config.yaml"
4718            | "config.yml"
4719            | "config.toml"
4720            | "settings.json"
4721            | "settings.yaml"
4722            | "settings.yml"
4723            | "settings.toml"
4724            | "appsettings.json"
4725    ) {
4726        "application-config"
4727    } else {
4728        return None;
4729    };
4730    Some(identity.to_string())
4731}
4732
4733/// Classify one accepted infrastructure path into a content-free platform.
4734fn deployment_platform_identity(path: &str) -> Option<String> {
4735    let normalized = path.replace('\\', "/").to_ascii_lowercase();
4736    let file = normalized.rsplit('/').next().unwrap_or(&normalized);
4737    let identity = if file_has_extension(file, "tf")
4738        || file
4739            .strip_suffix(".json")
4740            .is_some_and(|stem| file_has_extension(stem, "tf"))
4741    {
4742        "terraform"
4743    } else if file == "dockerfile"
4744        || file.starts_with("dockerfile.")
4745        || matches!(
4746            file,
4747            "compose.yaml" | "compose.yml" | "docker-compose.yaml" | "docker-compose.yml"
4748        )
4749    {
4750        "containers"
4751    } else if matches!(
4752        file,
4753        "chart.yaml" | "kustomization.yaml" | "kustomization.yml"
4754    ) || (normalized
4755        .split('/')
4756        .any(|segment| matches!(segment, "k8s" | "kubernetes" | "helm" | "kustomize"))
4757        && matches!(
4758            Path::new(file).extension().and_then(|value| value.to_str()),
4759            Some("yaml" | "yml" | "json")
4760        ))
4761    {
4762        "kubernetes"
4763    } else if file_has_extension(file, "bicep") {
4764        "azure-bicep"
4765    } else if file == "sam-template.yaml"
4766        || (file.contains("cloudformation")
4767            && matches!(
4768                Path::new(file).extension().and_then(|value| value.to_str()),
4769                Some("yaml" | "yml" | "json")
4770            ))
4771    {
4772        "cloudformation"
4773    } else if file == "playbook.yaml" || file == "playbook.yml" {
4774        "ansible"
4775    } else {
4776        return None;
4777    };
4778    Some(identity.to_string())
4779}
4780
4781/// Match one exact extension without platform case assumptions.
4782fn file_has_extension(file: &str, expected: &str) -> bool {
4783    Path::new(file)
4784        .extension()
4785        .is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
4786}
4787
4788/// Resolve one parser relation's unique local source entity when possible.
4789fn relation_source<'a>(
4790    owners: &GraphOwners,
4791    entities: &'a BTreeMap<String, GraphEntity>,
4792    graph: &SymbolGraph,
4793    symbol_index: &GraphSymbolIndex<'_>,
4794    relation: &SymbolRelation,
4795    control: &IndexWorkControl,
4796) -> Result<&'a GraphEntity, CliError> {
4797    let file = entities.get(&owners.file_digest).ok_or_else(|| {
4798        CliError::InvalidInput("graph file owner entity was not staged".to_string())
4799    })?;
4800    let is_php_call = relation.kind == RelationKind::Calls
4801        && graph
4802            .language
4803            .as_deref()
4804            .is_some_and(|language| language.eq_ignore_ascii_case("php"));
4805    let is_php_contains = relation.kind == RelationKind::Contains
4806        && graph
4807            .language
4808            .as_deref()
4809            .is_some_and(|language| language.eq_ignore_ascii_case("php"));
4810    let php_source = if is_php_call {
4811        unique_php_call_source(
4812            graph,
4813            symbol_index,
4814            &relation.source_name,
4815            relation.line,
4816            control,
4817        )?
4818    } else if is_php_contains {
4819        unique_php_containment_source(graph, symbol_index, relation, control)?
4820    } else {
4821        None
4822    };
4823    let indices = if is_php_call || is_php_contains {
4824        php_source.as_slice()
4825    } else {
4826        symbol_index.get(&relation.source_name)
4827    };
4828    let mut matched = None;
4829    for (candidate_index, &index) in indices.iter().enumerate() {
4830        check_graph_work(control, candidate_index)?;
4831        let digest = owners.symbol_digests.get(index).ok_or_else(|| {
4832            CliError::InvalidInput("graph symbol owner index was not staged".to_string())
4833        })?;
4834        let Some(digest) = digest else {
4835            continue;
4836        };
4837        let entity = entities.get(digest).ok_or_else(|| {
4838            CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
4839        })?;
4840        if matched.is_some() {
4841            return Ok(file);
4842        }
4843        matched = Some(entity);
4844    }
4845    Ok(matched.unwrap_or(file))
4846}
4847
4848/// Resolve one relation from canonical dependency keys and bounded candidates.
4849#[derive(Clone, Copy)]
4850struct ResolutionMatches<'a> {
4851    /// First target in stable digest order when at least one target exists.
4852    first: Option<&'a GraphEntity>,
4853    /// Exact number of distinct target entities.
4854    count: u32,
4855}
4856
4857/// Resolve one relation from canonical dependency keys and bounded candidates.
4858fn relation_resolution<'a>(
4859    project: ProjectInstanceId,
4860    generation: IndexGeneration,
4861    relation: &projectatlas_core::symbols::SymbolRelation,
4862    relation_index: Option<usize>,
4863    owners: &GraphOwners,
4864    graph: &SymbolGraph,
4865    symbol_index: &GraphSymbolIndex<'_>,
4866    dependency_keys: &[CanonicalResolutionKey],
4867    candidates: &'a ProjectResolutionRegistry,
4868    staged_entities: &'a BTreeMap<String, GraphEntity>,
4869    external_entities: &mut BTreeMap<String, GraphEntity>,
4870    control: &IndexWorkControl,
4871) -> Result<RelationResolution, CliError> {
4872    let matches = match relation.kind {
4873        RelationKind::Contains => local_relation_matches(
4874            relation,
4875            relation_index,
4876            owners,
4877            graph,
4878            symbol_index,
4879            staged_entities,
4880            control,
4881        )?,
4882        RelationKind::Calls => {
4883            let local = local_relation_matches(
4884                relation,
4885                relation_index,
4886                owners,
4887                graph,
4888                symbol_index,
4889                staged_entities,
4890                control,
4891            )?;
4892            if local.count == 0 {
4893                registry_resolution_matches(dependency_keys, candidates, staged_entities, control)?
4894            } else {
4895                local
4896            }
4897        }
4898        RelationKind::Imports | RelationKind::DependsOn => {
4899            registry_resolution_matches(dependency_keys, candidates, staged_entities, control)?
4900        }
4901    };
4902    match matches.count {
4903        0 => {
4904            if let Some(external) = explicit_external_selector(graph, relation)? {
4905                let entity =
4906                    GraphEntity::new(project, EntitySelector::External { external }, generation)
4907                        .map_err(invalid_graph_contract)?;
4908                let resolution =
4909                    RelationResolution::external(&entity).map_err(invalid_graph_contract)?;
4910                insert_entity(external_entities, entity)?;
4911                return Ok(resolution);
4912            }
4913            Ok(RelationResolution::Unresolved {
4914                reference: GraphIdentityText::new(relation_reference(relation))
4915                    .map_err(invalid_graph_contract)?,
4916            })
4917        }
4918        1 => RelationResolution::resolved(
4919            matches
4920                .first
4921                .ok_or_else(|| CliError::InvalidInput("resolved target disappeared".to_string()))?,
4922        )
4923        .map_err(invalid_graph_contract),
4924        count => Ok(RelationResolution::Ambiguous {
4925            reference: GraphIdentityText::new(relation_reference(relation))
4926                .map_err(invalid_graph_contract)?,
4927            candidates: NonZeroU32::new(count).ok_or_else(|| {
4928                CliError::InvalidInput("ambiguous target count was zero".to_string())
4929            })?,
4930        }),
4931    }
4932}
4933
4934/// Resolve exact declarations owned by the relation's source file.
4935fn local_relation_matches<'a>(
4936    relation: &projectatlas_core::symbols::SymbolRelation,
4937    relation_index: Option<usize>,
4938    owners: &GraphOwners,
4939    graph: &SymbolGraph,
4940    symbol_index: &GraphSymbolIndex<'_>,
4941    staged_entities: &'a BTreeMap<String, GraphEntity>,
4942    control: &IndexWorkControl,
4943) -> Result<ResolutionMatches<'a>, CliError> {
4944    let mut targets = BTreeMap::<&str, &GraphEntity>::new();
4945    let mut global_fallback_targets = BTreeMap::<&str, &GraphEntity>::new();
4946    let target_name = relation.target_name.trim();
4947    let is_php_call = relation.kind == RelationKind::Calls
4948        && graph
4949            .language
4950            .as_deref()
4951            .is_some_and(|language| language.eq_ignore_ascii_case("php"));
4952    let is_php_contains = relation.kind == RelationKind::Contains
4953        && graph
4954            .language
4955            .as_deref()
4956            .is_some_and(|language| language.eq_ignore_ascii_case("php"));
4957    let php_containment_source = if is_php_contains {
4958        unique_php_containment_source(graph, symbol_index, relation, control)?
4959            .and_then(|index| graph.symbols.get(index))
4960    } else {
4961        None
4962    };
4963    let source_parent = if relation.kind == RelationKind::Calls {
4964        unique_source_parent(
4965            graph,
4966            symbol_index,
4967            &relation.source_name,
4968            is_php_call.then_some(relation.line),
4969            control,
4970        )?
4971    } else {
4972        None
4973    };
4974    let known_source_namespace = if is_php_call {
4975        unique_php_source_namespace(graph, symbol_index, source_parent, relation, control)?
4976    } else {
4977        None
4978    };
4979    if is_php_call
4980        && target_name
4981            .split_once("::")
4982            .is_some_and(|(scope, _)| scope.eq_ignore_ascii_case("self"))
4983        && let Some(parent) = source_parent
4984    {
4985        for (candidate_index, &index) in symbol_index.get(parent).iter().enumerate() {
4986            check_graph_work(control, candidate_index)?;
4987            let owner = graph.symbols.get(index).ok_or_else(|| {
4988                CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
4989            })?;
4990            if owner.kind == SymbolKind::Trait
4991                && owner.line_start <= relation.line
4992                && relation.line <= owner.line_end
4993            {
4994                // A consuming class can override the trait member selected by self.
4995                return Ok(ResolutionMatches {
4996                    first: None,
4997                    count: 0,
4998                });
4999            }
5000        }
5001    }
5002    let php_imports_may_alias = symbol_index.php_imports_have_unknown_scope
5003        || known_source_namespace.map_or(
5004            !symbol_index.php_import_positions.is_empty(),
5005            |namespace| {
5006                let namespace = namespace.to_ascii_lowercase();
5007                let block_start = symbol_index
5008                    .php_namespace_start_lines
5009                    .get(&namespace)
5010                    .and_then(|starts| starts.range(..=relation.line).next_back())
5011                    .copied()
5012                    .unwrap_or(0);
5013                symbol_index
5014                    .php_import_positions
5015                    .get(&namespace)
5016                    .and_then(|positions| positions.range(block_start..=relation.line).next())
5017                    .is_some_and(|(&line, &import_index)| {
5018                        line < relation.line
5019                            || import_index
5020                                .zip(relation_index)
5021                                .is_none_or(|(import, call)| import <= call)
5022                    })
5023            },
5024        );
5025    let namespace_relative_target = if is_php_call
5026        && let Some((prefix, relative)) = target_name.split_once('\\')
5027        && !prefix.is_empty()
5028        && (prefix.eq_ignore_ascii_case("namespace") || !php_imports_may_alias)
5029    {
5030        let Some(namespace) = known_source_namespace else {
5031            return Ok(ResolutionMatches {
5032                first: None,
5033                count: 0,
5034            });
5035        };
5036        let relative = if prefix.eq_ignore_ascii_case("namespace") {
5037            relative
5038        } else {
5039            target_name
5040        };
5041        Some(if namespace.is_empty() {
5042            format!("\\{relative}")
5043        } else {
5044            format!("\\{namespace}\\{relative}")
5045        })
5046    } else {
5047        None
5048    };
5049    let lookup_target = namespace_relative_target.as_deref().unwrap_or(target_name);
5050    if is_php_call
5051        && php_imports_may_alias
5052        && !lookup_target.starts_with('\\')
5053        && !lookup_target
5054            .split_once("::")
5055            .is_some_and(|(scope, _)| scope.eq_ignore_ascii_case("self"))
5056    {
5057        // An import in this namespace, or an import with unknown scope, can
5058        // replace an unrooted name. Do not assume a same-file declaration wins.
5059        return Ok(ResolutionMatches {
5060            first: None,
5061            count: 0,
5062        });
5063    }
5064    let (lookup_name, scoped_parent, mut scoped_namespace, target_namespace) = if is_php_call {
5065        let Some(lookup) = php_call_lookup(lookup_target, source_parent) else {
5066            return Ok(ResolutionMatches {
5067                first: None,
5068                count: 0,
5069            });
5070        };
5071        lookup
5072    } else {
5073        (target_name, None, None, None)
5074    };
5075    if is_php_call && scoped_parent.is_some() && scoped_namespace.is_none() {
5076        scoped_namespace = known_source_namespace;
5077        if scoped_namespace.is_none() {
5078            return Ok(ResolutionMatches {
5079                first: None,
5080                count: 0,
5081            });
5082        }
5083    }
5084    let source_namespace = if is_php_call && scoped_parent.is_none() {
5085        if target_namespace.is_some() {
5086            target_namespace
5087        } else {
5088            known_source_namespace
5089        }
5090    } else {
5091        None
5092    };
5093    let candidate_indices = if is_php_call {
5094        symbol_index.get_php_call(lookup_name)
5095    } else {
5096        symbol_index.get(lookup_name)
5097    };
5098    for (candidate_index, &row_index) in candidate_indices.iter().enumerate() {
5099        check_graph_work(control, candidate_index)?;
5100        let symbol = graph.symbols.get(row_index).ok_or_else(|| {
5101            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5102        })?;
5103        let digest = owners.symbol_digests.get(row_index).ok_or_else(|| {
5104            CliError::InvalidInput("graph symbol owner index was not staged".to_string())
5105        })?;
5106        let exact_match = match relation.kind {
5107            RelationKind::Contains => {
5108                symbol.name == lookup_name
5109                    && symbol.parent.as_deref() == Some(relation.source_name.as_str())
5110                    && if is_php_contains {
5111                        if let Some(source) = php_containment_source {
5112                            symbol.line_start == relation.line
5113                                && symbol.detail.as_deref() == Some(relation.context.as_str())
5114                                && php_entity_matches_qualified_scope(
5115                                    staged_entities,
5116                                    digest.as_ref(),
5117                                    &source.name,
5118                                    source.parent.as_deref().unwrap_or(""),
5119                                )?
5120                        } else {
5121                            false
5122                        }
5123                    } else {
5124                        true
5125                    }
5126            }
5127            RelationKind::Calls => {
5128                let name_matches = if is_php_call {
5129                    symbol.name.eq_ignore_ascii_case(lookup_name)
5130                } else {
5131                    symbol.name == lookup_name
5132                };
5133                name_matches
5134                    && (!is_php_call
5135                        || symbol.kind
5136                            == if scoped_parent.is_some() {
5137                                SymbolKind::Method
5138                            } else {
5139                                SymbolKind::Function
5140                            })
5141                    && scoped_parent.is_none_or(|parent| {
5142                        symbol.parent.as_deref().is_some_and(|symbol_parent| {
5143                            if is_php_call {
5144                                symbol_parent.eq_ignore_ascii_case(parent)
5145                            } else {
5146                                symbol_parent == parent
5147                            }
5148                        })
5149                    })
5150                    && if let (Some(namespace), Some(scoped_parent)) =
5151                        (scoped_namespace, scoped_parent)
5152                    {
5153                        php_entity_matches_qualified_scope(
5154                            staged_entities,
5155                            digest.as_ref(),
5156                            scoped_parent,
5157                            namespace,
5158                        )?
5159                    } else {
5160                        true
5161                    }
5162                    && source_namespace.is_none_or(|namespace| {
5163                        symbol.parent.as_deref().is_some_and(|symbol_parent| {
5164                            symbol_parent.eq_ignore_ascii_case(namespace)
5165                        }) || (target_namespace.is_none_or(str::is_empty)
5166                            && symbol.parent.is_none())
5167                    })
5168                    && (scoped_parent.is_some()
5169                        || source_namespace.is_some()
5170                        || symbol.parent.is_none()
5171                        || symbol.parent.as_deref() == Some(relation.source_name.as_str())
5172                        || source_parent.is_some_and(|source_parent| {
5173                            symbol.parent.as_deref() == Some(source_parent)
5174                        }))
5175            }
5176            RelationKind::Imports | RelationKind::DependsOn => false,
5177        };
5178        if exact_match && let Some(digest) = digest {
5179            let entity = staged_entities.get(digest).ok_or_else(|| {
5180                CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
5181            })?;
5182            let is_global_fallback = is_php_call
5183                && scoped_parent.is_none()
5184                && target_namespace.is_none()
5185                && source_namespace.is_some()
5186                && symbol.kind == SymbolKind::Function
5187                && symbol.parent.is_none();
5188            let distinct_target_count = targets.len().saturating_add(global_fallback_targets.len());
5189            let destination = if is_global_fallback {
5190                &mut global_fallback_targets
5191            } else {
5192                &mut targets
5193            };
5194            if !destination.contains_key(entity.key().digest()) {
5195                enforce_resolution_match_budget(distinct_target_count.saturating_add(1))?;
5196            }
5197            destination.insert(entity.key().digest(), entity);
5198        }
5199    }
5200    let targets = if targets.is_empty() {
5201        global_fallback_targets
5202    } else {
5203        targets
5204    };
5205    let count = distinct_resolution_count(targets.len())?;
5206    Ok(ResolutionMatches {
5207        first: targets.into_values().next(),
5208        count,
5209    })
5210}
5211
5212/// Select the local PHP call member and, for scoped calls, its owning type.
5213fn php_call_lookup<'a>(
5214    target_name: &'a str,
5215    source_parent: Option<&'a str>,
5216) -> Option<(&'a str, Option<&'a str>, Option<&'a str>, Option<&'a str>)> {
5217    let Some((scope, member)) = target_name.rsplit_once("::") else {
5218        let Some(qualified_target) = target_name.strip_prefix('\\') else {
5219            return Some((target_name, None, None, None));
5220        };
5221        let Some((namespace, function)) = qualified_target.rsplit_once('\\') else {
5222            return Some((qualified_target, None, None, Some("")));
5223        };
5224        if namespace.is_empty() || function.is_empty() {
5225            return None;
5226        }
5227        return Some((function, None, None, Some(namespace)));
5228    };
5229    if scope.is_empty() || member.is_empty() {
5230        return None;
5231    }
5232    if scope.eq_ignore_ascii_case("self") {
5233        return source_parent.map(|parent| (member, Some(parent), None, None));
5234    }
5235    if scope.eq_ignore_ascii_case("static") {
5236        return None;
5237    }
5238    if scope.eq_ignore_ascii_case("parent") {
5239        return None;
5240    }
5241    if scope.contains('\\') && !scope.starts_with('\\') {
5242        return None;
5243    }
5244    let scope_without_root = scope.strip_prefix('\\').unwrap_or(scope);
5245    let (scope, namespace) = match scope_without_root.rsplit_once('\\') {
5246        Some((namespace, scope)) if !namespace.is_empty() && !scope.is_empty() => {
5247            (scope, Some(namespace))
5248        }
5249        None if scope.starts_with('\\') && !scope_without_root.is_empty() => {
5250            (scope_without_root, Some(""))
5251        }
5252        None if !scope_without_root.is_empty() => (scope_without_root, None),
5253        _ => return None,
5254    };
5255    Some((member, Some(scope), namespace, None))
5256}
5257
5258/// Confirm a PHP entity belongs to a qualified owner scope.
5259fn php_entity_matches_qualified_scope(
5260    staged_entities: &BTreeMap<String, GraphEntity>,
5261    digest: Option<&String>,
5262    scoped_parent: &str,
5263    namespace: &str,
5264) -> Result<bool, CliError> {
5265    let Some(digest) = digest else {
5266        return Ok(false);
5267    };
5268    let entity = staged_entities.get(digest).ok_or_else(|| {
5269        CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
5270    })?;
5271    let EntitySelector::Symbol { symbol } = entity.selector() else {
5272        return Ok(false);
5273    };
5274    let Some(parent) = symbol.parent.as_ref() else {
5275        return Ok(false);
5276    };
5277    Ok(
5278        if let Some((owner_namespace, owner_name)) = parent.as_str().rsplit_once("::") {
5279            owner_name.eq_ignore_ascii_case(scoped_parent)
5280                && owner_namespace.eq_ignore_ascii_case(namespace)
5281        } else {
5282            namespace.is_empty() && parent.as_str().eq_ignore_ascii_case(scoped_parent)
5283        },
5284    )
5285}
5286
5287/// Prove a PHP caller's namespace, including the empty global namespace.
5288fn unique_php_source_namespace<'a>(
5289    graph: &'a SymbolGraph,
5290    symbol_index: &GraphSymbolIndex<'_>,
5291    source_parent: Option<&str>,
5292    relation: &projectatlas_core::symbols::SymbolRelation,
5293    control: &IndexWorkControl,
5294) -> Result<Option<&'a str>, CliError> {
5295    let Some(source_parent) = source_parent else {
5296        // Partial PHP coverage retains the grammar's proven global source marker.
5297        if relation.source_name == MODULE_RELATION_SOURCE {
5298            return Ok(Some(""));
5299        }
5300        let mut global_function = false;
5301        let mut namespace = None;
5302        for (candidate_index, &row_index) in
5303            symbol_index.get(&relation.source_name).iter().enumerate()
5304        {
5305            check_graph_work(control, candidate_index)?;
5306            let symbol = graph.symbols.get(row_index).ok_or_else(|| {
5307                CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5308            })?;
5309            if matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method)
5310                && symbol.line_start <= relation.line
5311                && relation.line <= symbol.line_end
5312            {
5313                if symbol.kind != SymbolKind::Function || symbol.parent.is_some() {
5314                    return Ok(None);
5315                }
5316                global_function = true;
5317            } else if symbol.kind == SymbolKind::Module {
5318                namespace = Some(symbol.name.as_str());
5319            }
5320        }
5321        return Ok(if global_function { Some("") } else { namespace });
5322    };
5323    let mut namespace = None;
5324    let mut module_namespace = None;
5325    let mut source_found = false;
5326    for (candidate_index, &row_index) in symbol_index.get(source_parent).iter().enumerate() {
5327        check_graph_work(control, candidate_index)?;
5328        let symbol = graph.symbols.get(row_index).ok_or_else(|| {
5329            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5330        })?;
5331        let candidate = match symbol.kind {
5332            SymbolKind::Class | SymbolKind::Interface | SymbolKind::Trait | SymbolKind::Enum => {
5333                if relation.line < symbol.line_start || relation.line > symbol.line_end {
5334                    continue;
5335                }
5336                Some(symbol.parent.as_deref().unwrap_or(""))
5337            }
5338            SymbolKind::Module if symbol.name == source_parent => {
5339                module_namespace = Some(symbol.name.as_str());
5340                continue;
5341            }
5342            _ => continue,
5343        };
5344        if !source_found {
5345            namespace = candidate;
5346            source_found = true;
5347        } else if namespace != candidate {
5348            return Ok(None);
5349        }
5350    }
5351    // A line-matched containing type owns the call before a same-named namespace.
5352    Ok(namespace.or(module_namespace))
5353}
5354
5355/// Return one unambiguous containing scope for the parser relation source.
5356fn unique_source_parent<'a>(
5357    graph: &'a SymbolGraph,
5358    symbol_index: &GraphSymbolIndex<'_>,
5359    source_name: &str,
5360    relation_line: Option<usize>,
5361    control: &IndexWorkControl,
5362) -> Result<Option<&'a str>, CliError> {
5363    if let Some(line) = relation_line {
5364        let Some(index) = unique_php_call_source(graph, symbol_index, source_name, line, control)?
5365        else {
5366            return Ok(None);
5367        };
5368        let symbol = graph.symbols.get(index).ok_or_else(|| {
5369            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5370        })?;
5371        return Ok(symbol.parent.as_deref());
5372    }
5373    let mut parent = None;
5374    let mut source_found = false;
5375    for (candidate_index, &row_index) in symbol_index.get(source_name).iter().enumerate() {
5376        check_graph_work(control, candidate_index)?;
5377        let symbol = graph.symbols.get(row_index).ok_or_else(|| {
5378            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5379        })?;
5380        let candidate = symbol.parent.as_deref();
5381        if !source_found {
5382            parent = candidate;
5383            source_found = true;
5384        } else if parent != candidate {
5385            return Ok(None);
5386        }
5387    }
5388    Ok(parent)
5389}
5390
5391/// Select a PHP containment owner from the target's exact declaration span.
5392fn unique_php_containment_source(
5393    graph: &SymbolGraph,
5394    symbol_index: &GraphSymbolIndex<'_>,
5395    relation: &SymbolRelation,
5396    control: &IndexWorkControl,
5397) -> Result<Option<usize>, CliError> {
5398    let mut target = None;
5399    for (candidate_index, &index) in symbol_index.get(&relation.target_name).iter().enumerate() {
5400        check_graph_work(control, candidate_index)?;
5401        let symbol = graph.symbols.get(index).ok_or_else(|| {
5402            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5403        })?;
5404        if symbol.parent.as_deref() == Some(relation.source_name.as_str())
5405            && symbol.line_start == relation.line
5406            && symbol.detail.as_deref() == Some(relation.context.as_str())
5407            && target.replace(index).is_some()
5408        {
5409            return Ok(None);
5410        }
5411    }
5412    let Some(target) = target else {
5413        return Ok(None);
5414    };
5415    let Some(target_span) = graph.symbols[target].source_selector else {
5416        return Ok(None);
5417    };
5418    let mut matched = None;
5419    let mut module = None;
5420    for (candidate_index, &index) in symbol_index.get(&relation.source_name).iter().enumerate() {
5421        check_graph_work(control, candidate_index)?;
5422        let symbol = graph.symbols.get(index).ok_or_else(|| {
5423            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5424        })?;
5425        let contains = match symbol.kind {
5426            SymbolKind::Module => {
5427                module.get_or_insert(index);
5428                false
5429            }
5430            SymbolKind::Class | SymbolKind::Interface | SymbolKind::Trait | SymbolKind::Enum => {
5431                index != target
5432                    && symbol.source_selector.is_some_and(|span| {
5433                        span.byte_start <= target_span.byte_start
5434                            && target_span.byte_end <= span.byte_end
5435                    })
5436            }
5437            _ => false,
5438        };
5439        if contains && matched.replace(index).is_some() {
5440            return Ok(None);
5441        }
5442    }
5443    Ok(matched.or(module))
5444}
5445
5446/// Prefer a proven PHP callable source, falling back to a unique namespace owner.
5447fn unique_php_call_source(
5448    graph: &SymbolGraph,
5449    symbol_index: &GraphSymbolIndex<'_>,
5450    source_name: &str,
5451    line: usize,
5452    control: &IndexWorkControl,
5453) -> Result<Option<usize>, CliError> {
5454    let mut callable = None;
5455    let mut callable_boundary = false;
5456    let mut module = None;
5457    for (candidate_index, &index) in symbol_index.get(source_name).iter().enumerate() {
5458        check_graph_work(control, candidate_index)?;
5459        let symbol = graph.symbols.get(index).ok_or_else(|| {
5460            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5461        })?;
5462        match symbol.kind {
5463            SymbolKind::Function | SymbolKind::Method
5464                if symbol.line_start <= line && line <= symbol.line_end =>
5465            {
5466                if callable.replace(index).is_some() {
5467                    return Ok(None);
5468                }
5469                callable_boundary = line == symbol.line_start || line == symbol.line_end;
5470            }
5471            SymbolKind::Module => {
5472                // Reopened blocks share one line-independent namespace entity.
5473                module = Some(index);
5474            }
5475            _ => {}
5476        }
5477    }
5478    if callable.is_some() {
5479        // Line-only facts cannot distinguish a boundary-line call from a
5480        // same-named namespace's top-level call beside the declaration.
5481        Ok(if module.is_some() && callable_boundary {
5482            None
5483        } else {
5484            callable
5485        })
5486    } else {
5487        Ok(module)
5488    }
5489}
5490
5491/// Merge sorted candidate streams without materializing their full union.
5492fn registry_resolution_matches<'a>(
5493    dependency_keys: &[CanonicalResolutionKey],
5494    candidates: &'a ProjectResolutionRegistry,
5495    staged_entities: &'a BTreeMap<String, GraphEntity>,
5496    control: &IndexWorkControl,
5497) -> Result<ResolutionMatches<'a>, CliError> {
5498    enforce_resolution_match_budget(dependency_keys.len().saturating_mul(2))?;
5499    let mut streams = dependency_keys
5500        .iter()
5501        .filter_map(|key| candidates.candidate_digests_by_key.get(key))
5502        .map(BTreeSet::iter)
5503        .collect::<Vec<_>>();
5504    let mut frontier = BinaryHeap::new();
5505    for (stream_index, stream) in streams.iter_mut().enumerate() {
5506        if let Some(digest) = stream.next() {
5507            frontier.push(Reverse((digest.as_str(), stream_index)));
5508        }
5509    }
5510
5511    let mut first = None;
5512    let mut last_digest = None;
5513    let mut count = 0_u32;
5514    let mut visited = 0_usize;
5515    while let Some(Reverse((digest, stream_index))) = frontier.pop() {
5516        check_graph_work(control, visited)?;
5517        visited = visited.saturating_add(1);
5518        if last_digest != Some(digest) {
5519            let entity = staged_entities
5520                .get(digest)
5521                .or_else(|| candidates.supplemental_entities_by_digest.get(digest))
5522                .ok_or_else(|| {
5523                    CliError::InvalidInput(
5524                        "resolution candidate entity was not registered".to_string(),
5525                    )
5526                })?;
5527            first.get_or_insert(entity);
5528            count = count.checked_add(1).ok_or_else(|| {
5529                IndexWorkFailure::resource_limit(
5530                    IndexWorkStage::SymbolParsing,
5531                    IndexWorkResource::RelationRows,
5532                    u64::from(u32::MAX),
5533                    u64::from(u32::MAX) + 1,
5534                )
5535            })?;
5536            last_digest = Some(digest);
5537        }
5538        if let Some(next) = streams[stream_index].next() {
5539            frontier.push(Reverse((next.as_str(), stream_index)));
5540        }
5541    }
5542    Ok(ResolutionMatches { first, count })
5543}
5544
5545/// Convert one exact distinct target count without truncation.
5546fn distinct_resolution_count(count: usize) -> Result<u32, CliError> {
5547    u32::try_from(count).map_err(|_conversion_error| {
5548        IndexWorkFailure::resource_limit(
5549            IndexWorkStage::SymbolParsing,
5550            IndexWorkResource::RelationRows,
5551            u64::from(u32::MAX),
5552            u64::try_from(count).unwrap_or(u64::MAX),
5553        )
5554        .into()
5555    })
5556}
5557
5558/// Bound temporary maps and heap storage used while selecting relation targets.
5559fn enforce_resolution_match_budget(rows: usize) -> Result<(), CliError> {
5560    enforce_resolution_registry_budget(
5561        STAGED_GRAPH_ROW_BYTES.saturating_mul(u64::try_from(rows).unwrap_or(u64::MAX)),
5562    )
5563}
5564
5565/// Return one explicit external identity without guessing ordinary missing packages.
5566fn explicit_external_selector(
5567    graph: &SymbolGraph,
5568    relation: &projectatlas_core::symbols::SymbolRelation,
5569) -> Result<Option<ExternalSelector>, CliError> {
5570    let semantic_provider = graph
5571        .language
5572        .as_deref()
5573        .and_then(language_capability)
5574        .and_then(|capability| capability.effective_semantic_provider());
5575    let classified = match (semantic_provider, relation.kind) {
5576        (Some(SemanticProviderOwner::Cargo), RelationKind::DependsOn) => {
5577            external_reference_identity(&relation.target_name)
5578                .map(|identity| (CARGO_PACKAGE_MANAGER, identity))
5579        }
5580        (Some(SemanticProviderOwner::Rust), RelationKind::Imports | RelationKind::Calls) => {
5581            rust_toolchain_identity(relation).map(|identity| (RUST_TOOLCHAIN_SYSTEM, identity))
5582        }
5583        (Some(SemanticProviderOwner::EcmaScript), RelationKind::Imports) => {
5584            node_builtin_identity(relation).map(|identity| (NODE_SYSTEM, identity))
5585        }
5586        (
5587            Some(
5588                SemanticProviderOwner::Python
5589                | SemanticProviderOwner::Unavailable
5590                | SemanticProviderOwner::Cargo
5591                | SemanticProviderOwner::EcmaScript,
5592            )
5593            | None,
5594            _,
5595        )
5596        | (Some(SemanticProviderOwner::Rust), RelationKind::Contains | RelationKind::DependsOn) => {
5597            None
5598        }
5599    };
5600    classified
5601        .map(|(system, identity)| {
5602            Ok(ExternalSelector {
5603                system: GraphIdentityText::new(system).map_err(invalid_graph_contract)?,
5604                identity: GraphIdentityText::new(identity).map_err(invalid_graph_contract)?,
5605            })
5606        })
5607        .transpose()
5608}
5609
5610/// Normalize one explicit non-empty external reference.
5611fn external_reference_identity(reference: &str) -> Option<String> {
5612    let reference = reference.trim();
5613    (!reference.is_empty()).then(|| reference.to_string())
5614}
5615
5616/// Recognize only explicitly qualified Rust toolchain roots.
5617fn rust_toolchain_identity(
5618    relation: &projectatlas_core::symbols::SymbolRelation,
5619) -> Option<String> {
5620    if relation.kind == RelationKind::Imports {
5621        let mut references = parse_import_references(&relation.target_name).into_iter();
5622        let first = rust_import_identity(&references.next()?)?;
5623        return references.try_fold(first, |common, reference| {
5624            let identity = rust_import_identity(&reference)?;
5625            common_rust_path(&common, &identity)
5626        });
5627    }
5628    let target = relation_reference(relation);
5629    rust_toolchain_root(&target)?;
5630    Some(target)
5631}
5632
5633/// Return one explicitly toolchain-owned Rust import identity.
5634fn rust_import_identity(reference: &projectatlas_symbols::ImportReference) -> Option<String> {
5635    let module = reference.module();
5636    rust_toolchain_root(module)?;
5637    Some(reference.imported().map_or_else(
5638        || module.to_string(),
5639        |imported| format!("{module}::{imported}"),
5640    ))
5641}
5642
5643/// Return the non-empty common Rust module path shared by two imports.
5644fn common_rust_path(left: &str, right: &str) -> Option<String> {
5645    let components = left
5646        .split("::")
5647        .zip(right.split("::"))
5648        .take_while(|(left, right)| left == right)
5649        .map(|(component, _right)| component)
5650        .collect::<Vec<_>>();
5651    (!components.is_empty()).then(|| components.join("::"))
5652}
5653
5654/// Return the accepted toolchain root of one Rust module path.
5655fn rust_toolchain_root(path: &str) -> Option<&str> {
5656    let root = path.trim().split("::").next()?;
5657    matches!(root, "std" | "core" | "alloc").then_some(root)
5658}
5659
5660/// Recognize only the explicit `node:` built-in module scheme.
5661fn node_builtin_identity(relation: &projectatlas_core::symbols::SymbolRelation) -> Option<String> {
5662    parse_import_references(&relation.target_name)
5663        .into_iter()
5664        .find_map(|reference| {
5665            reference
5666                .module()
5667                .strip_prefix("node:")
5668                .filter(|identity| !identity.is_empty())
5669                .map(ToString::to_string)
5670        })
5671        .or_else(|| {
5672            quoted_ecmascript_module(&relation.target_name)
5673                .and_then(|module| module.strip_prefix("node:"))
5674                .filter(|identity| !identity.is_empty())
5675                .map(ToString::to_string)
5676        })
5677}
5678
5679/// Read the explicit quoted module from one parser-validated ECMAScript import.
5680fn quoted_ecmascript_module(import: &str) -> Option<&str> {
5681    let import = import.trim();
5682    if !import.starts_with("import ") {
5683        return None;
5684    }
5685    let (quote_index, quote) = import
5686        .char_indices()
5687        .find(|(_index, character)| matches!(character, '\'' | '"'))?;
5688    let remainder = &import[quote_index + quote.len_utf8()..];
5689    let end = remainder.find(quote)?;
5690    Some(&remainder[..end])
5691}
5692
5693/// Normalize an empty parser reference into one stable diagnostic identity.
5694fn nonempty_reference(value: &str) -> String {
5695    let value = value.trim();
5696    if value.is_empty() {
5697        UNKNOWN_REFERENCE.to_string()
5698    } else {
5699        value.to_string()
5700    }
5701}
5702
5703/// Keep call arguments and literal values out of persisted relation diagnostics.
5704fn relation_reference(relation: &SymbolRelation) -> String {
5705    let mut value = if relation.kind == RelationKind::Calls {
5706        relation
5707            .target_name
5708            .split_once('(')
5709            .map_or(relation.target_name.as_str(), |(target, _arguments)| target)
5710    } else {
5711        &relation.target_name
5712    };
5713    if relation.kind == RelationKind::Calls && value.contains(['\'', '"']) {
5714        value = call_leaf(value);
5715    }
5716    nonempty_reference(value)
5717}
5718
5719/// Project parser trust into one path-scoped coverage record.
5720fn coverage_for_graph(
5721    graph: &SymbolGraph,
5722    generation: IndexGeneration,
5723    identity_admission: &GraphIdentityAdmission,
5724    derived_identity_admission: &GraphIdentityAdmission,
5725) -> Result<CoverageRecord, CliError> {
5726    let scope = CoverageScope::Path {
5727        path: RepositoryNodePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
5728    };
5729    let identity_omitted =
5730        identity_admission.rejected_facts_for_graph(&graph.path, derived_identity_admission)?;
5731    let parser_omitted = identity_admission.parser_rejection_for_graph(&graph.path);
5732    let omitted = identity_omitted
5733        .checked_add(parser_omitted)
5734        .ok_or_else(|| CliError::InvalidInput("identity rejection count overflowed".to_string()))?;
5735    let identity_details_dropped = identity_admission.rejection_details_dropped_for(&graph.path)
5736        || derived_identity_admission.rejection_details_dropped_for(&graph.path);
5737    // Graph-scoped `rows` is the existing persisted coverage slot for the
5738    // publication-wide typed identity-detail ceiling. It is set only from
5739    // the admission fact that a distinct detail was actually evicted.
5740    let reached_limit = (omitted > 0 && identity_details_dropped).then_some(GraphLimitKind::Rows);
5741    let covered = if identity_omitted > 0 {
5742        u64::try_from(graph.symbols.len().saturating_add(graph.relations.len())).unwrap_or(u64::MAX)
5743    } else {
5744        u64::try_from(graph.relations.len()).unwrap_or(u64::MAX)
5745    };
5746    if omitted > 0 {
5747        let state = if covered > 0 {
5748            CoverageState::Partial
5749        } else {
5750            CoverageState::Failed
5751        };
5752        return CoverageRecord::new(
5753            scope,
5754            None,
5755            state,
5756            covered,
5757            omitted,
5758            generation,
5759            Some(GraphIdentityText::new(PARTIAL_COVERAGE_REASON).map_err(invalid_graph_contract)?),
5760            reached_limit,
5761        )
5762        .map_err(invalid_graph_contract);
5763    }
5764    match graph.parser {
5765        ParserKind::TreeSitter | ParserKind::Manifest => CoverageRecord::new(
5766            scope,
5767            None,
5768            CoverageState::Complete,
5769            covered,
5770            0,
5771            generation,
5772            None,
5773            None,
5774        )
5775        .map_err(invalid_graph_contract),
5776        ParserKind::Structural | ParserKind::Fallback if covered > 0 => CoverageRecord::new(
5777            scope,
5778            None,
5779            CoverageState::Partial,
5780            covered,
5781            1,
5782            generation,
5783            Some(GraphIdentityText::new(PARTIAL_COVERAGE_REASON).map_err(invalid_graph_contract)?),
5784            None,
5785        )
5786        .map_err(invalid_graph_contract),
5787        ParserKind::Structural | ParserKind::Fallback => CoverageRecord::new(
5788            scope,
5789            None,
5790            CoverageState::Failed,
5791            0,
5792            1,
5793            generation,
5794            Some(GraphIdentityText::new(PARTIAL_COVERAGE_REASON).map_err(invalid_graph_contract)?),
5795            None,
5796        )
5797        .map_err(invalid_graph_contract),
5798    }
5799}
5800
5801/// Map parser strength to relation confidence.
5802fn relation_confidence(parser: ParserKind) -> ConfidenceClass {
5803    match parser {
5804        ParserKind::TreeSitter | ParserKind::Manifest => ConfidenceClass::Exact,
5805        ParserKind::Structural => ConfidenceClass::Medium,
5806        ParserKind::Fallback => ConfidenceClass::Low,
5807    }
5808}
5809
5810/// Map parser strength to relation completeness.
5811fn relation_completeness(parser: ParserKind) -> Completeness {
5812    match parser {
5813        ParserKind::TreeSitter | ParserKind::Manifest => Completeness::Complete,
5814        ParserKind::Structural | ParserKind::Fallback => Completeness::Partial,
5815    }
5816}
5817
5818/// Carry current-generation rejection state for graphs safely reused by a stage.
5819///
5820/// A publication replaces graph and rejection rows for its selected paths.
5821/// Reused sanitized graphs no longer contain rejected parser values, so the
5822/// persisted typed facts and total path coverage are hydrated before normal
5823/// admission re-derives current key, Markdown, and derived facts. The detail
5824/// count is retained separately to reconcile those two observations exactly.
5825fn hydrate_reused_identity_admission(
5826    store: &AtlasStore,
5827    project: ProjectInstanceId,
5828    reused_paths: &BTreeSet<String>,
5829    graphs: &[impl Borrow<SymbolGraph>],
5830    report: &mut GraphIdentityAdmission,
5831    control: &IndexWorkControl,
5832) -> Result<(), CliError> {
5833    if reused_paths.is_empty() {
5834        return Ok(());
5835    }
5836    let reused_node_paths = reused_paths
5837        .iter()
5838        .map(|path| RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract))
5839        .collect::<Result<Vec<_>, _>>()?;
5840    let mut persisted_rejections = Vec::new();
5841    let mut persisted_coverage = BTreeMap::new();
5842    let mut persisted_rejection_details_dropped_by_path = BTreeSet::new();
5843    for paths in reused_node_paths.chunks(PERSISTED_GRAPH_PATHS_PER_CHUNK) {
5844        control.check(IndexWorkStage::SymbolParsing)?;
5845        let rejection_rows = match store.repository_graph_identity_rejections(
5846            project,
5847            paths,
5848            GraphLimits::MAX_ROWS,
5849            Some(control),
5850        ) {
5851            Ok(rows) => rows,
5852            Err(projectatlas_db::DbError::GraphRowShape { table, reason })
5853                if table == "project_identity"
5854                    && reason == "typed graph generation does not match complete publication" =>
5855            {
5856                // A full stage can repair an older incomplete publication, but
5857                // it cannot safely carry diagnostic rows from that state.
5858                return Ok(());
5859            }
5860            Err(error) => return Err(error.into()),
5861        };
5862        persisted_rejections.extend(rejection_rows);
5863        let coverage = match store.repository_graph_path_coverage(project, paths, Some(control)) {
5864            Ok(coverage) => coverage,
5865            Err(projectatlas_db::DbError::GraphRowShape { table, reason })
5866                if table == "project_identity"
5867                    && reason == "typed graph generation does not match complete publication" =>
5868            {
5869                return Ok(());
5870            }
5871            Err(error) => return Err(error.into()),
5872        };
5873        if coverage.truncated {
5874            return Err(CliError::InvalidInput(
5875                "reused graph coverage exceeded the publication row ceiling".to_string(),
5876            ));
5877        }
5878        for row in coverage.rows {
5879            let CoverageScope::Path { path } = row.scope() else {
5880                continue;
5881            };
5882            if row.relation().is_none() && row.omitted() > 0 {
5883                control.check(IndexWorkStage::SymbolParsing)?;
5884                persisted_coverage.insert(path.as_str().to_owned(), row.omitted());
5885            }
5886            if row.relation().is_none() && row.reached_limit() == Some(GraphLimitKind::Rows) {
5887                control.check(IndexWorkStage::SymbolParsing)?;
5888                persisted_rejection_details_dropped_by_path.insert(path.as_str().to_owned());
5889            }
5890        }
5891    }
5892    let mut persisted_fact_counts = BTreeMap::<String, BTreeSet<IdentityFactKey>>::new();
5893    for path in persisted_rejection_details_dropped_by_path {
5894        control.check(IndexWorkStage::SymbolParsing)?;
5895        if report
5896            .rejection_details_dropped_by_path
5897            .insert(path.clone())
5898        {
5899            report.reserve_reused_path_bytes(
5900                &path,
5901                false,
5902                control,
5903                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
5904            )?;
5905        }
5906    }
5907    for rejection in persisted_rejections {
5908        control.check(IndexWorkStage::SymbolParsing)?;
5909        let namespace = rejection.fact_index & !((1_u64 << 56) - 1);
5910        let path = rejection.path.as_str().to_owned();
5911        persisted_fact_counts
5912            .entry(path.clone())
5913            .or_default()
5914            .insert(IdentityFactKey {
5915                span: IdentitySpan {
5916                    start_line: rejection.span.start_line() as usize,
5917                    start_column: rejection.span.start_column() as usize,
5918                    end_line: rejection.span.end_line() as usize,
5919                    end_column: rejection.span.end_column() as usize,
5920                },
5921                parser: parser_fact_kind(rejection.parser),
5922                owner: u8::from(rejection.field == GraphIdentityField::ResolutionKey),
5923                fact_index: rejection.fact_index,
5924            });
5925        // Derived relations have their own admission report during projection;
5926        // hydrating them into the parser report would double-count coverage.
5927        if namespace == DERIVED_RELATION_FACT_INDEX_NAMESPACE {
5928            continue;
5929        }
5930        control.check(IndexWorkStage::SymbolParsing)?;
5931        let rejection_key_bytes = extend_bounded_identity_rejections_with_drop_paths(
5932            &mut report.rejections,
5933            &mut report.rejection_keys,
5934            std::iter::once(rejection),
5935            &mut report.rejection_details_dropped_by_path,
5936        )?;
5937        report.reserve_identity_bytes(
5938            rejection_key_bytes,
5939            control,
5940            MAX_IN_MEMORY_GRAPH_WORK_BYTES,
5941        )?;
5942    }
5943    for (path, facts) in persisted_fact_counts {
5944        control.check(IndexWorkStage::SymbolParsing)?;
5945        if !report.reused_rejection_facts.contains_key(&path) {
5946            report.reserve_identity_bytes(
5947                identity_fact_set_retained_bytes(&path, &facts)?,
5948                control,
5949                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
5950            )?;
5951            report.reused_rejection_facts.insert(path.clone(), facts);
5952        }
5953        let detail_count = u64::try_from(
5954            report
5955                .reused_rejection_facts
5956                .get(&path)
5957                .map_or(0, BTreeSet::len),
5958        )
5959        .map_err(|error| {
5960            CliError::InvalidInput(format!(
5961                "identity rejection detail count overflowed: {error}"
5962            ))
5963        })?;
5964        if !report.reused_rejection_detail_counts.contains_key(&path) {
5965            report.reserve_reused_path_bytes(
5966                &path,
5967                false,
5968                control,
5969                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
5970            )?;
5971            report
5972                .reused_rejection_detail_counts
5973                .insert(path, detail_count);
5974        }
5975    }
5976    for (path, omitted) in persisted_coverage {
5977        // Persisted path coverage is authoritative for safely reused graphs,
5978        // including structural/fallback rows whose typed details were evicted
5979        // by the global rejection-detail ceiling.
5980        control.check(IndexWorkStage::SymbolParsing)?;
5981        if !report.reused_rejection_counts.contains_key(&path) {
5982            report.reserve_reused_path_bytes(
5983                &path,
5984                false,
5985                control,
5986                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
5987            )?;
5988            report.reused_rejection_counts.insert(path, omitted);
5989        }
5990    }
5991    let graph_parsers = graphs
5992        .iter()
5993        .map(|graph| {
5994            let graph = Borrow::<SymbolGraph>::borrow(graph);
5995            (graph.path.as_str(), graph.parser)
5996        })
5997        .collect::<BTreeMap<_, _>>();
5998    for path in reused_paths {
5999        let Some(_omitted) = report.reused_rejection_counts.get(path).copied() else {
6000            continue;
6001        };
6002        let details = report
6003            .reused_rejection_detail_counts
6004            .get(path)
6005            .copied()
6006            .unwrap_or(0);
6007        let parser = graph_parsers.get(path.as_str()).copied();
6008        let baseline = parser.map_or(0, |parser| {
6009            u64::from(matches!(
6010                parser,
6011                ParserKind::Structural | ParserKind::Fallback
6012            ))
6013        });
6014        if details == 0 && baseline > 0 {
6015            // A structural/fallback path with no retained typed identity fact
6016            // still owns its coarse parser omission. Keep that provenance out
6017            // of identity reconciliation so unchanged graphs retain the same
6018            // covered count as a clean parse.
6019            if !report.reused_parser_rejection_counts.contains_key(path) {
6020                report.reserve_reused_path_bytes(
6021                    path,
6022                    false,
6023                    control,
6024                    MAX_IN_MEMORY_GRAPH_WORK_BYTES,
6025                )?;
6026                report
6027                    .reused_parser_rejection_counts
6028                    .insert(path.clone(), baseline);
6029            }
6030        }
6031        // Fallback rows intentionally carry one coarse parser omission and do
6032        // not re-derive identity details. Structural and exact parsers must
6033        // be reparsed when a capped persisted row set cannot identify every
6034        // old fact without guessing.
6035        if parser != Some(ParserKind::Fallback)
6036            && report.rejection_details_dropped_for(path)
6037            && !report.reused_rejection_details_incomplete.contains(path)
6038        {
6039            report.reserve_reused_path_bytes(
6040                path,
6041                false,
6042                control,
6043                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
6044            )?;
6045            report
6046                .reused_rejection_details_incomplete
6047                .insert(path.clone());
6048        }
6049    }
6050    let retained_bytes =
6051        checked_identity_admission_budget(report, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
6052    report.observed_fact_bytes = retained_bytes;
6053    Ok(())
6054}
6055
6056/// Overlay staged symbol changes on persisted graphs for exact selected paths.
6057fn complete_symbol_graphs<'a>(
6058    store: &AtlasStore,
6059    paths: &BTreeSet<String>,
6060    symbols: &'a SymbolBuildStage,
6061    control: &IndexWorkControl,
6062) -> Result<Vec<Cow<'a, SymbolGraph>>, CliError> {
6063    let paths = paths.iter().cloned().collect::<Vec<_>>();
6064    let mut graphs = BTreeMap::new();
6065    for chunk in paths.chunks(PERSISTED_GRAPH_PATHS_PER_CHUNK) {
6066        control.check(IndexWorkStage::SymbolParsing)?;
6067        for graph in store.load_symbol_graphs_for_paths(chunk)? {
6068            graphs.insert(graph.path.clone(), Cow::Owned(graph));
6069        }
6070    }
6071    for (index, change) in symbols.changes.iter().enumerate() {
6072        check_graph_work(control, index)?;
6073        match change {
6074            SymbolProjectionChange::Parsed(parsed) if paths.binary_search(&parsed.path).is_ok() => {
6075                graphs.insert(parsed.path.clone(), Cow::Borrowed(&parsed.graph));
6076            }
6077            SymbolProjectionChange::Clear { path, .. } if paths.binary_search(path).is_ok() => {
6078                graphs.remove(path);
6079            }
6080            SymbolProjectionChange::Parsed(_) | SymbolProjectionChange::Clear { .. } => {}
6081        }
6082    }
6083    graphs.retain(|path, _graph| paths.binary_search(path).is_ok());
6084    Ok(graphs.into_values().collect())
6085}
6086
6087/// Admit parser graphs at one shared boundary before any strict graph object is built.
6088fn admit_symbol_graphs<'a>(
6089    graphs: Vec<Cow<'a, SymbolGraph>>,
6090    control: &IndexWorkControl,
6091) -> Result<(Vec<Cow<'a, SymbolGraph>>, GraphIdentityAdmission), CliError> {
6092    let mut admitted = Vec::with_capacity(graphs.len());
6093    let mut report = GraphIdentityAdmission::default();
6094    for (index, graph) in graphs.into_iter().enumerate() {
6095        check_graph_work(control, index)?;
6096        let (graph, graph_report) = admit_symbol_graph(graph, control)?;
6097        report.merge(graph_report, control)?;
6098        admitted.push(graph);
6099    }
6100    Ok((admitted, report))
6101}
6102
6103/// Retain valid parser facts while recording every invalid identity field.
6104fn admit_symbol_graph<'a>(
6105    graph: Cow<'a, SymbolGraph>,
6106    control: &IndexWorkControl,
6107) -> Result<(Cow<'a, SymbolGraph>, GraphIdentityAdmission), CliError> {
6108    let mut report = GraphIdentityAdmission::default();
6109    let paired_import_relations = paired_import_relations(&graph, control)?;
6110    #[cfg(test)]
6111    {
6112        report.paired_import_pairing_work = paired_import_relations.work_items;
6113    }
6114    let mut rejected_symbols = vec![false; graph.symbols.len()];
6115    for (index, symbol) in graph.symbols.iter().enumerate() {
6116        check_graph_work(control, index)?;
6117        let paired_relation_index = paired_import_relations.by_symbol[index];
6118        let span = symbol_identity_span(&graph, index, paired_relation_index);
6119        let mut failures = Vec::new();
6120        let name_field = if symbol.kind == SymbolKind::Package {
6121            GraphIdentityField::Package
6122        } else {
6123            GraphIdentityField::Symbol
6124        };
6125        record_identity_failure(&mut failures, name_field, &symbol.name);
6126        if let Some(parent) = symbol.parent.as_deref() {
6127            record_identity_failure(&mut failures, GraphIdentityField::Parent, parent);
6128        }
6129        let signature = if symbol.signature.is_empty() {
6130            &symbol.name
6131        } else {
6132            &symbol.signature
6133        };
6134        record_identity_failure(&mut failures, GraphIdentityField::Signature, signature);
6135        if !failures.is_empty() {
6136            rejected_symbols[index] = true;
6137            report.record(
6138                &graph.path,
6139                span,
6140                symbol.parser,
6141                symbol_parser_fact_index(index, paired_relation_index),
6142                &failures,
6143                control,
6144            )?;
6145        }
6146    }
6147    let mut rejected_relations = vec![false; graph.relations.len()];
6148    for (index, relation) in graph.relations.iter().enumerate() {
6149        check_graph_work(control, index)?;
6150        let span = IdentitySpan {
6151            start_line: relation.line.max(1),
6152            start_column: 0,
6153            end_line: relation.line.max(1),
6154            end_column: 0,
6155        };
6156        let mut failures = Vec::new();
6157        record_identity_failure(
6158            &mut failures,
6159            GraphIdentityField::RelationSource,
6160            &relation.source_name,
6161        );
6162        record_identity_failure(
6163            &mut failures,
6164            GraphIdentityField::RelationTarget,
6165            &relation.target_name,
6166        );
6167        if !failures.is_empty() {
6168            rejected_relations[index] = true;
6169            report.record(
6170                &graph.path,
6171                span,
6172                relation.parser,
6173                parser_fact_index(RELATION_FACT_INDEX_NAMESPACE, index),
6174                &failures,
6175                control,
6176            )?;
6177        }
6178    }
6179    if report.rejected_facts_by_path.is_empty() {
6180        return Ok((graph, report));
6181    }
6182    let mut graph = graph.into_owned();
6183    graph.symbols = graph
6184        .symbols
6185        .into_iter()
6186        .enumerate()
6187        .filter_map(|(index, symbol)| (!rejected_symbols[index]).then_some(symbol))
6188        .collect();
6189    let mut relation_fact_indices = Vec::with_capacity(graph.relations.len());
6190    graph.relations = graph
6191        .relations
6192        .into_iter()
6193        .enumerate()
6194        .filter_map(|(index, relation)| {
6195            (!rejected_relations[index]).then(|| {
6196                relation_fact_indices.push(index);
6197                relation
6198            })
6199        })
6200        .collect();
6201    if rejected_relations.iter().any(|rejected| *rejected) && !relation_fact_indices.is_empty() {
6202        report
6203            .relation_fact_indices
6204            .insert(graph.path.clone(), relation_fact_indices);
6205    }
6206    Ok((Cow::Owned(graph), report))
6207}
6208
6209/// Sanitize parser output before any summary, persistence, or graph projection sink.
6210pub(super) fn admit_symbol_build_stage(
6211    staged: &mut SymbolBuildStage,
6212    control: &IndexWorkControl,
6213) -> Result<GraphIdentityAdmission, CliError> {
6214    let mut report = GraphIdentityAdmission::default();
6215    for (index, change) in staged.changes.iter_mut().enumerate() {
6216        check_graph_work(control, index)?;
6217        let SymbolProjectionChange::Parsed(parsed) = change else {
6218            continue;
6219        };
6220        let placeholder = SymbolGraph {
6221            path: parsed.path.clone(),
6222            language: None,
6223            parser: parsed.source_parser,
6224            symbols: Vec::new(),
6225            relations: Vec::new(),
6226        };
6227        let graph = std::mem::replace(&mut parsed.graph, placeholder);
6228        let (admitted, graph_report) = admit_symbol_graph(Cow::Owned(graph), control)?;
6229        parsed.graph = admitted.into_owned();
6230        if let Some(markdown) = parsed.markdown_facts.as_deref_mut() {
6231            admit_markdown_fact_batch(
6232                &parsed.path,
6233                parsed.source_parser,
6234                markdown,
6235                &mut report,
6236                control,
6237            )?;
6238        }
6239        if graph_report.has_rejections() {
6240            // Parser summaries and generated suggestions are sinks too. Rebuild both
6241            // from the admitted graph so rejected identity text cannot survive there.
6242            parsed.summary = super::summarize_symbol_graph(&parsed.graph, None);
6243            parsed.summary_is_structural = false;
6244            if parsed.purpose_suggestion.is_some() {
6245                parsed.purpose_suggestion =
6246                    Some(super::suggest_file_purpose(&parsed.path, &parsed.summary));
6247            }
6248        }
6249        report.merge(graph_report, control)?;
6250    }
6251    let mut symbols = 0_usize;
6252    let mut relations = 0_usize;
6253    for change in &staged.changes {
6254        let SymbolProjectionChange::Parsed(parsed) = change else {
6255            continue;
6256        };
6257        symbols = symbols
6258            .checked_add(parsed.graph.symbols.len())
6259            .ok_or_else(|| {
6260                CliError::InvalidInput("admitted symbol report count overflowed".to_string())
6261            })?;
6262        relations = relations
6263            .checked_add(parsed.graph.relations.len())
6264            .ok_or_else(|| {
6265                CliError::InvalidInput("admitted relation report count overflowed".to_string())
6266            })?;
6267    }
6268    staged.report.symbols = symbols;
6269    staged.report.relations = relations;
6270    report.source_admitted = true;
6271    Ok(report)
6272}
6273
6274/// Validate one source identity without retaining the rejected raw value.
6275fn record_identity_failure(
6276    failures: &mut Vec<(GraphIdentityField, GraphIdentityRejectionReason)>,
6277    field: GraphIdentityField,
6278    value: &str,
6279) {
6280    if let Err(error) = source_symbol_identity_error(value) {
6281        failures.push((field, GraphIdentityRejectionReason::from_error(&error)));
6282    }
6283}
6284
6285/// Validate one source identity without allocating on the valid hot path.
6286fn source_symbol_identity_error(value: &str) -> Result<(), GraphContractError> {
6287    GraphIdentityText::validate(value)?;
6288    if value.starts_with(QUALIFIED_SYMBOL_SCOPE_PREFIX) {
6289        return Err(GraphContractError::InvalidIdentityText {
6290            reason: "source symbol identity uses the reserved derived-scope namespace",
6291        });
6292    }
6293    Ok(())
6294}
6295
6296/// Validate derived canonical keys before the entity projection requests them.
6297fn admit_resolution_key_failures(
6298    project: ProjectInstanceId,
6299    generation: IndexGeneration,
6300    graphs: &[impl Borrow<SymbolGraph>],
6301    packages: &PackageIndex,
6302    configured_modules: &ConfiguredModuleResolution,
6303    report: &mut GraphIdentityAdmission,
6304    control: &IndexWorkControl,
6305) -> Result<(), CliError> {
6306    for (index, graph) in graphs.iter().enumerate() {
6307        let graph = graph.borrow();
6308        check_graph_work(control, index)?;
6309        #[cfg(not(test))]
6310        let _ = generation;
6311        if report.resolution_projections.contains_key(&graph.path) {
6312            return Err(CliError::InvalidInput(
6313                "duplicate resolution projection admission".to_string(),
6314            ));
6315        }
6316        #[cfg(test)]
6317        report.record_resolution_derivation(&graph.path, generation);
6318        let context = ResolutionProjectionContext::with_configured_modules(configured_modules);
6319        match derive_resolution_keys_with_context(
6320            project,
6321            packages.package_name(&graph.path),
6322            graph,
6323            context,
6324        ) {
6325            Ok(projection) => {
6326                report
6327                    .resolution_projections
6328                    .insert(graph.path.clone(), projection);
6329            }
6330            Err(ResolutionProjectionError::KeyLimit { requested, .. }) => {
6331                return Err(resolution_key_limit_failure(requested));
6332            }
6333            Err(ResolutionProjectionError::Contract(failure)) => {
6334                let (failures, rejected_count, projection) = (*failure).into_parts();
6335                for failure in &failures {
6336                    report.record(
6337                        &graph.path,
6338                        resolution_projection_span(graph, failure.fact()),
6339                        graph.parser,
6340                        resolution_projection_fact_index(report, &graph.path, failure.fact()),
6341                        &[(
6342                            GraphIdentityField::ResolutionKey,
6343                            GraphIdentityRejectionReason::from_error(failure.error()),
6344                        )],
6345                        control,
6346                    )?;
6347                }
6348                report.record_rejected_fact_count(
6349                    &graph.path,
6350                    rejected_count.saturating_sub(failures.len()),
6351                    control,
6352                )?;
6353                report
6354                    .resolution_projections
6355                    .insert(graph.path.clone(), projection);
6356            }
6357        }
6358    }
6359    Ok(())
6360}
6361
6362/// Require one admitted resolution projection for every graph being published.
6363fn ensure_admitted_resolution_projections(
6364    graphs: &[impl Borrow<SymbolGraph>],
6365    projections: &BTreeMap<String, ResolutionKeyProjection>,
6366) -> Result<(), CliError> {
6367    if projections.len() != graphs.len()
6368        || graphs
6369            .iter()
6370            .any(|graph| !projections.contains_key(&graph.borrow().path))
6371    {
6372        return Err(CliError::InvalidInput(
6373            "resolution projection admission did not match staged graphs".to_string(),
6374        ));
6375    }
6376    Ok(())
6377}
6378
6379/// Map one resolution fact into its deterministic internal rejection identity.
6380fn resolution_projection_fact_index(
6381    report: &GraphIdentityAdmission,
6382    path: &str,
6383    fact: ResolutionProjectionFact,
6384) -> u64 {
6385    match fact {
6386        ResolutionProjectionFact::Source => 0,
6387        ResolutionProjectionFact::Symbol(index) => {
6388            parser_fact_index(SYMBOL_FACT_INDEX_NAMESPACE, index)
6389        }
6390        ResolutionProjectionFact::Relation(index) => parser_fact_index(
6391            RELATION_FACT_INDEX_NAMESPACE,
6392            report.relation_parser_index(path, index),
6393        ),
6394    }
6395}
6396
6397/// Select the narrowest parser span available for one rejected derived key.
6398fn resolution_projection_span(graph: &SymbolGraph, fact: ResolutionProjectionFact) -> IdentitySpan {
6399    match fact {
6400        ResolutionProjectionFact::Source => graph_identity_span(graph),
6401        ResolutionProjectionFact::Symbol(index) => graph.symbols.get(index).map_or_else(
6402            || graph_identity_span(graph),
6403            |symbol| IdentitySpan {
6404                start_line: symbol.line_start.max(1),
6405                start_column: 0,
6406                end_line: symbol.line_end.max(symbol.line_start).max(1),
6407                end_column: 0,
6408            },
6409        ),
6410        ResolutionProjectionFact::Relation(index) => graph.relations.get(index).map_or_else(
6411            || graph_identity_span(graph),
6412            |relation| IdentitySpan {
6413                start_line: relation.line.max(1),
6414                start_column: 0,
6415                end_line: relation.line.max(1),
6416                end_column: 0,
6417            },
6418        ),
6419    }
6420}
6421
6422/// Bound one graph-wide rejection span when canonical-key derivation has no fact index.
6423fn graph_identity_span(graph: &SymbolGraph) -> IdentitySpan {
6424    let mut start_line = usize::MAX;
6425    let mut end_line = 1;
6426    for symbol in &graph.symbols {
6427        start_line = start_line.min(symbol.line_start.max(1));
6428        end_line = end_line.max(symbol.line_end.max(symbol.line_start).max(1));
6429    }
6430    for relation in &graph.relations {
6431        start_line = start_line.min(relation.line.max(1));
6432        end_line = end_line.max(relation.line.max(1));
6433    }
6434    IdentitySpan {
6435        start_line: if start_line == usize::MAX {
6436            1
6437        } else {
6438            start_line
6439        },
6440        start_column: 0,
6441        end_line,
6442        end_column: 0,
6443    }
6444}
6445
6446/// Admit parser-owned Markdown selectors before document-key derivation.
6447fn admit_markdown_facts(
6448    facts: &mut BTreeMap<String, Cow<'_, MarkdownFacts>>,
6449    graphs: &[impl Borrow<SymbolGraph>],
6450    report: &mut GraphIdentityAdmission,
6451    control: &IndexWorkControl,
6452) -> Result<(), CliError> {
6453    for (graph_index, graph) in graphs.iter().enumerate() {
6454        check_graph_work(control, graph_index)?;
6455        let graph = graph.borrow();
6456        let Some(markdown) = facts.get_mut(&graph.path) else {
6457            continue;
6458        };
6459        if !markdown
6460            .link_candidates
6461            .iter()
6462            .any(|candidate| GraphIdentityText::validate(&candidate.selector).is_err())
6463        {
6464            continue;
6465        }
6466        admit_markdown_fact_batch(
6467            &graph.path,
6468            graph.parser,
6469            markdown.to_mut(),
6470            report,
6471            control,
6472        )?;
6473    }
6474    Ok(())
6475}
6476
6477/// Admit one parser-owned Markdown fact batch before document-key derivation.
6478fn admit_markdown_fact_batch(
6479    path: &str,
6480    parser: ParserKind,
6481    markdown: &mut MarkdownFacts,
6482    report: &mut GraphIdentityAdmission,
6483    control: &IndexWorkControl,
6484) -> Result<(), CliError> {
6485    let mut rejected = Vec::new();
6486    for (candidate_index, candidate) in markdown.link_candidates.iter().enumerate() {
6487        check_graph_work(control, candidate_index)?;
6488        if let Err(error) = GraphIdentityText::validate(&candidate.selector) {
6489            rejected.push((
6490                candidate_index,
6491                candidate.source.line_start,
6492                candidate.source.column_start,
6493                candidate.source.line_end,
6494                candidate.source.column_end,
6495                GraphIdentityRejectionReason::from_error(&error),
6496            ));
6497        }
6498    }
6499    for (candidate_index, start_line, start_column, end_line, end_column, reason) in rejected {
6500        report.record(
6501            path,
6502            IdentitySpan {
6503                start_line,
6504                start_column,
6505                end_line,
6506                end_column,
6507            },
6508            parser,
6509            parser_fact_index(MARKDOWN_FACT_INDEX_NAMESPACE, candidate_index),
6510            &[(GraphIdentityField::RelationTarget, reason)],
6511            control,
6512        )?;
6513    }
6514    markdown
6515        .link_candidates
6516        .retain(|candidate| GraphIdentityText::validate(&candidate.selector).is_ok());
6517    Ok(())
6518}
6519
6520/// Overlay staged Markdown facts and parse only persisted graphs not parsed in this operation.
6521fn complete_markdown_facts<'a>(
6522    root: &Path,
6523    nodes: &[Node],
6524    graphs: &[impl Borrow<SymbolGraph>],
6525    symbols: &'a SymbolBuildStage,
6526    control: &IndexWorkControl,
6527) -> Result<BTreeMap<String, Cow<'a, MarkdownFacts>>, CliError> {
6528    let graph_paths = graphs
6529        .iter()
6530        .map(|graph| graph.borrow().path.as_str())
6531        .collect::<BTreeSet<_>>();
6532    let nodes_by_path = nodes
6533        .iter()
6534        .filter(|node| node.kind == NodeKind::File)
6535        .map(|node| (node.path.as_str(), node))
6536        .collect::<BTreeMap<_, _>>();
6537    let mut facts = BTreeMap::new();
6538    for change in &symbols.changes {
6539        let SymbolProjectionChange::Parsed(parsed) = change else {
6540            continue;
6541        };
6542        if graph_paths.contains(parsed.path.as_str())
6543            && let Some(markdown) = parsed.markdown_facts.as_ref()
6544        {
6545            facts.insert(parsed.path.clone(), Cow::Borrowed(markdown.as_ref()));
6546        }
6547    }
6548    for graph in graphs {
6549        let graph = graph.borrow();
6550        control.check(IndexWorkStage::SymbolParsing)?;
6551        if facts.contains_key(&graph.path)
6552            || !graph
6553                .language
6554                .as_deref()
6555                .and_then(language_capability)
6556                .is_some_and(|capability| capability.symbol_parser == SymbolParserOwner::Markdown)
6557        {
6558            continue;
6559        }
6560        let node = nodes_by_path.get(graph.path.as_str()).ok_or_else(|| {
6561            CliError::InvalidInput(format!(
6562                "Markdown graph path was absent from the staged file inventory: {}",
6563                graph.path
6564            ))
6565        })?;
6566        let native_path = root.join(Path::new(&graph.path));
6567        let bytes = match read_source_bytes_controlled(
6568            &native_path,
6569            MAX_SYMBOL_FILE_BYTES,
6570            IndexWorkStage::SymbolParsing,
6571            control,
6572        ) {
6573            Ok(bytes) => bytes,
6574            Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
6575            Err(SourceReadFailure::LimitExceeded { .. }) => {
6576                return Err(source_changed_during_derivation(root, &graph.path));
6577            }
6578            Err(SourceReadFailure::Io(source)) => {
6579                return Err(CliError::Io {
6580                    path: native_path,
6581                    source,
6582                });
6583            }
6584        };
6585        if node
6586            .content_hash
6587            .as_deref()
6588            .is_none_or(|expected| blake3::hash(&bytes).to_hex().as_str() != expected)
6589        {
6590            return Err(source_changed_during_derivation(root, &graph.path));
6591        }
6592        let content = String::from_utf8(bytes)
6593            .map_err(|_source| source_changed_during_derivation(root, &graph.path))?;
6594        facts.insert(
6595            graph.path.clone(),
6596            Cow::Owned(extract_markdown_facts_controlled(&content, control)?),
6597        );
6598    }
6599    enforce_resolution_registry_budget(document_fact_map_retained_bytes(&facts))?;
6600    Ok(facts)
6601}
6602
6603/// Count map ownership plus evidence owned only for persisted graphs parsed on demand.
6604fn document_fact_map_retained_bytes(facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>) -> u64 {
6605    facts.iter().fold(0_u64, |bytes, (path, facts)| {
6606        let bytes = bytes
6607            .saturating_add(STAGED_GRAPH_ROW_BYTES)
6608            .saturating_add(path.len() as u64);
6609        if !matches!(facts, Cow::Owned(_)) {
6610            return bytes;
6611        }
6612        let heading_bytes = facts.headings.iter().fold(0_u64, |bytes, heading| {
6613            bytes
6614                .saturating_add(STAGED_GRAPH_ROW_BYTES)
6615                .saturating_add(heading.text.len() as u64)
6616                .saturating_add(heading.slug.len() as u64)
6617        });
6618        facts.link_candidates.iter().fold(
6619            bytes.saturating_add(heading_bytes),
6620            |bytes, candidate| {
6621                bytes
6622                    .saturating_add(STAGED_GRAPH_ROW_BYTES)
6623                    .saturating_add(candidate.selector.len() as u64)
6624                    .saturating_add(candidate.label.as_ref().map_or(0, String::len) as u64)
6625                    .saturating_add(
6626                        candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64
6627                    )
6628            },
6629        )
6630    })
6631}
6632
6633/// Count document projection state that can survive the per-candidate filters.
6634///
6635/// Same-file links without a fragment are discarded before resolution and never
6636/// enter the emitted relation, occurrence, dependency, or reason state. Keep
6637/// those candidates out of the preflight estimate so incremental admission and
6638/// the post-projection aggregate use the same ownership boundary.
6639fn document_projection_retained_bytes(
6640    facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
6641    control: &IndexWorkControl,
6642) -> Result<u64, CliError> {
6643    let mut retained_bytes = 0_u64;
6644    let mut candidate_index = 0_usize;
6645    for (document_path, facts) in facts {
6646        for candidate in &facts.link_candidates {
6647            check_graph_work(control, candidate_index)?;
6648            candidate_index = candidate_index.saturating_add(1);
6649            if normalize_document_target(document_path, &candidate.selector).is_ok_and(|target| {
6650                target.path == document_path.as_str() && target.fragment.is_none()
6651            }) {
6652                continue;
6653            }
6654            retained_bytes = retained_bytes
6655                .saturating_add(DOCUMENT_PROJECTION_ROW_BYTES)
6656                .saturating_add(candidate.selector.len() as u64)
6657                .saturating_add(candidate.label.as_ref().map_or(0, String::len) as u64)
6658                .saturating_add(candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64);
6659        }
6660    }
6661    Ok(retained_bytes)
6662}
6663
6664/// One normalized repository-local document target and optional heading fragment.
6665#[derive(Clone, Debug, Eq, PartialEq)]
6666struct DocumentTargetIdentity {
6667    /// Exact slash-separated path under the selected root.
6668    path: String,
6669    /// Static fragment retained only when it can address a heading export.
6670    fragment: Option<String>,
6671}
6672
6673/// Normalize one parser-admitted selector relative to its owning document.
6674fn normalize_document_target(
6675    document_path: &str,
6676    selector: &str,
6677) -> Result<DocumentTargetIdentity, DocumentTargetUnresolvedReason> {
6678    let (path_and_query, fragment) = selector
6679        .split_once('#')
6680        .map_or((selector, None), |(path, fragment)| (path, Some(fragment)));
6681    let path = path_and_query
6682        .split_once('?')
6683        .map_or(path_and_query, |(path, _query)| path);
6684    let path = strip_document_line_selector(path);
6685    if path.is_empty() {
6686        return Err(DocumentTargetUnresolvedReason::NoStaticTarget);
6687    }
6688    let mut components = document_path
6689        .rsplit_once('/')
6690        .map_or_else(Vec::new, |(parent, _file)| {
6691            parent.split('/').map(str::to_owned).collect::<Vec<_>>()
6692        });
6693    for component in path.split('/') {
6694        match component {
6695            "" => return Err(DocumentTargetUnresolvedReason::Unsupported),
6696            "." => {}
6697            ".." => {
6698                if components.pop().is_none() {
6699                    return Err(DocumentTargetUnresolvedReason::OutsideRoot);
6700                }
6701            }
6702            value if value.contains(':') => {
6703                return Err(DocumentTargetUnresolvedReason::Unsupported);
6704            }
6705            value => components.push(value.to_owned()),
6706        }
6707    }
6708    if components.is_empty() {
6709        return Err(DocumentTargetUnresolvedReason::NoStaticTarget);
6710    }
6711    let fragment = match fragment.map(str::trim) {
6712        None => None,
6713        Some(value)
6714            if !value.is_empty()
6715                && !value.contains(['/', '\\', '?', '#', '{', '}', '<', '>', '|', '*', '$'])
6716                && !value.chars().any(char::is_whitespace) =>
6717        {
6718            Some(value.to_lowercase())
6719        }
6720        Some(_value) => return Err(DocumentTargetUnresolvedReason::NoStaticTarget),
6721    };
6722    Ok(DocumentTargetIdentity {
6723        path: components.join("/"),
6724        fragment,
6725    })
6726}
6727
6728/// Remove one supported `:12`, `:L12-L20`, or equivalent line selector.
6729fn strip_document_line_selector(path: &str) -> &str {
6730    let Some((identity, selector)) = path.rsplit_once(':') else {
6731        return path;
6732    };
6733    let selector = selector.strip_prefix('L').unwrap_or(selector);
6734    let valid = selector.split_once('-').map_or_else(
6735        || !selector.is_empty() && selector.chars().all(|character| character.is_ascii_digit()),
6736        |(start, end)| {
6737            !start.is_empty()
6738                && !end.is_empty()
6739                && start.chars().all(|character| character.is_ascii_digit())
6740                && end
6741                    .strip_prefix('L')
6742                    .unwrap_or(end)
6743                    .chars()
6744                    .all(|character| character.is_ascii_digit())
6745        },
6746    );
6747    if valid { identity } else { path }
6748}
6749
6750/// Construct one exact file identity through the existing module-key domain.
6751fn document_file_resolution_key(
6752    project: ProjectInstanceId,
6753    path: &str,
6754) -> Result<CanonicalResolutionKey, CliError> {
6755    document_resolution_key(project, ResolutionKeyDomain::Module, path)
6756}
6757
6758/// Construct one case-fold collision key through the existing module-key domain.
6759fn document_casefold_resolution_key(
6760    project: ProjectInstanceId,
6761    path: &str,
6762) -> Result<CanonicalResolutionKey, CliError> {
6763    document_resolution_key_with_language(
6764        project,
6765        ResolutionKeyDomain::Module,
6766        DOCUMENT_CASEFOLD_LANGUAGE,
6767        &path.to_lowercase(),
6768    )
6769}
6770
6771/// Construct one exact Markdown heading identity through the declaration-key domain.
6772fn document_heading_resolution_key(
6773    project: ProjectInstanceId,
6774    path: &str,
6775    fragment: &str,
6776) -> Result<CanonicalResolutionKey, CliError> {
6777    document_resolution_key(
6778        project,
6779        ResolutionKeyDomain::Declaration,
6780        &format!("{path}#{fragment}"),
6781    )
6782}
6783
6784/// Construct one project-qualified canonical document identity without a new key family.
6785fn document_resolution_key(
6786    project: ProjectInstanceId,
6787    domain: ResolutionKeyDomain,
6788    identity: &str,
6789) -> Result<CanonicalResolutionKey, CliError> {
6790    document_resolution_key_with_language(project, domain, DOCUMENT_PATH_LANGUAGE, identity)
6791}
6792
6793/// Construct one project-qualified document key under a selected resolver language.
6794fn document_resolution_key_with_language(
6795    project: ProjectInstanceId,
6796    domain: ResolutionKeyDomain,
6797    resolver_language: &str,
6798    identity: &str,
6799) -> Result<CanonicalResolutionKey, CliError> {
6800    let provider =
6801        GraphIdentityText::new(DOCUMENT_PATH_PROVIDER).map_err(invalid_graph_contract)?;
6802    let language = GraphIdentityText::new(resolver_language).map_err(invalid_graph_contract)?;
6803    let identity = GraphIdentityText::new(identity).map_err(invalid_graph_contract)?;
6804    Ok(CanonicalResolutionKey::new(
6805        project,
6806        domain,
6807        &provider,
6808        &language,
6809        None,
6810        None,
6811        Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
6812        &identity,
6813    ))
6814}
6815
6816/// Return all exact path and heading keys that can affect staged document relations.
6817fn document_dependency_keys(
6818    project: ProjectInstanceId,
6819    facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
6820) -> Result<BTreeSet<CanonicalResolutionKey>, CliError> {
6821    let mut keys = BTreeSet::new();
6822    for (document_path, facts) in facts {
6823        for candidate in &facts.link_candidates {
6824            let Ok(target) = normalize_document_target(document_path, &candidate.selector) else {
6825                continue;
6826            };
6827            keys.insert(document_file_resolution_key(project, &target.path)?);
6828            keys.insert(document_casefold_resolution_key(project, &target.path)?);
6829            if let Some(fragment) = target.fragment {
6830                keys.insert(document_heading_resolution_key(
6831                    project,
6832                    &target.path,
6833                    &fragment,
6834                )?);
6835            }
6836        }
6837    }
6838    Ok(keys)
6839}
6840
6841/// Derive canonical resolution keys with repository compiler configuration.
6842fn resolution_projection_with_config(
6843    project: ProjectInstanceId,
6844    package: Option<&str>,
6845    graph: &SymbolGraph,
6846    configured_modules: &ConfiguredModuleResolution,
6847) -> Result<ResolutionKeyProjection, CliError> {
6848    let context = ResolutionProjectionContext::with_configured_modules(configured_modules);
6849    match derive_resolution_keys_with_context(project, package, graph, context) {
6850        Ok(projection) => Ok(projection),
6851        Err(ResolutionProjectionError::KeyLimit { requested, .. }) => {
6852            Err(resolution_key_limit_failure(requested))
6853        }
6854        Err(ResolutionProjectionError::Contract(failure)) => {
6855            let (mut failures, _rejected_count, _projection) = (*failure).into_parts();
6856            let Some(failure) = failures.pop() else {
6857                return Err(CliError::InvalidInput(
6858                    "resolution projection reported an empty contract failure".to_string(),
6859                ));
6860            };
6861            let (_fact, error) = failure.into_parts();
6862            Err(invalid_graph_contract(error))
6863        }
6864    }
6865}
6866
6867/// Preserve resource-limit failures while admitting contract-invalid key facts.
6868fn resolution_key_limit_failure(requested: usize) -> CliError {
6869    IndexWorkFailure::resource_limit(
6870        IndexWorkStage::SymbolParsing,
6871        IndexWorkResource::RelationRows,
6872        u64::try_from(MAX_RESOLUTION_KEYS_PER_FACT).unwrap_or(u64::MAX),
6873        u64::try_from(requested).unwrap_or(u64::MAX),
6874    )
6875    .into()
6876}
6877
6878/// Load the project identity required by normalized graph projection.
6879fn selected_project(store: &AtlasStore) -> Result<ProjectInstanceId, CliError> {
6880    store.project_instance_id()?.ok_or_else(|| {
6881        CliError::InvalidInput("repository graph requires a bound project identity".to_string())
6882    })
6883}
6884
6885/// Return the pending graph generation after one successful publication.
6886fn next_generation(base: IndexGeneration) -> Result<IndexGeneration, CliError> {
6887    base.checked_next().ok_or_else(|| {
6888        CliError::InvalidInput("repository graph generation is exhausted".to_string())
6889    })
6890}
6891
6892/// Insert one stable entity while rejecting digest collisions.
6893fn insert_entity(
6894    entities: &mut BTreeMap<String, GraphEntity>,
6895    entity: GraphEntity,
6896) -> Result<(), CliError> {
6897    let digest = entity.key().digest().to_string();
6898    if let Some(existing) = entities.get(&digest) {
6899        if !existing
6900            .key()
6901            .reconcile(entity.key())
6902            .map_err(invalid_graph_contract)?
6903        {
6904            return Err(CliError::InvalidInput(
6905                "graph entity digest retained conflicting ownership".to_string(),
6906            ));
6907        }
6908        return Ok(());
6909    }
6910    entities.insert(digest, entity);
6911    Ok(())
6912}
6913
6914/// Sort and deduplicate entity export bindings by canonical identity and owner.
6915fn sort_dedup_exports(exports: &mut Vec<EntityResolutionKey>) {
6916    exports.sort_by(|left, right| {
6917        left.key()
6918            .cmp(right.key())
6919            .then_with(|| left.entity().digest().cmp(right.entity().digest()))
6920    });
6921    exports.dedup();
6922}
6923
6924/// Sort and deduplicate relation dependency bindings by canonical key and owner.
6925fn sort_dedup_dependencies(dependencies: &mut Vec<RelationDependencyKey>) {
6926    dependencies.sort_by(|left, right| {
6927        left.key()
6928            .cmp(right.key())
6929            .then_with(|| left.relation().digest().cmp(right.relation().digest()))
6930    });
6931    dependencies.dedup();
6932}
6933
6934/// Enforce the complete-projection ceiling for canonical key bindings.
6935fn enforce_key_binding_limit(count: usize) -> Result<(), CliError> {
6936    let observed = u64::try_from(count).unwrap_or(u64::MAX);
6937    if observed > MAX_GRAPH_KEY_BINDINGS {
6938        return Err(IndexWorkFailure::resource_limit(
6939            IndexWorkStage::SymbolParsing,
6940            IndexWorkResource::RelationRows,
6941            MAX_GRAPH_KEY_BINDINGS,
6942            observed,
6943        )
6944        .into());
6945    }
6946    Ok(())
6947}
6948
6949/// Enforce one path/key count before incremental graph work expands further.
6950fn enforce_incremental_count<T>(
6951    root: &Path,
6952    _context: &'static str,
6953    count: usize,
6954    sample: &BTreeSet<T>,
6955) -> Result<(), CliError>
6956where
6957    T: ToString + Ord,
6958{
6959    if count > MAX_INCREMENTAL_RESOLUTION_ITEMS as usize {
6960        return Err(dependency_closure_limit(
6961            root,
6962            sample.iter().map(ToString::to_string),
6963            count,
6964        ));
6965    }
6966    Ok(())
6967}
6968
6969/// Load and admit the persisted rows that an incremental replacement will remove.
6970fn admitted_persisted_footprint(
6971    store: &AtlasStore,
6972    project: ProjectInstanceId,
6973    root: &Path,
6974    affected_paths: &BTreeSet<String>,
6975    control: &IndexWorkControl,
6976) -> Result<RepositoryAffectedSourceFootprint, CliError> {
6977    control.check(IndexWorkStage::SymbolParsing)?;
6978    let footprint = store.repository_affected_source_footprint(
6979        project,
6980        &affected_paths.iter().cloned().collect::<Vec<_>>(),
6981        u32::try_from(MAX_INCREMENTAL_GRAPH_ROWS).unwrap_or(u32::MAX),
6982    )?;
6983    control.check(IndexWorkStage::SymbolParsing)?;
6984    if footprint.truncated {
6985        return Err(dependency_closure_limit(
6986            root,
6987            affected_paths.iter().cloned(),
6988            usize::try_from(footprint.rows).unwrap_or(usize::MAX),
6989        ));
6990    }
6991    enforce_incremental_projection_budget(
6992        root,
6993        affected_paths,
6994        footprint.rows,
6995        footprint.retained_bytes,
6996    )?;
6997    Ok(footprint)
6998}
6999
7000/// Enforce aggregate old-removal and new-insertion work for one closure.
7001fn enforce_incremental_projection_limits(
7002    root: &Path,
7003    affected_paths: &BTreeSet<String>,
7004    persisted: RepositoryAffectedSourceFootprint,
7005    staged: &StagedRepositoryGraph,
7006) -> Result<(), CliError> {
7007    let staged_rows = [
7008        affected_paths.len(),
7009        staged.entities.len(),
7010        staged.relations.len(),
7011        staged.occurrences.len(),
7012        staged.coverage.len(),
7013        staged.entity_exports.len(),
7014        staged.relation_dependencies.len(),
7015        staged.document_unresolved_reasons.len(),
7016    ]
7017    .into_iter()
7018    .fold(0_u64, |total, count| {
7019        total.saturating_add(u64::try_from(count).unwrap_or(u64::MAX))
7020    });
7021    enforce_incremental_projection_budget(
7022        root,
7023        affected_paths,
7024        persisted.rows.saturating_add(staged_rows),
7025        persisted
7026            .retained_bytes
7027            .saturating_add(staged.retained_bytes),
7028    )
7029}
7030
7031/// Return typed full-refresh guidance when an incremental projection exceeds a bound.
7032fn enforce_incremental_projection_budget(
7033    root: &Path,
7034    affected_paths: &BTreeSet<String>,
7035    rows: u64,
7036    retained_bytes: u64,
7037) -> Result<(), CliError> {
7038    if rows > MAX_INCREMENTAL_GRAPH_ROWS || retained_bytes > MAX_INCREMENTAL_GRAPH_BYTES {
7039        return Err(dependency_closure_limit(
7040            root,
7041            affected_paths.iter().cloned(),
7042            affected_paths.len(),
7043        ));
7044    }
7045    Ok(())
7046}
7047
7048/// Construct deterministic full-refresh guidance for an oversized dependency closure.
7049fn dependency_closure_limit(
7050    root: &Path,
7051    sample: impl IntoIterator<Item = String>,
7052    observed: usize,
7053) -> CliError {
7054    CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7055        project_root: lossless_project_root_display(root),
7056        worktree: None,
7057        status: IndexReadStatus::RefreshRequired,
7058        reason: IndexRefreshReason::DependencyClosureLimit,
7059        scope: IndexRefreshScope::Full,
7060        changed: observed,
7061        added: 0,
7062        removed: 0,
7063        modified: observed,
7064        sample_paths: sample
7065            .into_iter()
7066            .take(INDEX_FRESHNESS_SAMPLE_LIMIT)
7067            .collect(),
7068    }))
7069}
7070
7071/// Translate a graph-domain contract violation for the CLI boundary.
7072fn invalid_graph_contract(error: GraphContractError) -> CliError {
7073    CliError::from(error)
7074}
7075
7076impl From<GraphContractError> for CliError {
7077    fn from(error: GraphContractError) -> Self {
7078        Self::InvalidInput(format!("repository graph projection failed: {error}"))
7079    }
7080}
7081
7082/// Count conservative retained canonical resolution-key bytes.
7083fn resolution_retained_bytes(projection: &ResolutionKeyProjection) -> u64 {
7084    projection
7085        .source_keys()
7086        .iter()
7087        .chain(
7088            projection
7089                .symbol_keys()
7090                .iter()
7091                .flat_map(projectatlas_symbols::SymbolResolutionKeys::keys),
7092        )
7093        .chain(
7094            projection
7095                .relation_keys()
7096                .iter()
7097                .flat_map(projectatlas_symbols::RelationResolutionKeys::keys),
7098        )
7099        .fold(0_u64, |bytes, key| {
7100            bytes
7101                .saturating_add(32)
7102                .saturating_add(key.canonical_identity().len() as u64)
7103        })
7104}
7105
7106/// Count one admitted projection entry while it remains in the source map.
7107fn resolution_projection_map_entry_retained_bytes(
7108    path: &str,
7109    projection: &ResolutionKeyProjection,
7110) -> u64 {
7111    STAGED_GRAPH_ROW_BYTES
7112        .saturating_add(path.len() as u64)
7113        .saturating_add(resolution_retained_bytes(projection))
7114}
7115
7116/// Count all projection-map entries that coexist with the growing entity state.
7117fn resolution_projection_map_retained_bytes(
7118    projections: &BTreeMap<String, ResolutionKeyProjection>,
7119) -> u64 {
7120    projections.iter().fold(0_u64, |bytes, (path, projection)| {
7121        bytes.saturating_add(resolution_projection_map_entry_retained_bytes(
7122            path, projection,
7123        ))
7124    })
7125}
7126
7127#[cfg(test)]
7128mod tests {
7129    use super::{
7130        CliError, DOCUMENT_PROJECTION_ROW_BYTES, DocumentResolutionIndex, DocumentTargetIdentity,
7131        GRAPH_STAGE_DATABASE_FILE_NAME, GRAPH_STAGE_DIRECTORY_PREFIX, GraphIdentityAdmission,
7132        GraphOwners, GraphSymbolIndex, MAX_IN_MEMORY_GRAPH_WORK_BYTES, MAX_INCREMENTAL_GRAPH_BYTES,
7133        MAX_INCREMENTAL_GRAPH_ROWS, PARTIAL_COVERAGE_REASON, PackageIndex,
7134        ProjectResolutionRegistry, QUALIFIED_SYMBOL_SCOPE_PREFIX, RepositoryGraphMutation,
7135        StagedRepositoryGraph, build_entity_projection, build_entity_projection_with_config,
7136        build_entity_projection_with_config_limit, cleanup_abandoned_graph_staging,
7137        coverage_for_graph, document_casefold_resolution_key, document_coverage,
7138        document_fact_map_retained_bytes, document_projection_retained_bytes,
7139        enforce_incremental_projection_budget, enforce_incremental_projection_limits,
7140        enforce_resolution_staging_budget, explicit_external_selector, finish_projection,
7141        finish_projection_in_database, finish_projection_in_database_with_documents,
7142        finish_projection_with_documents, identity_rejection_keys_retained_bytes, insert_relation,
7143        is_cargo_manifest_path, normalize_document_target, project_document_rows,
7144        qualified_symbol_identity, qualified_symbol_parents, registry_resolution_matches,
7145        relation_resolution, remove_owned_graph_stage_payload, repository_path_belongs_to,
7146        resolution_projection_map_retained_bytes, resolution_registry_from_exports,
7147        rust_toolchain_identity, source_symbol_identity, stage_full_repository_graph,
7148        stage_incremental_repository_graph, stage_incremental_repository_graph_with_test_limit,
7149        try_graph_stage_lease,
7150    };
7151    use crate::runtime::{
7152        IndexRefreshReason, IndexRefreshScope, SymbolBuildReport, SymbolBuildStage,
7153        SymbolParseSuccess, SymbolProjectionChange,
7154    };
7155    use projectatlas_core::graph::{
7156        CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
7157        CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
7158        ExtendedRelationKind, GraphEntity, GraphIdentityField, GraphIdentityRejectionReason,
7159        GraphIdentityText, GraphLimitKind, GraphLimits, GraphRelationKind, LogicalRelation,
7160        MAX_GRAPH_IDENTITY_BYTES, PackageSelector, ProjectInstanceId, RelationDependencyKey,
7161        RelationResolution, RepositoryFilePath, RepositoryNodePath, ResolutionKeyDomain,
7162        ReusableTargetSelector, SymbolSelector,
7163    };
7164    use projectatlas_core::relation_capabilities::{
7165        RELATION_FAMILY_CAPABILITIES, RelationFamilyState,
7166    };
7167    use projectatlas_core::symbols::{
7168        CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
7169        SymbolRelation,
7170    };
7171    use projectatlas_core::{
7172        IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkResource,
7173        IndexWorkStage, Node, NodeKind,
7174    };
7175    use projectatlas_db::{
7176        AtlasStore, RepositoryAffectedSourceFootprint, RepositoryGraphRelationQuery,
7177    };
7178    use projectatlas_fs::{RootScanPolicy, ScanOptions};
7179    use projectatlas_symbols::extract_symbol_graph;
7180    use projectatlas_symbols::{
7181        ConfiguredModuleResolution, EcmaScriptConfigKind, EcmaScriptModuleConfig,
7182        EcmaScriptPathMapping, MAX_DOCUMENT_LINK_CANDIDATES, MAX_DOCUMENT_SELECTOR_BYTES,
7183        MAX_MARKDOWN_EVIDENCE_BYTES, MAX_MARKDOWN_LABEL_BYTES, MarkdownFactLimit,
7184    };
7185    use rusqlite::Connection;
7186    use std::borrow::Cow;
7187    use std::collections::{BTreeMap, BTreeSet};
7188    use std::error::Error;
7189    use std::fmt::Debug;
7190    use std::fs;
7191    use std::io;
7192    use std::num::NonZeroU32;
7193    use std::path::Path;
7194    use std::thread;
7195    use std::time::{Duration, Instant};
7196
7197    #[test]
7198    fn paired_multiline_import_rejection_is_one_parser_fact() -> Result<(), Box<dyn Error>> {
7199        let graph = extract_symbol_graph(
7200            "src/page.ts",
7201            Some("typescript"),
7202            "import {\n  helper\n} from './LeakedIdentity\u{0}module';\n",
7203        );
7204        let control = IndexWorkControl::new(IndexCancellation::new(), None);
7205        let (admitted, report) = super::admit_symbol_graph(Cow::Owned(graph), &control)?;
7206        require_eq(
7207            &report.rejected_facts_for("src/page.ts"),
7208            &1,
7209            "paired multiline import omission count",
7210        )?;
7211        require_eq(
7212            &report.rejections.len(),
7213            &3,
7214            "paired multiline import typed detail count",
7215        )?;
7216        require(
7217            report.rejections.iter().all(|rejection| {
7218                rejection.fact_index == report.rejections[0].fact_index
7219                    && rejection.span.start_line() == 1
7220                    && rejection.span.end_line() == 1
7221            }),
7222            "paired multiline import details did not share one parser span",
7223        )?;
7224        for field in [
7225            GraphIdentityField::RelationTarget,
7226            GraphIdentityField::Signature,
7227            GraphIdentityField::Symbol,
7228        ] {
7229            require(
7230                report
7231                    .rejections
7232                    .iter()
7233                    .any(|rejection| rejection.field == field),
7234                "paired multiline import lost a typed invalid field",
7235            )?;
7236        }
7237        require(
7238            admitted.symbols.is_empty() && admitted.relations.is_empty(),
7239            "paired multiline import retained an invalid parser fact",
7240        )?;
7241        Ok(())
7242    }
7243
7244    #[test]
7245    fn paired_import_admission_scans_scale_bound_once_and_deduplicates_replay()
7246    -> Result<(), Box<dyn Error>> {
7247        const IMPORT_SYMBOLS: usize = 4_000;
7248        const IMPORT_RELATIONS: usize = 8_000;
7249        const PAIRING_WORK_ITEMS: usize = IMPORT_SYMBOLS + IMPORT_RELATIONS;
7250        let graph = |path: &str, invalid: bool| SymbolGraph {
7251            path: path.to_string(),
7252            language: Some("typescript".to_string()),
7253            parser: ParserKind::TreeSitter,
7254            symbols: (0..IMPORT_SYMBOLS)
7255                .map(|index| {
7256                    let name = if invalid {
7257                        format!("invalid\0import-{index}")
7258                    } else {
7259                        format!("import-{index}")
7260                    };
7261                    CodeSymbol {
7262                        path: path.to_string(),
7263                        language: Some("typescript".to_string()),
7264                        name: name.clone(),
7265                        kind: SymbolKind::Import,
7266                        signature: name,
7267                        exported: false,
7268                        documentation: None,
7269                        line_start: 1,
7270                        line_end: 2,
7271                        source_selector: None,
7272                        parent: None,
7273                        parser: ParserKind::TreeSitter,
7274                        detail: Some("import_statement".to_string()),
7275                    }
7276                })
7277                .collect(),
7278            relations: (0..IMPORT_RELATIONS)
7279                .map(|index| SymbolRelation {
7280                    path: path.to_string(),
7281                    source_name: path.to_string(),
7282                    target_name: if invalid {
7283                        format!("invalid\0module-{index}")
7284                    } else {
7285                        format!("module-{index}")
7286                    },
7287                    kind: RelationKind::Imports,
7288                    line: 1,
7289                    context: "import".to_string(),
7290                    parser: ParserKind::TreeSitter,
7291                })
7292                .collect(),
7293        };
7294        let control = IndexWorkControl::new(IndexCancellation::new(), None);
7295
7296        let (valid, valid_report) = super::admit_symbol_graph(
7297            Cow::Owned(graph("src/import-scale-valid.ts", false)),
7298            &control,
7299        )?;
7300        require_eq(
7301            &valid_report.paired_import_pairing_work,
7302            &PAIRING_WORK_ITEMS,
7303            "valid import pairing work",
7304        )?;
7305        require_eq(
7306            &valid.symbols.len(),
7307            &IMPORT_SYMBOLS,
7308            "valid import symbol count",
7309        )?;
7310        require_eq(
7311            &valid.relations.len(),
7312            &IMPORT_RELATIONS,
7313            "valid import relation count",
7314        )?;
7315        require_eq(
7316            &valid_report.rejected_facts_for("src/import-scale-valid.ts"),
7317            &0,
7318            "valid import rejection count",
7319        )?;
7320
7321        let invalid_graph = graph("src/import-scale-invalid.ts", true);
7322        let (invalid, mut invalid_report) =
7323            super::admit_symbol_graph(Cow::Owned(invalid_graph.clone()), &control)?;
7324        require_eq(
7325            &invalid_report.paired_import_pairing_work,
7326            &PAIRING_WORK_ITEMS,
7327            "invalid import pairing work",
7328        )?;
7329        require(
7330            invalid.symbols.is_empty() && invalid.relations.is_empty(),
7331            "invalid import facts survived admission",
7332        )?;
7333        require_eq(
7334            &invalid_report.rejected_facts_for("src/import-scale-invalid.ts"),
7335            &u64::try_from(IMPORT_RELATIONS)?,
7336            "invalid import rejection count",
7337        )?;
7338        require_eq(
7339            &invalid_report.rejections.len(),
7340            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7341            "invalid import detail ceiling",
7342        )?;
7343
7344        let (replayed, replay_report) =
7345            super::admit_symbol_graph(Cow::Owned(invalid_graph), &control)?;
7346        require(
7347            replayed.symbols.is_empty() && replayed.relations.is_empty(),
7348            "replayed invalid import facts survived admission",
7349        )?;
7350        require_eq(
7351            &replay_report.paired_import_pairing_work,
7352            &PAIRING_WORK_ITEMS,
7353            "replayed import pairing work",
7354        )?;
7355        invalid_report.merge(replay_report, &control)?;
7356        require_eq(
7357            &invalid_report.paired_import_pairing_work,
7358            &(PAIRING_WORK_ITEMS * 2),
7359            "merged replay import pairing work",
7360        )?;
7361        require_eq(
7362            &invalid_report.rejected_facts_for("src/import-scale-invalid.ts"),
7363            &u64::try_from(IMPORT_RELATIONS)?,
7364            "replayed import rejection count",
7365        )?;
7366        require_eq(
7367            &invalid_report.rejections.len(),
7368            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7369            "replayed import detail ceiling",
7370        )?;
7371        Ok(())
7372    }
7373
7374    #[test]
7375    fn admission_counts_distinct_same_span_facts_but_deduplicates_replays()
7376    -> Result<(), Box<dyn Error>> {
7377        let mut admission = GraphIdentityAdmission::default();
7378        let control = IndexWorkControl::new(IndexCancellation::new(), None);
7379        let span = super::IdentitySpan {
7380            start_line: 7,
7381            start_column: 0,
7382            end_line: 7,
7383            end_column: 0,
7384        };
7385        let failure = [(
7386            GraphIdentityField::RelationTarget,
7387            GraphIdentityRejectionReason::Empty,
7388        )];
7389        admission.record(
7390            "src/facts.ts",
7391            span,
7392            ParserKind::TreeSitter,
7393            super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
7394            &failure,
7395            &control,
7396        )?;
7397        admission.record(
7398            "src/facts.ts",
7399            span,
7400            ParserKind::TreeSitter,
7401            super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 2),
7402            &failure,
7403            &control,
7404        )?;
7405        admission.record(
7406            "src/facts.ts",
7407            span,
7408            ParserKind::TreeSitter,
7409            super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
7410            &failure,
7411            &control,
7412        )?;
7413        require_eq(
7414            &admission.rejected_facts_for("src/facts.ts"),
7415            &2,
7416            "same-span distinct rejection count",
7417        )?;
7418        require_eq(
7419            &admission.rejections.len(),
7420            &2,
7421            "same-span distinct typed detail count",
7422        )?;
7423        require(
7424            admission.rejections[0].fact_index != admission.rejections[1].fact_index,
7425            "same-span distinct facts shared an internal identity",
7426        )?;
7427        Ok(())
7428    }
7429
7430    #[test]
7431    fn rejection_membership_preserves_distinct_fields_across_replay_and_merge()
7432    -> Result<(), Box<dyn Error>> {
7433        let control = IndexWorkControl::new(IndexCancellation::new(), None);
7434        let span = super::IdentitySpan {
7435            start_line: 7,
7436            start_column: 0,
7437            end_line: 7,
7438            end_column: 0,
7439        };
7440        let failures = [
7441            (
7442                GraphIdentityField::RelationSource,
7443                GraphIdentityRejectionReason::ControlCharacters,
7444            ),
7445            (
7446                GraphIdentityField::RelationTarget,
7447                GraphIdentityRejectionReason::ControlCharacters,
7448            ),
7449            (
7450                GraphIdentityField::Parent,
7451                GraphIdentityRejectionReason::ControlCharacters,
7452            ),
7453            (
7454                GraphIdentityField::Signature,
7455                GraphIdentityRejectionReason::ControlCharacters,
7456            ),
7457        ];
7458        let mut admission = GraphIdentityAdmission::default();
7459        admission.record(
7460            "src/multi-field.ts",
7461            span,
7462            ParserKind::TreeSitter,
7463            17,
7464            &failures,
7465            &control,
7466        )?;
7467        admission.record(
7468            "src/multi-field.ts",
7469            span,
7470            ParserKind::TreeSitter,
7471            17,
7472            &failures,
7473            &control,
7474        )?;
7475        require_eq(
7476            &admission.rejections.len(),
7477            &failures.len(),
7478            "same-fact replay collapsed distinct rejection fields",
7479        )?;
7480        for &(field, reason) in &failures {
7481            require(
7482                admission
7483                    .rejections
7484                    .iter()
7485                    .any(|rejection| rejection.field == field && rejection.reason == reason),
7486                "same-fact rejection field was lost",
7487            )?;
7488        }
7489
7490        let mut aggregated = Vec::new();
7491        let mut aggregated_keys = BTreeSet::new();
7492        super::extend_bounded_identity_rejections(
7493            &mut aggregated,
7494            &mut aggregated_keys,
7495            admission.rejections.iter().cloned(),
7496        )?;
7497        super::extend_bounded_identity_rejections(
7498            &mut aggregated,
7499            &mut aggregated_keys,
7500            admission.rejections,
7501        )?;
7502        require_eq(
7503            &aggregated.len(),
7504            &failures.len(),
7505            "bounded aggregation collapsed distinct rejection fields",
7506        )?;
7507        require_eq(
7508            &aggregated_keys.len(),
7509            &failures.len(),
7510            "bounded aggregation membership did not deduplicate replay",
7511        )?;
7512        Ok(())
7513    }
7514
7515    #[test]
7516    fn admission_merge_deduplicates_replayed_observed_facts() -> Result<(), Box<dyn Error>> {
7517        let control = IndexWorkControl::new(IndexCancellation::new(), None);
7518        let span = super::IdentitySpan {
7519            start_line: 7,
7520            start_column: 0,
7521            end_line: 7,
7522            end_column: 0,
7523        };
7524        let failure = [(
7525            GraphIdentityField::RelationTarget,
7526            GraphIdentityRejectionReason::Empty,
7527        )];
7528        let mut first = GraphIdentityAdmission::default();
7529        for fact_index in 1..=2 {
7530            first.record(
7531                "src/merged.ts",
7532                span,
7533                ParserKind::TreeSitter,
7534                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
7535                &failure,
7536                &control,
7537            )?;
7538        }
7539        let mut replay = GraphIdentityAdmission::default();
7540        for fact_index in 1..=3 {
7541            replay.record(
7542                "src/merged.ts",
7543                span,
7544                ParserKind::TreeSitter,
7545                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
7546                &failure,
7547                &control,
7548            )?;
7549        }
7550        first.merge(replay, &control)?;
7551        require_eq(
7552            &first.rejected_facts_for("src/merged.ts"),
7553            &3,
7554            "merged replayed identity count",
7555        )?;
7556        require_eq(
7557            &first.rejections.len(),
7558            &3,
7559            "merged replayed typed detail count",
7560        )?;
7561        Ok(())
7562    }
7563
7564    #[test]
7565    fn admission_deduplicates_replayed_facts_after_detail_ceiling() -> Result<(), Box<dyn Error>> {
7566        let mut admission = GraphIdentityAdmission::default();
7567        let control = IndexWorkControl::new(IndexCancellation::new(), None);
7568        let span = super::IdentitySpan {
7569            start_line: 7,
7570            start_column: 0,
7571            end_line: 7,
7572            end_column: 0,
7573        };
7574        let failure = [(
7575            GraphIdentityField::RelationTarget,
7576            GraphIdentityRejectionReason::Empty,
7577        )];
7578        for fact_index in 0..super::MAX_GRAPH_IDENTITY_REJECTIONS {
7579            admission.record(
7580                "src/capped.ts",
7581                span,
7582                ParserKind::TreeSitter,
7583                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
7584                &failure,
7585                &control,
7586            )?;
7587        }
7588        require_eq(
7589            &admission.rejected_facts_for("src/capped.ts"),
7590            &u64::try_from(super::MAX_GRAPH_IDENTITY_REJECTIONS)?,
7591            "capped distinct rejection count",
7592        )?;
7593        require_eq(
7594            &admission.rejections.len(),
7595            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7596            "capped typed detail count",
7597        )?;
7598        admission.record(
7599            "src/capped.ts",
7600            span,
7601            ParserKind::TreeSitter,
7602            super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 0),
7603            &failure,
7604            &control,
7605        )?;
7606        require_eq(
7607            &admission.rejected_facts_for("src/capped.ts"),
7608            &u64::try_from(super::MAX_GRAPH_IDENTITY_REJECTIONS)?,
7609            "replayed capped rejection count",
7610        )?;
7611        require_eq(
7612            &admission.rejections.len(),
7613            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7614            "replayed capped typed detail count",
7615        )?;
7616        admission.record(
7617            "src/capped.ts",
7618            span,
7619            ParserKind::TreeSitter,
7620            super::parser_fact_index(
7621                super::RELATION_FACT_INDEX_NAMESPACE,
7622                super::MAX_GRAPH_IDENTITY_REJECTIONS,
7623            ),
7624            &failure,
7625            &control,
7626        )?;
7627        require_eq(
7628            &admission.rejected_facts_for("src/capped.ts"),
7629            &u64::try_from(super::MAX_GRAPH_IDENTITY_REJECTIONS + 1)?,
7630            "new capped rejection count",
7631        )?;
7632        require_eq(
7633            &admission.rejections.len(),
7634            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7635            "new capped typed detail count",
7636        )?;
7637        let mut aggregated = Vec::new();
7638        let mut aggregated_keys = BTreeSet::new();
7639        super::extend_bounded_identity_rejections(
7640            &mut aggregated,
7641            &mut aggregated_keys,
7642            admission.rejections.iter().cloned(),
7643        )?;
7644        super::extend_bounded_identity_rejections(
7645            &mut aggregated,
7646            &mut aggregated_keys,
7647            admission.rejections,
7648        )?;
7649        require_eq(
7650            &aggregated.len(),
7651            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7652            "bounded aggregation changed the distinct detail cardinality",
7653        )?;
7654        require_eq(
7655            &aggregated_keys.len(),
7656            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7657            "bounded aggregation membership lost a distinct detail",
7658        )?;
7659        Ok(())
7660    }
7661
7662    #[test]
7663    fn identity_rejection_limit_marker_is_causal_and_path_scoped() -> Result<(), Box<dyn Error>> {
7664        let control = IndexWorkControl::new(IndexCancellation::new(), None);
7665        let failure = [(
7666            GraphIdentityField::Symbol,
7667            GraphIdentityRejectionReason::Empty,
7668        )];
7669        let span = |line| super::IdentitySpan {
7670            start_line: line,
7671            start_column: 0,
7672            end_line: line,
7673            end_column: 0,
7674        };
7675        let mut admission = GraphIdentityAdmission::default();
7676        for fact_index in 0..super::MAX_GRAPH_IDENTITY_REJECTIONS {
7677            admission.record(
7678                "src/exact-cap.ts",
7679                span(fact_index + 1),
7680                ParserKind::TreeSitter,
7681                super::parser_fact_index(super::SYMBOL_FACT_INDEX_NAMESPACE, fact_index),
7682                &failure,
7683                &control,
7684            )?;
7685        }
7686        require(
7687            admission.rejection_details_dropped_by_path.is_empty(),
7688            "an exactly full retained detail set claimed an eviction",
7689        )?;
7690
7691        let graph = |path: &str, parser: ParserKind| SymbolGraph {
7692            path: path.to_string(),
7693            language: Some("test".to_string()),
7694            parser,
7695            symbols: Vec::new(),
7696            relations: Vec::new(),
7697        };
7698        let exact_cap = super::coverage_for_graph(
7699            &graph("src/exact-cap.ts", ParserKind::TreeSitter),
7700            IndexGeneration::new(1),
7701            &admission,
7702            &GraphIdentityAdmission::default(),
7703        )?;
7704        require_eq(
7705            &exact_cap.reached_limit(),
7706            &None,
7707            "exactly full retained details reported an eviction",
7708        )?;
7709
7710        admission.record(
7711            "src/exact-parser.ts",
7712            span(1),
7713            ParserKind::TreeSitter,
7714            super::parser_fact_index(super::SYMBOL_FACT_INDEX_NAMESPACE, 10_000),
7715            &failure,
7716            &control,
7717        )?;
7718        admission.record(
7719            "src/markdown-structural.md",
7720            span(1),
7721            ParserKind::Structural,
7722            super::parser_fact_index(super::MARKDOWN_FACT_INDEX_NAMESPACE, 0),
7723            &failure,
7724            &control,
7725        )?;
7726        require(
7727            admission
7728                .rejection_details_dropped_by_path
7729                .contains("src/exact-parser.ts")
7730                && admission
7731                    .rejection_details_dropped_by_path
7732                    .contains("src/markdown-structural.md"),
7733            "distinct exact and structural evictions were not marked by path",
7734        )?;
7735        for (path, parser) in [
7736            ("src/exact-parser.ts", ParserKind::TreeSitter),
7737            ("src/markdown-structural.md", ParserKind::Structural),
7738        ] {
7739            let coverage = super::coverage_for_graph(
7740                &graph(path, parser),
7741                IndexGeneration::new(1),
7742                &admission,
7743                &GraphIdentityAdmission::default(),
7744            )?;
7745            require_eq(
7746                &coverage.reached_limit(),
7747                &Some(GraphLimitKind::Rows),
7748                "causal identity eviction did not reach path coverage",
7749            )?;
7750        }
7751
7752        for parser in [ParserKind::Structural, ParserKind::Fallback] {
7753            let coverage = super::coverage_for_graph(
7754                &graph("src/baseline.rs", parser),
7755                IndexGeneration::new(1),
7756                &GraphIdentityAdmission::default(),
7757                &GraphIdentityAdmission::default(),
7758            )?;
7759            require_eq(
7760                &coverage.reached_limit(),
7761                &None,
7762                "parser baseline omission claimed identity-detail eviction",
7763            )?;
7764        }
7765        let unrelated = super::coverage_for_graph(
7766            &graph("src/unrelated.rs", ParserKind::TreeSitter),
7767            IndexGeneration::new(1),
7768            &admission,
7769            &GraphIdentityAdmission::default(),
7770        )?;
7771        require_eq(
7772            &unrelated.reached_limit(),
7773            &None,
7774            "an unrelated path inherited another path's identity eviction",
7775        )?;
7776        Ok(())
7777    }
7778
7779    #[test]
7780    fn admission_merges_many_graphs_with_incremental_retained_bytes() -> Result<(), Box<dyn Error>>
7781    {
7782        const GRAPH_COUNT: usize = 1_000;
7783        const FACTS_PER_GRAPH: usize = 10;
7784        let control = IndexWorkControl::new(IndexCancellation::new(), None);
7785        let span = super::IdentitySpan {
7786            start_line: 7,
7787            start_column: 0,
7788            end_line: 7,
7789            end_column: 0,
7790        };
7791        let failure = [(
7792            GraphIdentityField::RelationTarget,
7793            GraphIdentityRejectionReason::Empty,
7794        )];
7795        let mut admission = GraphIdentityAdmission::default();
7796        let mut expected_bytes = 0_u64;
7797        for graph_index in 0..GRAPH_COUNT {
7798            let path = format!("src/many-graphs-{graph_index}.ts");
7799            let mut graph = GraphIdentityAdmission::default();
7800            for fact_index in 0..FACTS_PER_GRAPH {
7801                graph.record(
7802                    &path,
7803                    span,
7804                    ParserKind::TreeSitter,
7805                    super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
7806                    &failure,
7807                    &control,
7808                )?;
7809            }
7810            admission.merge(graph, &control)?;
7811            let key_bytes = super::identity_rejection_key_retained_bytes(&path)?
7812                .checked_mul(u64::try_from(FACTS_PER_GRAPH)?)
7813                .ok_or_else(|| io::Error::other("many-graph identity bytes overflowed"))?;
7814            let graph_bytes = super::identity_observed_path_retained_bytes(&path, FACTS_PER_GRAPH)?
7815                .checked_add(super::identity_count_path_retained_bytes(&path)?)
7816                .and_then(|bytes| bytes.checked_add(key_bytes))
7817                .ok_or_else(|| io::Error::other("many-graph identity bytes overflowed"))?;
7818            expected_bytes = expected_bytes
7819                .checked_add(graph_bytes)
7820                .ok_or_else(|| io::Error::other("many-graph identity bytes overflowed"))?;
7821            require_eq(
7822                &admission.observed_fact_bytes,
7823                &expected_bytes,
7824                "incremental identity byte cache",
7825            )?;
7826        }
7827        require_eq(
7828            &admission.rejections.len(),
7829            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7830            "many-graph retained detail ceiling",
7831        )?;
7832        require_eq(
7833            &admission.rejection_keys.len(),
7834            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7835            "many-graph keyed detail ceiling",
7836        )?;
7837        require_eq(
7838            &super::identity_admission_retained_bytes(&admission),
7839            &expected_bytes,
7840            "incremental identity byte cache remained authoritative",
7841        )?;
7842
7843        let replay_path = "src/many-graphs-999.ts";
7844        let mut replay = GraphIdentityAdmission::default();
7845        for fact_index in 0..FACTS_PER_GRAPH {
7846            replay.record(
7847                replay_path,
7848                span,
7849                ParserKind::TreeSitter,
7850                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
7851                &failure,
7852                &control,
7853            )?;
7854        }
7855        admission.merge(replay, &control)?;
7856        require_eq(
7857            &admission.observed_fact_bytes,
7858            &expected_bytes,
7859            "replayed graph did not inflate identity byte cache",
7860        )?;
7861        Ok(())
7862    }
7863
7864    #[test]
7865    fn observed_identity_facts_charge_budget_at_minus_equal_plus_boundaries()
7866    -> Result<(), Box<dyn Error>> {
7867        let path = "src/budget.ts";
7868        let span = super::IdentitySpan {
7869            start_line: 7,
7870            start_column: 0,
7871            end_line: 7,
7872            end_column: 0,
7873        };
7874        let failure = [(
7875            GraphIdentityField::RelationTarget,
7876            GraphIdentityRejectionReason::Empty,
7877        )];
7878        let additional = super::identity_fact_retained_bytes(path, true, true)?
7879            .checked_add(super::identity_rejection_key_retained_bytes(path)?)
7880            .ok_or_else(|| io::Error::other("identity budget fixture overflowed"))?;
7881        let next_fact_additional = super::STAGED_GRAPH_ROW_BYTES
7882            .checked_add(super::identity_rejection_key_retained_bytes(path)?)
7883            .ok_or_else(|| io::Error::other("identity budget fixture overflowed"))?;
7884
7885        let control = IndexWorkControl::new(IndexCancellation::new(), None);
7886        let mut below = GraphIdentityAdmission {
7887            observed_fact_bytes: MAX_IN_MEMORY_GRAPH_WORK_BYTES - additional + 1,
7888            ..GraphIdentityAdmission::default()
7889        };
7890        let error = below
7891            .record(
7892                path,
7893                span,
7894                ParserKind::TreeSitter,
7895                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
7896                &failure,
7897                &control,
7898            )
7899            .err()
7900            .ok_or_else(|| io::Error::other("one byte over the identity budget was retained"))?;
7901        require(
7902            matches!(
7903                error,
7904                CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
7905                    stage: IndexWorkStage::SymbolParsing,
7906                    resource: IndexWorkResource::OutputBytes,
7907                    limit: MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7908                    observed,
7909                }) if observed == MAX_IN_MEMORY_GRAPH_WORK_BYTES + 1
7910            ),
7911            "identity budget overflow did not return its typed refusal",
7912        )?;
7913        require(
7914            below.observed_facts.is_empty() && below.rejected_facts_by_path.is_empty(),
7915            "identity budget refusal retained partial state",
7916        )?;
7917
7918        let mut equal = GraphIdentityAdmission {
7919            observed_fact_bytes: MAX_IN_MEMORY_GRAPH_WORK_BYTES - additional,
7920            ..GraphIdentityAdmission::default()
7921        };
7922        equal.record(
7923            path,
7924            span,
7925            ParserKind::TreeSitter,
7926            super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
7927            &failure,
7928            &control,
7929        )?;
7930        require_eq(
7931            &equal.observed_fact_bytes,
7932            &MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7933            "identity budget exact boundary",
7934        )?;
7935        require_eq(
7936            &equal.rejected_facts_for(path),
7937            &1,
7938            "identity budget exact boundary count",
7939        )?;
7940
7941        let error = equal
7942            .record(
7943                path,
7944                span,
7945                ParserKind::TreeSitter,
7946                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 2),
7947                &failure,
7948                &control,
7949            )
7950            .err()
7951            .ok_or_else(|| io::Error::other("one additional identity fact exceeded the budget"))?;
7952        require(
7953            matches!(
7954                error,
7955                CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
7956                    stage: IndexWorkStage::SymbolParsing,
7957                    resource: IndexWorkResource::OutputBytes,
7958                    limit: MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7959                    observed,
7960                }) if observed == MAX_IN_MEMORY_GRAPH_WORK_BYTES + next_fact_additional
7961            ),
7962            "identity budget plus boundary did not return its typed refusal",
7963        )?;
7964        require_eq(
7965            &equal.rejected_facts_for(path),
7966            &1,
7967            "identity plus boundary changed retained count",
7968        )?;
7969        Ok(())
7970    }
7971
7972    #[test]
7973    fn observed_identity_facts_honor_cancellation_before_retention() -> Result<(), Box<dyn Error>> {
7974        let cancellation = IndexCancellation::new();
7975        let control = IndexWorkControl::new(cancellation.clone(), None);
7976        cancellation.cancel();
7977        let mut admission = GraphIdentityAdmission::default();
7978        let error = admission
7979            .record(
7980                "src/canceled.ts",
7981                super::IdentitySpan {
7982                    start_line: 1,
7983                    start_column: 0,
7984                    end_line: 1,
7985                    end_column: 0,
7986                },
7987                ParserKind::TreeSitter,
7988                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 0),
7989                &[(
7990                    GraphIdentityField::RelationTarget,
7991                    GraphIdentityRejectionReason::Empty,
7992                )],
7993                &control,
7994            )
7995            .err()
7996            .ok_or_else(|| io::Error::other("canceled identity admission retained a fact"))?;
7997        require(
7998            matches!(
7999                error,
8000                CliError::IndexWork(IndexWorkFailure::Cancelled {
8001                    stage: IndexWorkStage::SymbolParsing
8002                })
8003            ),
8004            "identity admission cancellation was not typed",
8005        )?;
8006        require(
8007            admission.observed_facts.is_empty()
8008                && admission.rejected_facts_by_path.is_empty()
8009                && admission.observed_fact_bytes == 0,
8010            "canceled identity admission retained partial state",
8011        )?;
8012        Ok(())
8013    }
8014
8015    #[test]
8016    fn reused_reconciliation_paths_charge_all_maps_at_injected_boundaries()
8017    -> Result<(), Box<dyn Error>> {
8018        let mut report = GraphIdentityAdmission::default();
8019        let control = IndexWorkControl::new(IndexCancellation::new(), None);
8020        for index in 0..32 {
8021            let path = format!("src/reused-{index}.ts");
8022            for _ in 0..4 {
8023                report.reserve_reused_path_bytes(
8024                    &path,
8025                    false,
8026                    &control,
8027                    MAX_IN_MEMORY_GRAPH_WORK_BYTES,
8028                )?;
8029            }
8030            report.reused_rejection_counts.insert(path.clone(), 2);
8031            report
8032                .reused_parser_rejection_counts
8033                .insert(path.clone(), 1);
8034            report
8035                .reused_rejection_detail_counts
8036                .insert(path.clone(), 0);
8037            report.reused_rejection_details_incomplete.insert(path);
8038        }
8039        let retained = super::identity_admission_retained_bytes(&report);
8040        require(
8041            retained > 0,
8042            "reused reconciliation bytes were not retained",
8043        )?;
8044
8045        let below = retained - 1;
8046        let error = super::checked_identity_admission_budget(&report, &control, below)
8047            .err()
8048            .ok_or_else(|| io::Error::other("below-limit reused paths were accepted"))?;
8049        require(
8050            matches!(
8051                error,
8052                CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
8053                    stage: IndexWorkStage::SymbolParsing,
8054                    resource: IndexWorkResource::OutputBytes,
8055                    limit,
8056                    observed,
8057                }) if limit == below && observed == retained
8058            ),
8059            "below-limit reused paths did not return their typed refusal",
8060        )?;
8061        require_eq(
8062            &super::checked_identity_admission_budget(&report, &control, retained)?,
8063            &retained,
8064            "equal-limit reused paths",
8065        )?;
8066        require_eq(
8067            &super::checked_identity_admission_budget(&report, &control, retained + 1)?,
8068            &retained,
8069            "above-limit reused paths",
8070        )?;
8071        Ok(())
8072    }
8073
8074    #[test]
8075    fn reused_reconciliation_retention_honors_cancellation_before_insertion()
8076    -> Result<(), Box<dyn Error>> {
8077        let cancellation = IndexCancellation::new();
8078        let control = IndexWorkControl::new(cancellation.clone(), None);
8079        cancellation.cancel();
8080        let mut report = GraphIdentityAdmission::default();
8081        let error = report
8082            .reserve_reused_path_bytes(
8083                "src/canceled-reuse.ts",
8084                false,
8085                &control,
8086                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
8087            )
8088            .err()
8089            .ok_or_else(|| io::Error::other("canceled reused path was retained"))?;
8090        require(
8091            matches!(
8092                error,
8093                CliError::IndexWork(IndexWorkFailure::Cancelled {
8094                    stage: IndexWorkStage::SymbolParsing
8095                })
8096            ),
8097            "reused path cancellation was not typed",
8098        )?;
8099        require(
8100            report.reused_rejection_counts.is_empty()
8101                && report.reused_parser_rejection_counts.is_empty()
8102                && report.reused_rejection_detail_counts.is_empty()
8103                && report.reused_rejection_details_incomplete.is_empty()
8104                && report.observed_fact_bytes == 0,
8105            "canceled reused path retained reconciliation state",
8106        )?;
8107
8108        let mut incoming = GraphIdentityAdmission::default();
8109        incoming
8110            .reused_rejection_counts
8111            .insert("src/incoming.ts".to_string(), 1);
8112        let error = report
8113            .merge(incoming, &control)
8114            .err()
8115            .ok_or_else(|| io::Error::other("canceled merge retained reconciliation state"))?;
8116        require(
8117            matches!(
8118                error,
8119                CliError::IndexWork(IndexWorkFailure::Cancelled {
8120                    stage: IndexWorkStage::SymbolParsing
8121                })
8122            ),
8123            "merge cancellation was not typed",
8124        )?;
8125        require(
8126            report.reused_rejection_counts.is_empty() && report.observed_fact_bytes == 0,
8127            "canceled merge retained reconciliation state",
8128        )?;
8129        Ok(())
8130    }
8131
8132    #[test]
8133    fn reused_identity_counts_preserve_new_and_removed_derived_outcomes()
8134    -> Result<(), Box<dyn Error>> {
8135        let path = "src/reused.ts";
8136        let control = IndexWorkControl::new(IndexCancellation::new(), None);
8137        let failure = [(
8138            GraphIdentityField::ResolutionKey,
8139            GraphIdentityRejectionReason::Oversized,
8140        )];
8141        let mut admission = GraphIdentityAdmission::default();
8142        for _ in 0..2 {
8143            admission.reserve_reused_path_bytes(
8144                path,
8145                false,
8146                &control,
8147                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
8148            )?;
8149        }
8150        admission
8151            .reused_rejection_counts
8152            .insert(path.to_string(), 3);
8153        admission
8154            .reused_rejection_detail_counts
8155            .insert(path.to_string(), 2);
8156        for fact_index in 0..2 {
8157            admission.record(
8158                path,
8159                super::IdentitySpan {
8160                    start_line: fact_index + 1,
8161                    start_column: 0,
8162                    end_line: fact_index + 1,
8163                    end_column: 0,
8164                },
8165                ParserKind::TreeSitter,
8166                fact_index as u64,
8167                &failure,
8168                &control,
8169            )?;
8170        }
8171        let persisted_facts = admission
8172            .observed_facts
8173            .get(path)
8174            .cloned()
8175            .ok_or("persisted identity fact keys are missing")?;
8176        admission.reserve_identity_bytes(
8177            super::identity_fact_set_retained_bytes(path, &persisted_facts)?,
8178            &control,
8179            MAX_IN_MEMORY_GRAPH_WORK_BYTES,
8180        )?;
8181        admission
8182            .reused_rejection_facts
8183            .insert(path.to_string(), persisted_facts);
8184        require_eq(
8185            &admission.rejected_facts_for_graph(path, &GraphIdentityAdmission::default())?,
8186            &3,
8187            "reused unchanged rejection count",
8188        )?;
8189
8190        let mut derived = GraphIdentityAdmission::default();
8191        derived.record(
8192            path,
8193            super::IdentitySpan {
8194                start_line: 3,
8195                start_column: 0,
8196                end_line: 3,
8197                end_column: 0,
8198            },
8199            ParserKind::TreeSitter,
8200            2,
8201            &failure,
8202            &control,
8203        )?;
8204        require_eq(
8205            &admission.rejected_facts_for_graph(path, &derived)?,
8206            &4,
8207            "reused genuinely new rejection count",
8208        )?;
8209
8210        let mut removed = GraphIdentityAdmission::default();
8211        for _ in 0..2 {
8212            removed.reserve_reused_path_bytes(
8213                path,
8214                false,
8215                &control,
8216                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
8217            )?;
8218        }
8219        removed.reused_rejection_counts.insert(path.to_string(), 2);
8220        removed
8221            .reused_rejection_detail_counts
8222            .insert(path.to_string(), 2);
8223        removed.reused_rejection_facts.insert(
8224            path.to_string(),
8225            admission
8226                .reused_rejection_facts
8227                .get(path)
8228                .cloned()
8229                .ok_or("persisted identity fact keys are missing")?,
8230        );
8231        let persisted_facts = removed
8232            .reused_rejection_facts
8233            .get(path)
8234            .cloned()
8235            .ok_or("persisted identity fact keys are missing")?;
8236        removed.reserve_identity_bytes(
8237            super::identity_fact_set_retained_bytes(path, &persisted_facts)?,
8238            &control,
8239            MAX_IN_MEMORY_GRAPH_WORK_BYTES,
8240        )?;
8241        removed.record(
8242            path,
8243            super::IdentitySpan {
8244                start_line: 1,
8245                start_column: 0,
8246                end_line: 1,
8247                end_column: 0,
8248            },
8249            ParserKind::TreeSitter,
8250            0,
8251            &failure,
8252            &control,
8253        )?;
8254        require_eq(
8255            &removed.rejected_facts_for_graph(path, &GraphIdentityAdmission::default())?,
8256            &1,
8257            "reused removed rejection count",
8258        )?;
8259        require_eq(
8260            &removed.rejected_facts_for_graph(path, &derived)?,
8261            &2,
8262            "reused removed plus new rejection count",
8263        )?;
8264        Ok(())
8265    }
8266
8267    #[test]
8268    fn relation_rejections_keep_original_parser_ordinals_through_reopen_and_retry()
8269    -> Result<(), Box<dyn Error>> {
8270        let temp = tempfile::tempdir()?;
8271        let root = fs::canonicalize(temp.path())?;
8272        fs::create_dir_all(root.join(".projectatlas"))?;
8273        let database = root.join(".projectatlas/projectatlas.db");
8274        let components = (0..20)
8275            .map(|_| "d".repeat(200))
8276            .collect::<Vec<_>>()
8277            .join("/");
8278        let path = format!("src/{components}/page.ts");
8279        let invalid_target = "x".repeat(MAX_GRAPH_IDENTITY_BYTES + 1);
8280        let oversized_resolved_module = "m".repeat(100);
8281        let graph = SymbolGraph {
8282            path: path.clone(),
8283            language: Some("typescript".to_string()),
8284            parser: ParserKind::TreeSitter,
8285            symbols: vec![CodeSymbol {
8286                path: path.clone(),
8287                language: Some("typescript".to_string()),
8288                name: "caller".to_string(),
8289                kind: SymbolKind::Function,
8290                signature: "function caller()".to_string(),
8291                exported: true,
8292                documentation: None,
8293                line_start: 1,
8294                line_end: 1,
8295                source_selector: None,
8296                parent: None,
8297                parser: ParserKind::TreeSitter,
8298                detail: None,
8299            }],
8300            relations: vec![
8301                SymbolRelation {
8302                    path: path.clone(),
8303                    source_name: "caller".to_string(),
8304                    target_name: invalid_target,
8305                    kind: RelationKind::Calls,
8306                    line: 1,
8307                    context: "caller()".to_string(),
8308                    parser: ParserKind::TreeSitter,
8309                },
8310                SymbolRelation {
8311                    path: path.clone(),
8312                    source_name: "<module>".to_string(),
8313                    target_name: format!("import {{ run }} from './{oversized_resolved_module}';"),
8314                    kind: RelationKind::Imports,
8315                    line: 1,
8316                    context: "relation ordinal fixture".to_string(),
8317                    parser: ParserKind::TreeSitter,
8318                },
8319            ],
8320        };
8321        let nodes = vec![test_file_node(&path, "typescript")];
8322        let mut store = AtlasStore::open_for_project(&database, &root)?;
8323        let control = IndexWorkControl::new(IndexCancellation::new(), None);
8324        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
8325        let symbols = symbol_build_stage_for_graphs(vec![graph.clone()]);
8326        let staged = stage_full_repository_graph(
8327            &store,
8328            &root,
8329            IndexGeneration::ZERO,
8330            &nodes,
8331            &scan_policy,
8332            &symbols,
8333            &control,
8334        )?;
8335        let relation_rejections = staged
8336            .identity_rejections
8337            .iter()
8338            .filter(|row| {
8339                matches!(
8340                    row.field,
8341                    GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
8342                )
8343            })
8344            .collect::<Vec<_>>();
8345        require_eq(
8346            &relation_rejections.len(),
8347            &2,
8348            "same-line relation rejection detail count",
8349        )?;
8350        require(
8351            relation_rejections.iter().all(|row| {
8352                row.path.as_str() == path
8353                    && row.span.start_line() == 1
8354                    && row.span.end_line() == 1
8355                    && row.parser == ParserKind::TreeSitter
8356                    && row.reason == GraphIdentityRejectionReason::Oversized
8357            }),
8358            "same-line relation rejection provenance was not exact",
8359        )?;
8360        let mut fact_indices = relation_rejections
8361            .iter()
8362            .map(|row| row.fact_index)
8363            .collect::<Vec<_>>();
8364        fact_indices.sort_unstable();
8365        require_eq(
8366            &fact_indices,
8367            &vec![
8368                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 0),
8369                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
8370            ],
8371            "same-line relation parser ordinals",
8372        )?;
8373        require_eq(
8374            &staged.relations.len(),
8375            &1,
8376            "valid relation retained after source admission",
8377        )?;
8378        publish_full_staged_graph(
8379            &mut store,
8380            &nodes,
8381            &staged,
8382            &control,
8383            "relation-ordinal-full",
8384        )?;
8385        let first_generation = store
8386            .index_publication()?
8387            .ok_or("relation ordinal full publication is missing")?
8388            .generation;
8389        let path_key = RepositoryNodePath::new(Path::new(&path))?;
8390        let persisted = store.repository_graph_identity_rejections(
8391            store
8392                .project_instance_id()?
8393                .ok_or("relation ordinal project identity is missing")?,
8394            std::slice::from_ref(&path_key),
8395            16,
8396            None,
8397        )?;
8398        let persisted_fact_indices = persisted
8399            .iter()
8400            .filter(|row| {
8401                matches!(
8402                    row.field,
8403                    GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
8404                )
8405            })
8406            .map(|row| row.fact_index)
8407            .collect::<Vec<_>>();
8408        require_eq(
8409            &persisted_fact_indices,
8410            &fact_indices,
8411            "persisted same-line relation parser ordinals",
8412        )?;
8413        let persisted_wire = serde_json::to_string(&persisted)?;
8414        require(
8415            !persisted_wire.contains(&"x".repeat(32)),
8416            "persisted relation rejection retained invalid identity text",
8417        )?;
8418        drop(store);
8419
8420        let mut store = AtlasStore::open_for_project(&database, &root)?;
8421        let reopened = store.repository_graph_identity_rejections(
8422            store
8423                .project_instance_id()?
8424                .ok_or("reopened relation ordinal project identity is missing")?,
8425            std::slice::from_ref(&path_key),
8426            16,
8427            None,
8428        )?;
8429        require_eq(
8430            &reopened
8431                .iter()
8432                .filter(|row| {
8433                    matches!(
8434                        row.field,
8435                        GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
8436                    )
8437                })
8438                .map(|row| row.fact_index)
8439                .collect::<Vec<_>>(),
8440            &fact_indices,
8441            "reopened same-line relation parser ordinals",
8442        )?;
8443
8444        let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
8445        let incremental_policy =
8446            RootScanPolicy::discover(&root, &ScanOptions::default(), &incremental_control)?;
8447        let fault_graph = graph.clone();
8448        let incremental_symbols = symbol_build_stage_for_graphs(vec![graph]);
8449        let incremental = stage_incremental_repository_graph(
8450            &store,
8451            &root,
8452            first_generation,
8453            &nodes,
8454            std::slice::from_ref(&path),
8455            &incremental_policy,
8456            &incremental_symbols,
8457            &incremental_control,
8458        )?;
8459        let incremental_rejections = incremental
8460            .identity_rejections
8461            .iter()
8462            .filter(|row| {
8463                matches!(
8464                    row.field,
8465                    GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
8466                )
8467            })
8468            .map(|row| row.fact_index)
8469            .collect::<Vec<_>>();
8470        require_eq(
8471            &incremental_rejections,
8472            &fact_indices,
8473            "incremental same-line relation parser ordinals",
8474        )?;
8475        let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
8476        canceled.cancel();
8477        {
8478            let mut publication = store.begin_index_publication("relation-ordinal-cancel")?;
8479            require(
8480                incremental.apply(&mut publication, &canceled).is_err(),
8481                "relation ordinal cancellation did not fail publication",
8482            )?;
8483        }
8484        require_eq(
8485            &store
8486                .index_publication()?
8487                .map(|publication| publication.generation),
8488            &Some(first_generation),
8489            "generation after canceled relation ordinal publication",
8490        )?;
8491        {
8492            let mut publication = store.begin_index_publication("relation-ordinal-incremental")?;
8493            incremental.apply(&mut publication, &incremental_control)?;
8494            publication.complete()?;
8495        }
8496        let second_generation = store
8497            .index_publication()?
8498            .ok_or("relation ordinal incremental publication is missing")?
8499            .generation;
8500        drop(store);
8501        let mut store = AtlasStore::open_for_project(&database, &root)?;
8502        let reopened_incremental = store.repository_graph_identity_rejections(
8503            store
8504                .project_instance_id()?
8505                .ok_or("reopened incremental project identity is missing")?,
8506            std::slice::from_ref(&path_key),
8507            16,
8508            None,
8509        )?;
8510        require(
8511            reopened_incremental.iter().any(|row| {
8512                matches!(
8513                    row.field,
8514                    GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
8515                )
8516            }),
8517            "reopened incremental relation rejection is missing",
8518        )?;
8519        let mut fault = stage_incremental_repository_graph(
8520            &store,
8521            &root,
8522            second_generation,
8523            &nodes,
8524            std::slice::from_ref(&path),
8525            &incremental_policy,
8526            &symbol_build_stage_for_graphs(vec![fault_graph]),
8527            &incremental_control,
8528        )?;
8529        fault.identity_rejections.resize(
8530            usize::try_from(GraphLimits::MAX_ROWS)
8531                .unwrap_or(usize::MAX)
8532                .saturating_add(1),
8533            fault.identity_rejections[0].clone(),
8534        );
8535        {
8536            let mut publication = store.begin_index_publication("relation-ordinal-fault")?;
8537            require(
8538                fault.apply(&mut publication, &incremental_control).is_err(),
8539                "relation ordinal late fault did not fail publication",
8540            )?;
8541        }
8542        require_eq(
8543            &store
8544                .index_publication()?
8545                .map(|publication| publication.generation),
8546            &Some(second_generation),
8547            "generation after relation ordinal late fault",
8548        )?;
8549        Ok(())
8550    }
8551
8552    #[test]
8553    fn sqlite_recovered_invalid_manifest_context_uses_shared_admission()
8554    -> Result<(), Box<dyn Error>> {
8555        let temp = tempfile::tempdir()?;
8556        let root = fs::canonicalize(temp.path())?;
8557        let database = root.join(".projectatlas/projectatlas.db");
8558        fs::create_dir_all(root.join(".projectatlas"))?;
8559        let invalid_manifest = package_graph("Cargo.toml", "bad\0package");
8560        let direct_graph = function_graph("src/lib.rs", 1);
8561        let mut store = AtlasStore::open_for_project(&database, &root)?;
8562        store.replace_symbol_graph(&invalid_manifest)?;
8563        store.replace_symbol_graph(&direct_graph)?;
8564        drop(store);
8565
8566        let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
8567        let recovered = reopened
8568            .load_symbol_graphs_for_paths(&["Cargo.toml".to_string(), "src/lib.rs".to_string()])?;
8569        let control = IndexWorkControl::new(IndexCancellation::new(), None);
8570        let (admitted, report) =
8571            super::admit_symbol_graphs(recovered.into_iter().map(Cow::Owned).collect(), &control)?;
8572        require_eq(
8573            &report.rejected_facts_for("Cargo.toml"),
8574            &1,
8575            "recovered invalid manifest rejection count",
8576        )?;
8577        let manifest = admitted
8578            .iter()
8579            .find(|graph| graph.as_ref().path == "Cargo.toml")
8580            .ok_or("recovered manifest graph is missing")?;
8581        require(
8582            manifest.as_ref().symbols.is_empty(),
8583            "invalid recovered package identity reached package context",
8584        )?;
8585        let admitted_graphs = admitted.iter().map(Cow::as_ref).collect::<Vec<_>>();
8586        let packages = PackageIndex::from_graphs(&admitted_graphs)?;
8587        require_eq(
8588            &packages.package_name("src/lib.rs"),
8589            &None,
8590            "invalid recovered manifest silently supplied package ownership",
8591        )?;
8592        Ok(())
8593    }
8594
8595    /// Downgrade a current fixture to the released schema-19 worktree shape.
8596    fn drop_native_worktree_identity_schema(connection: &Connection) -> rusqlite::Result<()> {
8597        connection.execute_batch(
8598            "DROP INDEX IF EXISTS idx_worktree_registrations_active_native_administrative_directory;
8599             DROP INDEX IF EXISTS idx_worktree_registrations_active_native_root;
8600             ALTER TABLE worktree_registrations DROP COLUMN git_common_directory_identity;
8601             ALTER TABLE worktree_registrations DROP COLUMN git_administrative_directory_identity;
8602             ALTER TABLE worktree_registrations DROP COLUMN last_root_identity;",
8603        )
8604    }
8605    #[cfg(unix)]
8606    fn create_directory_link(target: &Path, link: &Path) -> io::Result<()> {
8607        std::os::unix::fs::symlink(target, link)
8608    }
8609
8610    #[cfg(windows)]
8611    fn create_directory_link(target: &Path, link: &Path) -> io::Result<()> {
8612        match std::os::windows::fs::symlink_dir(target, link) {
8613            Ok(()) => Ok(()),
8614            Err(source) if source.raw_os_error() == Some(1314) => {
8615                let status = std::process::Command::new("cmd")
8616                    .arg("/C")
8617                    .arg("mklink")
8618                    .arg("/J")
8619                    .arg(link)
8620                    .arg(target)
8621                    .status()?;
8622                if status.success() {
8623                    Ok(())
8624                } else {
8625                    Err(source)
8626                }
8627            }
8628            Err(source) => Err(source),
8629        }
8630    }
8631
8632    #[cfg(unix)]
8633    fn create_file_link(target: &Path, link: &Path) -> io::Result<()> {
8634        std::os::unix::fs::symlink(target, link)
8635    }
8636
8637    #[cfg(windows)]
8638    fn create_file_link(target: &Path, link: &Path) -> io::Result<()> {
8639        std::os::windows::fs::symlink_file(target, link)
8640    }
8641
8642    #[test]
8643    fn cargo_package_ownership_uses_the_longest_repository_prefix() -> Result<(), Box<dyn Error>> {
8644        let graphs = vec![
8645            package_graph("Cargo.toml", "workspace"),
8646            package_graph("crates/member/Cargo.toml", "member"),
8647        ];
8648        let packages = PackageIndex::from_graphs(&graphs)?;
8649        require_eq(
8650            &packages.package_name("src/lib.rs"),
8651            &Some("workspace"),
8652            "root package ownership",
8653        )?;
8654        require_eq(
8655            &packages.package_name("crates/member/src/lib.rs"),
8656            &Some("member"),
8657            "nested package ownership",
8658        )?;
8659        require(
8660            repository_path_belongs_to("crates/member/src/lib.rs", "crates/member"),
8661            "member path was not owned by its package",
8662        )?;
8663        require(
8664            !repository_path_belongs_to("crates/membership/src/lib.rs", "crates/member"),
8665            "package prefix matched a partial segment",
8666        )?;
8667        require(is_cargo_manifest_path("Cargo.toml"), "root manifest")?;
8668        require(
8669            is_cargo_manifest_path("crates/member/Cargo.toml"),
8670            "nested manifest",
8671        )?;
8672        require(
8673            !is_cargo_manifest_path("docs/Cargo.toml.example"),
8674            "manifest suffix lookalike",
8675        )?;
8676        Ok(())
8677    }
8678
8679    #[test]
8680    fn incremental_projection_budget_requests_one_complete_refresh() -> Result<(), Box<dyn Error>> {
8681        let root = Path::new("repository");
8682        let affected_paths = BTreeSet::from(["src/lib.rs".to_string()]);
8683        for (rows, retained_bytes) in [
8684            (MAX_INCREMENTAL_GRAPH_ROWS + 1, 0),
8685            (0, MAX_INCREMENTAL_GRAPH_BYTES + 1),
8686        ] {
8687            let error =
8688                enforce_incremental_projection_budget(root, &affected_paths, rows, retained_bytes)
8689                    .err()
8690                    .ok_or_else(|| io::Error::other("oversized closure reached publication"))?;
8691            let CliError::RefreshRequired(report) = error else {
8692                return Err(io::Error::other(format!(
8693                    "expected typed full-refresh guidance, found {error:?}"
8694                ))
8695                .into());
8696            };
8697            require_eq(
8698                &report.reason,
8699                &IndexRefreshReason::DependencyClosureLimit,
8700                "incremental budget reason",
8701            )?;
8702            require_eq(
8703                &report.scope,
8704                &IndexRefreshScope::Full,
8705                "incremental budget scope",
8706            )?;
8707            require_eq(&report.changed, &1, "incremental changed paths")?;
8708            require_eq(
8709                &report.sample_paths,
8710                &vec!["src/lib.rs".to_string()],
8711                "incremental sample paths",
8712            )?;
8713        }
8714        Ok(())
8715    }
8716
8717    #[test]
8718    fn admitted_resolution_keys_move_once_and_count_at_budget_boundary()
8719    -> Result<(), Box<dyn Error>> {
8720        let graph = function_graph("src/lib.rs", 1);
8721        let packages = PackageIndex::from_graphs(std::slice::from_ref(&graph))?;
8722        let project = ProjectInstanceId::from_bytes([31; 16])?;
8723        let admitted_projection = projectatlas_symbols::derive_resolution_keys(
8724            project,
8725            packages.package_name(&graph.path),
8726            &graph,
8727        )?;
8728        let admitted_key_bytes = super::resolution_retained_bytes(&admitted_projection);
8729        let mut admitted = BTreeMap::from([(graph.path.clone(), admitted_projection)]);
8730        let control = IndexWorkControl::new(IndexCancellation::new(), None);
8731        let entities = build_entity_projection_with_config(
8732            project,
8733            IndexGeneration::new(1),
8734            &[],
8735            std::slice::from_ref(&graph),
8736            &packages,
8737            &ConfiguredModuleResolution::default(),
8738            Some(&mut admitted),
8739            true,
8740            &control,
8741        )?;
8742        require(
8743            admitted.is_empty(),
8744            "admitted resolution map retained moved keys",
8745        )?;
8746        require(
8747            entities.retained_bytes >= admitted_key_bytes,
8748            "entity projection budget omitted live resolution-key bytes",
8749        )?;
8750
8751        let mut registry = ProjectResolutionRegistry {
8752            retained_bytes: super::super::MAX_PUBLICATION_STAGING_BYTES
8753                .saturating_sub(entities.retained_bytes),
8754            ..ProjectResolutionRegistry::default()
8755        };
8756        require(
8757            enforce_resolution_staging_budget(&entities, &registry).is_ok(),
8758            "exact staging budget boundary was rejected",
8759        )?;
8760        registry.retained_bytes = registry.retained_bytes.saturating_add(1);
8761        require(
8762            enforce_resolution_staging_budget(&entities, &registry).is_err(),
8763            "staging budget ignored live resolution-key bytes at the boundary",
8764        )?;
8765        Ok(())
8766    }
8767
8768    #[test]
8769    fn multi_graph_resolution_peak_moves_each_entry_before_entity_allocation()
8770    -> Result<(), Box<dyn Error>> {
8771        let project = ProjectInstanceId::from_bytes([32; 16])?;
8772        let generation = IndexGeneration::new(1);
8773        let control = IndexWorkControl::new(IndexCancellation::new(), None);
8774        let graphs = vec![
8775            package_graph("Cargo.toml", "peak-workspace"),
8776            function_graph("src/target.rs", 64),
8777            function_graph("src/caller.rs", 64),
8778        ];
8779        let packages = PackageIndex::from_graphs(&graphs)?;
8780        let fresh_admitted = || {
8781            graphs
8782                .iter()
8783                .map(|graph| {
8784                    Ok::<_, Box<dyn Error>>((
8785                        graph.path.clone(),
8786                        projectatlas_symbols::derive_resolution_keys(
8787                            project,
8788                            packages.package_name(&graph.path),
8789                            graph,
8790                        )?,
8791                    ))
8792                })
8793                .collect::<Result<BTreeMap<_, _>, _>>()
8794        };
8795        let mut admitted = fresh_admitted()?;
8796        require_eq(
8797            &admitted.len(),
8798            &graphs.len(),
8799            "multi-graph admitted resolution projection count",
8800        )?;
8801        let map_bytes = resolution_projection_map_retained_bytes(&admitted);
8802        require(
8803            map_bytes > 0,
8804            "multi-graph projection map had no retained bytes",
8805        )?;
8806        let first = build_entity_projection_with_config_limit(
8807            project,
8808            generation,
8809            &[],
8810            &graphs,
8811            &packages,
8812            &ConfiguredModuleResolution::default(),
8813            Some(&mut admitted),
8814            true,
8815            &control,
8816            super::super::MAX_PUBLICATION_STAGING_BYTES,
8817        )?;
8818        require(
8819            admitted.is_empty(),
8820            "full multi-graph projection retained admitted map entries",
8821        )?;
8822        require_eq(
8823            &first.projection_removals_before_entities,
8824            &graphs
8825                .iter()
8826                .map(|graph| graph.path.clone())
8827                .collect::<Vec<_>>(),
8828            "full projection removal order",
8829        )?;
8830        require(
8831            first.peak_retained_bytes >= map_bytes,
8832            "full peak accounting omitted the admitted projection map",
8833        )?;
8834        let peak = first.peak_retained_bytes;
8835        let mut at_peak_admitted = fresh_admitted()?;
8836        let at_peak = build_entity_projection_with_config_limit(
8837            project,
8838            generation.checked_next().ok_or("generation overflow")?,
8839            &[],
8840            &graphs,
8841            &packages,
8842            &ConfiguredModuleResolution::default(),
8843            Some(&mut at_peak_admitted),
8844            true,
8845            &control,
8846            peak,
8847        )?;
8848        require(
8849            at_peak_admitted.is_empty(),
8850            "exact-peak multi-graph projection retained admitted map entries",
8851        )?;
8852        require_eq(
8853            &at_peak.projection_removals_before_entities,
8854            &graphs
8855                .iter()
8856                .map(|graph| graph.path.clone())
8857                .collect::<Vec<_>>(),
8858            "exact-peak projection removal order",
8859        )?;
8860        require(
8861            at_peak.peak_retained_bytes <= peak,
8862            "exact-peak projection exceeded the measured full peak unexpectedly",
8863        )?;
8864        let below_peak = peak.checked_sub(1).ok_or("multi-graph peak was zero")?;
8865        require_eq(
8866            &peak.saturating_sub(below_peak),
8867            &1,
8868            "multi-graph below-peak budget was not exactly one byte lower",
8869        )?;
8870        let mut below_peak_admitted = fresh_admitted()?;
8871        let below_peak_result = build_entity_projection_with_config_limit(
8872            project,
8873            generation.checked_next().ok_or("generation overflow")?,
8874            &[],
8875            &graphs,
8876            &packages,
8877            &ConfiguredModuleResolution::default(),
8878            Some(&mut below_peak_admitted),
8879            true,
8880            &control,
8881            below_peak,
8882        );
8883        require(
8884            matches!(
8885                &below_peak_result,
8886                Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
8887                    resource: IndexWorkResource::OutputBytes,
8888                    limit,
8889                    observed,
8890                    ..
8891                })) if *limit == below_peak && *observed == peak
8892            ),
8893            "one byte below the measured full peak did not fail at the measured peak",
8894        )?;
8895        Ok(())
8896    }
8897
8898    #[test]
8899    fn incremental_direct_and_inbound_keys_are_counted_once_at_budget_boundary()
8900    -> Result<(), Box<dyn Error>> {
8901        let temp = tempfile::tempdir()?;
8902        let root = temp.path().join("incremental-resolution-budget");
8903        fs::create_dir_all(root.join(".projectatlas"))?;
8904        let database = root.join(".projectatlas/projectatlas.db");
8905        let mut store = AtlasStore::open_for_project(&database, &root)?;
8906        let manifest_path = "Cargo.toml";
8907        let root_module_path = "src/lib.rs";
8908        let direct_path = "src/target.rs";
8909        let inbound_path = "src/caller.rs";
8910        let manifest_source =
8911            "[package]\nname = \"dependency-refresh\"\nversion = \"0.1.0\"\nedition = \"2021\"\n";
8912        let root_module_source = "mod caller;\nmod target;\n";
8913        let direct_source = "pub fn target() {}\n";
8914        let inbound_source = "pub fn caller() { target(); }\n";
8915        fs::create_dir_all(root.join("src"))?;
8916        fs::write(root.join(manifest_path), manifest_source)?;
8917        fs::write(root.join(root_module_path), root_module_source)?;
8918        fs::write(root.join(direct_path), direct_source)?;
8919        fs::write(root.join(inbound_path), inbound_source)?;
8920        let nodes = vec![
8921            test_file_node(manifest_path, "cargo-manifest"),
8922            test_file_node(root_module_path, "rust"),
8923            test_file_node(direct_path, "rust"),
8924            test_file_node(inbound_path, "rust"),
8925        ];
8926        let graphs = vec![
8927            extract_symbol_graph(manifest_path, Some("cargo-manifest"), manifest_source),
8928            extract_symbol_graph(root_module_path, Some("rust"), root_module_source),
8929            extract_symbol_graph(direct_path, Some("rust"), direct_source),
8930            extract_symbol_graph(inbound_path, Some("rust"), inbound_source),
8931        ];
8932        let control = IndexWorkControl::new(IndexCancellation::new(), None);
8933        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
8934        let full = stage_full_repository_graph(
8935            &store,
8936            &root,
8937            IndexGeneration::ZERO,
8938            &nodes,
8939            &scan_policy,
8940            &symbol_build_stage_for_graphs(graphs.clone()),
8941            &control,
8942        )?;
8943        publish_full_staged_graph(
8944            &mut store,
8945            &nodes,
8946            &full,
8947            &control,
8948            "incremental-budget-full",
8949        )?;
8950        for graph in &graphs {
8951            store.replace_symbol_graph(graph)?;
8952        }
8953        drop(store);
8954        let store = AtlasStore::open_for_project(&database, &root)?;
8955        let base_generation = store
8956            .index_publication()?
8957            .ok_or("incremental budget full publication is missing")?
8958            .generation;
8959        let incremental = stage_incremental_repository_graph(
8960            &store,
8961            &root,
8962            base_generation,
8963            &nodes,
8964            &[direct_path.to_string()],
8965            &scan_policy,
8966            &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
8967            &control,
8968        )?;
8969        let affected_paths = BTreeSet::from([direct_path.to_string(), inbound_path.to_string()]);
8970        require(
8971            matches!(
8972                &incremental.mutation,
8973                RepositoryGraphMutation::AffectedPaths(paths)
8974                    if paths.iter().cloned().collect::<BTreeSet<_>>() == affected_paths
8975            ),
8976            "incremental budget fixture did not include its inbound graph",
8977        )?;
8978        let expected_generation = base_generation
8979            .checked_next()
8980            .ok_or("incremental budget generation overflowed")?;
8981        for path in [&direct_path, &inbound_path] {
8982            let derivations = incremental
8983                .resolution_derivations
8984                .get(&(path.to_string(), expected_generation))
8985                .copied();
8986            require_eq(
8987                &derivations,
8988                &Some(1),
8989                "incremental direct/inbound resolution derivation count",
8990            )?;
8991        }
8992        let peak = incremental.peak_retained_bytes;
8993        require(peak > 0, "incremental projection measured no retained peak")?;
8994        let expected_removals = vec![direct_path.to_string(), inbound_path.to_string()];
8995        require_eq(
8996            &incremental.projection_removals_before_entities,
8997            &expected_removals,
8998            "incremental projection removal order",
8999        )?;
9000        let unrelated_manifest_derivations = incremental
9001            .resolution_derivations
9002            .get(&(manifest_path.to_string(), expected_generation))
9003            .copied();
9004        require_eq(
9005            &unrelated_manifest_derivations,
9006            &None,
9007            "incremental unrelated manifest resolution derivation count",
9008        )?;
9009        let unrelated_root_derivations = incremental
9010            .resolution_derivations
9011            .get(&(root_module_path.to_string(), expected_generation))
9012            .copied();
9013        require_eq(
9014            &unrelated_root_derivations,
9015            &None,
9016            "incremental unrelated root-module resolution derivation count",
9017        )?;
9018        let rows = u64::try_from(incremental.entities.len())?;
9019        let retained_bytes = incremental.retained_bytes;
9020        drop(incremental);
9021        let at_peak = stage_incremental_repository_graph_with_test_limit(
9022            &store,
9023            &root,
9024            base_generation,
9025            &nodes,
9026            &[direct_path.to_string()],
9027            &scan_policy,
9028            &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
9029            &control,
9030            peak,
9031        )?;
9032        require_eq(
9033            &at_peak.projection_removals_before_entities,
9034            &expected_removals,
9035            "exact-peak incremental projection removal order",
9036        )?;
9037        require(
9038            at_peak.peak_retained_bytes <= peak,
9039            "exact-peak incremental projection exceeded its measured peak",
9040        )?;
9041        for path in [&direct_path, &inbound_path] {
9042            require_eq(
9043                &at_peak
9044                    .resolution_derivations
9045                    .get(&(path.to_string(), expected_generation))
9046                    .copied(),
9047                &Some(1),
9048                "exact-peak incremental derivation count",
9049            )?;
9050        }
9051        require_eq(
9052            &at_peak
9053                .resolution_derivations
9054                .get(&(manifest_path.to_string(), expected_generation))
9055                .copied(),
9056            &None,
9057            "exact-peak unrelated manifest derivation count",
9058        )?;
9059        require_eq(
9060            &at_peak
9061                .resolution_derivations
9062                .get(&(root_module_path.to_string(), expected_generation))
9063                .copied(),
9064            &None,
9065            "exact-peak unrelated root-module derivation count",
9066        )?;
9067        drop(at_peak);
9068        let below_peak = peak.checked_sub(1).ok_or("incremental peak was zero")?;
9069        require_eq(
9070            &peak.saturating_sub(below_peak),
9071            &1,
9072            "incremental below-peak budget was not exactly one byte lower",
9073        )?;
9074        let below_peak_result = stage_incremental_repository_graph_with_test_limit(
9075            &store,
9076            &root,
9077            base_generation,
9078            &nodes,
9079            &[direct_path.to_string()],
9080            &scan_policy,
9081            &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
9082            &control,
9083            below_peak,
9084        );
9085        require(
9086            matches!(
9087                &below_peak_result,
9088                Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
9089                    resource: IndexWorkResource::OutputBytes,
9090                    limit,
9091                    observed,
9092                    ..
9093                })) if *limit == below_peak && *observed == peak
9094            ),
9095            "one byte below the measured incremental peak did not fail at the measured peak",
9096        )?;
9097        require(
9098            retained_bytes > 0,
9099            "incremental budget fixture did not retain projected key bytes",
9100        )?;
9101        require(
9102            enforce_incremental_projection_budget(&root, &affected_paths, rows, retained_bytes)
9103                .is_ok(),
9104            "incremental budget rejected actual direct and inbound projection",
9105        )?;
9106        let available = MAX_INCREMENTAL_GRAPH_BYTES.saturating_sub(retained_bytes);
9107        require(
9108            enforce_incremental_projection_budget(
9109                &root,
9110                &affected_paths,
9111                rows,
9112                retained_bytes.saturating_add(available),
9113            )
9114            .is_ok(),
9115            "incremental exact byte boundary was rejected",
9116        )?;
9117        require(
9118            enforce_incremental_projection_budget(
9119                &root,
9120                &affected_paths,
9121                rows,
9122                retained_bytes.saturating_add(available).saturating_add(1),
9123            )
9124            .is_err(),
9125            "incremental byte boundary ignored one additional byte",
9126        )?;
9127        Ok(())
9128    }
9129
9130    #[test]
9131    fn incremental_projection_budget_combines_old_and_new_work() -> Result<(), Box<dyn Error>> {
9132        let temp = tempfile::tempdir()?;
9133        let root = temp.path();
9134        let affected_paths = BTreeSet::from(["src/lib.rs".to_string()]);
9135        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9136        let persisted = RepositoryAffectedSourceFootprint {
9137            rows: MAX_INCREMENTAL_GRAPH_ROWS,
9138            retained_bytes: 0,
9139            truncated: false,
9140        };
9141        let staged = StagedRepositoryGraph {
9142            project: ProjectInstanceId::from_bytes([1; 16])?,
9143            mutation: RepositoryGraphMutation::AffectedPaths(vec!["src/lib.rs".to_string()]),
9144            entities: Vec::new(),
9145            relations: Vec::new(),
9146            occurrences: Vec::new(),
9147            coverage: Vec::new(),
9148            entity_exports: Vec::new(),
9149            relation_dependencies: Vec::new(),
9150            document_unresolved_reasons: Vec::new(),
9151            identity_rejections: Vec::new(),
9152            resolution_derivations: BTreeMap::new(),
9153            peak_retained_bytes: 0,
9154            projection_removals_before_entities: Vec::new(),
9155            scan_policy: RootScanPolicy::discover(root, &ScanOptions::default(), &control)?,
9156            document_target_states: Vec::new(),
9157            database: None,
9158            retained_bytes: 0,
9159        };
9160        let error =
9161            enforce_incremental_projection_limits(root, &affected_paths, persisted, &staged)
9162                .err()
9163                .ok_or_else(|| io::Error::other("combined old and new work was admitted"))?;
9164        let CliError::RefreshRequired(report) = error else {
9165            return Err(io::Error::other(format!(
9166                "expected typed full-refresh guidance, found {error:?}"
9167            ))
9168            .into());
9169        };
9170        require_eq(
9171            &report.reason,
9172            &IndexRefreshReason::DependencyClosureLimit,
9173            "combined budget reason",
9174        )?;
9175        require_eq(
9176            &report.scope,
9177            &IndexRefreshScope::Full,
9178            "combined budget scope",
9179        )?;
9180        Ok(())
9181    }
9182
9183    #[test]
9184    fn document_projection_memory_budget_includes_reason_validation_state()
9185    -> Result<(), Box<dyn Error>> {
9186        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9187        let facts: BTreeMap<String, Cow<'static, projectatlas_symbols::MarkdownFacts>> = (0..12)
9188            .map(|document| {
9189                let source = (0..1_024)
9190                    .map(|link| format!("[missing]({document}-{link}.md)"))
9191                    .collect::<Vec<_>>()
9192                    .join("\n");
9193                (
9194                    format!("docs/source-{document}.md"),
9195                    Cow::Owned(projectatlas_symbols::extract_markdown_facts(&source)),
9196                )
9197            })
9198            .collect::<BTreeMap<_, _>>();
9199        let candidate_count = facts
9200            .values()
9201            .map(|document| document.link_candidates.len())
9202            .sum::<usize>();
9203        require_eq(&candidate_count, &12_288, "document candidate fixture size")?;
9204        let fact_bytes = document_fact_map_retained_bytes(&facts);
9205        let projection_bytes = document_projection_retained_bytes(&facts, &control)?;
9206        require(
9207            projection_bytes >= u64::try_from(candidate_count)? * DOCUMENT_PROJECTION_ROW_BYTES,
9208            "generated document projection state was under-accounted",
9209        )?;
9210        require(
9211            fact_bytes.saturating_add(projection_bytes) < 512 * 1_024 * 1_024,
9212            "bounded document projection fixture exceeded the in-memory envelope",
9213        )?;
9214        Ok(())
9215    }
9216
9217    #[test]
9218    fn document_projection_estimator_deadline_preserves_generation_and_allows_retry()
9219    -> Result<(), Box<dyn Error>> {
9220        const DOCUMENT_COUNT: usize = 513;
9221        const CANDIDATES_PER_DOCUMENT: usize = 1_024;
9222        let temp = tempfile::tempdir()?;
9223        let root = fs::canonicalize(temp.path())?;
9224        let database = root.join("projectatlas.db");
9225        let mut store = AtlasStore::open_for_project(&database, &root)?;
9226        let project = store
9227            .project_instance_id()?
9228            .ok_or("estimator cancellation project identity is missing")?;
9229        let publication_control = IndexWorkControl::new(IndexCancellation::new(), None);
9230        let baseline_graph = function_graph("src/lib.rs", 1);
9231        let baseline_packages = PackageIndex::from_graphs(std::slice::from_ref(&baseline_graph))?;
9232        let baseline_entities = build_entity_projection(
9233            project,
9234            IndexGeneration::new(1),
9235            &[],
9236            std::slice::from_ref(&baseline_graph),
9237            &baseline_packages,
9238            true,
9239            &publication_control,
9240        )?;
9241        let baseline_candidates =
9242            resolution_registry_from_exports(&baseline_entities, &publication_control)?;
9243        let baseline_staged = finish_projection(
9244            project,
9245            IndexGeneration::new(1),
9246            RepositoryGraphMutation::Full,
9247            std::slice::from_ref(&baseline_graph),
9248            baseline_entities,
9249            &baseline_candidates,
9250            &publication_control,
9251        )?;
9252        let baseline_node = test_file_node("src/lib.rs", "rust");
9253        {
9254            let mut publication = store.begin_index_publication("estimator-cancellation")?;
9255            publication.begin_scan_replacement()?;
9256            publication.upsert_scan_node_batch(std::slice::from_ref(&baseline_node))?;
9257            publication.finish_scan_replacement()?;
9258            baseline_staged.apply(&mut publication, &publication_control)?;
9259            publication.complete()?;
9260        }
9261        let publication_before = store
9262            .index_publication()?
9263            .ok_or("estimator cancellation baseline publication is missing")?;
9264
9265        let document_facts: BTreeMap<String, Cow<'_, projectatlas_symbols::MarkdownFacts>> = (0
9266            ..DOCUMENT_COUNT)
9267            .map(|document| {
9268                let path = format!("content/source-{document:03}.md");
9269                let file_name = path
9270                    .rsplit_once('/')
9271                    .map_or(path.as_str(), |(_parent, file_name)| file_name);
9272                let source = (0..CANDIDATES_PER_DOCUMENT)
9273                    .map(|index| format!("[self-{index:04}]({file_name})"))
9274                    .collect::<Vec<_>>()
9275                    .join("\n");
9276                (
9277                    path,
9278                    Cow::Owned(projectatlas_symbols::extract_markdown_facts(&source)),
9279                )
9280            })
9281            .collect::<BTreeMap<_, _>>();
9282        let candidate_count = document_facts
9283            .values()
9284            .map(|facts| facts.link_candidates.len())
9285            .sum::<usize>();
9286        require_eq(
9287            &candidate_count,
9288            &(DOCUMENT_COUNT * CANDIDATES_PER_DOCUMENT),
9289            "estimator cancellation candidate count",
9290        )?;
9291
9292        let expired_control =
9293            IndexWorkControl::with_deadline(IndexCancellation::new(), Instant::now());
9294        let error = document_projection_retained_bytes(&document_facts, &expired_control)
9295            .err()
9296            .ok_or("expired estimator unexpectedly traversed all candidates")?;
9297        require(
9298            matches!(
9299                error,
9300                CliError::IndexWork(IndexWorkFailure::DeadlineExceeded {
9301                    stage: IndexWorkStage::SymbolParsing,
9302                })
9303            ),
9304            "estimator deadline did not return its typed graph-work failure",
9305        )?;
9306        require_eq(
9307            &store.index_publication()?,
9308            &Some(publication_before.clone()),
9309            "estimator deadline changed the current generation",
9310        )?;
9311
9312        let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
9313        let retained_bytes = document_projection_retained_bytes(&document_facts, &retry_control)?;
9314        require_eq(
9315            &retained_bytes,
9316            &0,
9317            "same-file candidates entered the retained projection estimate",
9318        )?;
9319        let retry_graphs = document_facts
9320            .iter()
9321            .map(|(path, facts)| facts.symbol_graph(path, Some("markdown")))
9322            .collect::<Vec<_>>();
9323        let retry_nodes = document_facts
9324            .keys()
9325            .map(|path| test_file_node(path, "markdown"))
9326            .collect::<Vec<_>>();
9327        let retry_scan_policy =
9328            RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
9329        let retry_packages = PackageIndex::from_graphs(&retry_graphs)?;
9330        let retry_entities = build_entity_projection(
9331            project,
9332            IndexGeneration::new(2),
9333            &retry_nodes,
9334            &retry_graphs,
9335            &retry_packages,
9336            false,
9337            &retry_control,
9338        )?;
9339        let retry_candidates = resolution_registry_from_exports(&retry_entities, &retry_control)?;
9340        let retried_projection = finish_projection_with_documents(
9341            project,
9342            IndexGeneration::new(2),
9343            RepositoryGraphMutation::Full,
9344            &retry_graphs,
9345            &root,
9346            &retry_nodes,
9347            &document_facts,
9348            &GraphIdentityAdmission::default(),
9349            retry_entities,
9350            &retry_candidates,
9351            &retry_scan_policy,
9352            &retry_control,
9353        )?;
9354        require(
9355            retried_projection.relations.is_empty()
9356                && retried_projection.document_unresolved_reasons.is_empty(),
9357            "same-file retry emitted document projection rows",
9358        )?;
9359        require_eq(
9360            &store.index_publication()?,
9361            &Some(publication_before),
9362            "estimator retry changed the current generation without publication",
9363        )?;
9364        Ok(())
9365    }
9366
9367    #[test]
9368    fn incremental_document_projection_overflow_requests_full_refresh_staging()
9369    -> Result<(), Box<dyn Error>> {
9370        const DOCUMENT_COUNT: usize = 513;
9371        let temp = tempfile::tempdir()?;
9372        let root = fs::canonicalize(temp.path())?;
9373        let database = root.join("projectatlas.db");
9374        let mut store = AtlasStore::open_for_project(&database, &root)?;
9375        let project = store
9376            .project_instance_id()?
9377            .ok_or("incremental document budget project identity is missing")?;
9378        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9379        let template = projectatlas_symbols::extract_markdown_facts("[missing](shared.md)");
9380        let candidate = template
9381            .link_candidates
9382            .first()
9383            .cloned()
9384            .ok_or("document budget candidate fixture is missing")?;
9385        let mut template = template;
9386        while template.link_candidates.len() < MAX_DOCUMENT_LINK_CANDIDATES {
9387            template.link_candidates.push(candidate.clone());
9388        }
9389
9390        let mut nodes = Vec::with_capacity(DOCUMENT_COUNT);
9391        let mut graphs = Vec::with_capacity(DOCUMENT_COUNT);
9392        let mut changes = Vec::with_capacity(DOCUMENT_COUNT);
9393        let mut paths = Vec::with_capacity(DOCUMENT_COUNT);
9394        for document in 0..DOCUMENT_COUNT {
9395            let path = format!("content/source-{document:03}.md");
9396            let graph = template.symbol_graph(&path, Some("markdown"));
9397            nodes.push(test_file_node(&path, "markdown"));
9398            paths.push(path.clone());
9399            graphs.push(graph.clone());
9400            changes.push(SymbolProjectionChange::Parsed(SymbolParseSuccess {
9401                path,
9402                graph,
9403                markdown_facts: Some(Box::new(template.clone())),
9404                source_parser: ParserKind::Structural,
9405                summary: String::new(),
9406                summary_is_structural: true,
9407                purpose_suggestion: None,
9408            }));
9409        }
9410        store.replace_scan(&nodes)?;
9411        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
9412        let packages = PackageIndex::from_graphs(&graphs)?;
9413        let entities = build_entity_projection(
9414            project,
9415            IndexGeneration::new(1),
9416            &nodes,
9417            &graphs,
9418            &packages,
9419            false,
9420            &control,
9421        )?;
9422        let candidates = resolution_registry_from_exports(&entities, &control)?;
9423        let projected_facts = paths
9424            .iter()
9425            .map(|path| (path.clone(), Cow::Borrowed(&template)))
9426            .collect::<BTreeMap<_, _>>();
9427        let pre_document_bytes = entities
9428            .retained_bytes
9429            .saturating_add(candidates.retained_bytes)
9430            .saturating_add(document_fact_map_retained_bytes(&projected_facts));
9431        let document_projection_bytes =
9432            document_projection_retained_bytes(&projected_facts, &control)?;
9433        require(
9434            pre_document_bytes < MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9435            "pre-document incremental state already exceeded the staging budget",
9436        )?;
9437        require(
9438            pre_document_bytes.saturating_add(document_projection_bytes)
9439                > MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9440            "document projection did not cross the aggregate staging budget",
9441        )?;
9442
9443        let mut symbols = empty_symbol_build_stage();
9444        symbols.changes = changes;
9445        let direct_paths = paths.clone();
9446        let error = stage_incremental_repository_graph(
9447            &store,
9448            &root,
9449            IndexGeneration::new(0),
9450            &nodes,
9451            &direct_paths,
9452            &scan_policy,
9453            &symbols,
9454            &control,
9455        )
9456        .err()
9457        .ok_or("document projection overflow was admitted incrementally")?;
9458        let CliError::RefreshRequired(report) = error else {
9459            return Err(io::Error::other(format!(
9460                "expected typed full-refresh guidance, found {error:?}"
9461            ))
9462            .into());
9463        };
9464        require_eq(
9465            &report.reason,
9466            &IndexRefreshReason::DependencyClosureLimit,
9467            "document projection overflow reason",
9468        )?;
9469        require_eq(
9470            &report.scope,
9471            &IndexRefreshScope::Full,
9472            "document projection overflow scope",
9473        )?;
9474
9475        let staged = stage_full_repository_graph(
9476            &store,
9477            &root,
9478            IndexGeneration::new(0),
9479            &nodes,
9480            &scan_policy,
9481            &symbols,
9482            &control,
9483        )?;
9484        require(
9485            staged.database.is_some(),
9486            "full-refresh guidance did not select disposable SQLite staging",
9487        )?;
9488        Ok(())
9489    }
9490
9491    #[test]
9492    fn resolution_registry_reuses_staged_export_entities() -> Result<(), Box<dyn Error>> {
9493        let project = ProjectInstanceId::from_bytes([2; 16])?;
9494        let generation = IndexGeneration::new(1);
9495        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9496        let graphs = vec![
9497            extract_symbol_graph(
9498                "Cargo.toml",
9499                Some("cargo-manifest"),
9500                "[package]\nname = \"atlas\"\n",
9501            ),
9502            extract_symbol_graph("src/lib.rs", Some("rust"), "pub fn run() {}\n"),
9503        ];
9504        let packages = PackageIndex::from_graphs(&graphs)?;
9505        let projection =
9506            build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
9507        let registry = resolution_registry_from_exports(&projection, &control)?;
9508        let registered_bindings = registry
9509            .candidate_digests_by_key
9510            .values()
9511            .map(BTreeSet::len)
9512            .sum::<usize>();
9513
9514        require_eq(
9515            &registry.supplemental_entities_by_digest.len(),
9516            &0,
9517            "duplicate registry-owned entity count",
9518        )?;
9519        require_eq(
9520            &registered_bindings,
9521            &projection.entity_exports.len(),
9522            "registry key binding count",
9523        )?;
9524        require(
9525            registry
9526                .candidate_digests_by_key
9527                .values()
9528                .flatten()
9529                .all(|digest| projection.entity_by_digest.contains_key(digest)),
9530            "resolution key referenced an unstaged entity digest",
9531        )?;
9532        let mut bindings_per_entity = BTreeMap::<&str, usize>::new();
9533        for digest in registry.candidate_digests_by_key.values().flatten() {
9534            *bindings_per_entity.entry(digest).or_default() += 1;
9535        }
9536        require(
9537            bindings_per_entity.values().any(|count| *count > 1),
9538            "fixture did not exercise one entity exported under multiple canonical keys",
9539        )?;
9540        Ok(())
9541    }
9542
9543    #[test]
9544    fn restart_cleanup_removes_only_inactive_owned_graph_stages() -> Result<(), Box<dyn Error>> {
9545        let temp = tempfile::tempdir()?;
9546        let root = temp.path().join("restart-cleanup");
9547        let atlas_dir = root.join(".projectatlas");
9548        fs::create_dir_all(&atlas_dir)?;
9549        let database = atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9550        let store = AtlasStore::open_for_project(&database, &root)?;
9551        let project = store
9552            .project_instance_id()?
9553            .ok_or("bound project identity is missing")?;
9554
9555        let owned = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}owned"));
9556        fs::create_dir(&owned)?;
9557        let owned_database = owned.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9558        drop(AtlasStore::create_repository_graph_staging(
9559            &owned_database,
9560            &root,
9561            project,
9562        )?);
9563        let owned_payload = owned.join("payload");
9564        fs::create_dir(&owned_payload)?;
9565        fs::write(owned_payload.join("row"), "discard")?;
9566        let owned_link_target = temp.path().join("owned-link-target");
9567        fs::create_dir(&owned_link_target)?;
9568        fs::write(owned_link_target.join("sentinel"), "preserve")?;
9569        let owned_payload_link = owned.join("linked-payload");
9570        create_directory_link(&owned_link_target, &owned_payload_link)?;
9571        let interrupted_shell =
9572            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}interrupted-shell"));
9573        fs::create_dir(&interrupted_shell)?;
9574        let unvalidated_nonempty = atlas_dir.join(format!(
9575            "{GRAPH_STAGE_DIRECTORY_PREFIX}unvalidated-nonempty"
9576        ));
9577        fs::create_dir(&unvalidated_nonempty)?;
9578        fs::write(unvalidated_nonempty.join("sentinel"), "preserve")?;
9579        let lookalike = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}lookalike"));
9580        fs::create_dir(&lookalike)?;
9581        drop(AtlasStore::open_for_project(
9582            &lookalike.join(GRAPH_STAGE_DATABASE_FILE_NAME),
9583            &root,
9584        )?);
9585        let foreign_root = temp.path().join("foreign-project");
9586        fs::create_dir(&foreign_root)?;
9587        let foreign_store =
9588            AtlasStore::open_for_project(&foreign_root.join("projectatlas.db"), &foreign_root)?;
9589        let foreign_project = foreign_store
9590            .project_instance_id()?
9591            .ok_or("foreign project identity is missing")?;
9592        let foreign_project_stage =
9593            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}foreign-project"));
9594        fs::create_dir(&foreign_project_stage)?;
9595        drop(AtlasStore::create_repository_graph_staging(
9596            &foreign_project_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
9597            &root,
9598            foreign_project,
9599        )?);
9600        let foreign_root_stage =
9601            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}foreign-root"));
9602        fs::create_dir(&foreign_root_stage)?;
9603        drop(AtlasStore::create_repository_graph_staging(
9604            &foreign_root_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
9605            &foreign_root,
9606            project,
9607        )?);
9608        let linked_stage_target = temp.path().join("linked-stage-target");
9609        fs::create_dir(&linked_stage_target)?;
9610        fs::write(linked_stage_target.join("sentinel"), "preserve")?;
9611        drop(AtlasStore::create_repository_graph_staging(
9612            &linked_stage_target.join(GRAPH_STAGE_DATABASE_FILE_NAME),
9613            &root,
9614            project,
9615        )?);
9616        let linked_stage = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}linked"));
9617        create_directory_link(&linked_stage_target, &linked_stage)?;
9618
9619        let linked_database_target = temp.path().join("linked-stage-database.db");
9620        drop(AtlasStore::create_repository_graph_staging(
9621            &linked_database_target,
9622            &root,
9623            project,
9624        )?);
9625        let linked_database_stage =
9626            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}linked-database"));
9627        fs::create_dir(&linked_database_stage)?;
9628        let linked_database = linked_database_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9629        let linked_database_created =
9630            match create_file_link(&linked_database_target, &linked_database) {
9631                Ok(()) => true,
9632                #[cfg(windows)]
9633                Err(source) if source.raw_os_error() == Some(1314) => false,
9634                Err(source) => return Err(source.into()),
9635            };
9636        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9637
9638        let lease = try_graph_stage_lease(&atlas_dir)?
9639            .ok_or("test could not acquire graph staging lease")?;
9640        remove_owned_graph_stage_payload(&owned, &owned_database, Some(&control))?;
9641        require(
9642            owned_database.is_file() && !owned_payload.exists(),
9643            "payload cleanup did not retain the ownership database until last",
9644        )?;
9645        require(
9646            fs::symlink_metadata(&owned_payload_link).is_err()
9647                && owned_link_target.join("sentinel").is_file(),
9648            "payload cleanup followed or retained a linked child",
9649        )?;
9650        cleanup_abandoned_graph_staging(&root, project, &control)?;
9651        require(
9652            owned.exists(),
9653            "restart cleanup removed an actively leased stage",
9654        )?;
9655        drop(lease);
9656
9657        let canceled_control = IndexWorkControl::new(IndexCancellation::new(), None);
9658        canceled_control.cancel();
9659        let canceled = cleanup_abandoned_graph_staging(&root, project, &canceled_control)
9660            .err()
9661            .ok_or("canceled restart cleanup unexpectedly succeeded")?;
9662        require(
9663            matches!(
9664                canceled,
9665                CliError::IndexWork(IndexWorkFailure::Cancelled {
9666                    stage: IndexWorkStage::Publication
9667                })
9668            ),
9669            "restart cleanup did not preserve typed cancellation",
9670        )?;
9671        require(
9672            owned.exists(),
9673            "canceled restart cleanup removed an owned stage",
9674        )?;
9675
9676        cleanup_abandoned_graph_staging(&root, project, &control)?;
9677        require(
9678            !owned.exists(),
9679            "restart cleanup retained an inactive owned stage",
9680        )?;
9681        require(
9682            !interrupted_shell.exists(),
9683            "restart cleanup retained an empty interrupted stage shell",
9684        )?;
9685        require(
9686            unvalidated_nonempty.join("sentinel").is_file(),
9687            "restart cleanup removed a non-empty unvalidated stage",
9688        )?;
9689        require(
9690            lookalike.exists(),
9691            "restart cleanup removed an unvalidated lookalike stage",
9692        )?;
9693        require(
9694            foreign_project_stage.exists(),
9695            "restart cleanup removed a valid stage owned by another project",
9696        )?;
9697        require(
9698            foreign_root_stage.exists(),
9699            "restart cleanup removed a valid stage bound to another root",
9700        )?;
9701        require(
9702            fs::symlink_metadata(&linked_stage).is_ok()
9703                && linked_stage_target.join("sentinel").is_file(),
9704            "restart cleanup followed a linked stage directory",
9705        )?;
9706        if linked_database_created {
9707            require(
9708                fs::symlink_metadata(&linked_database).is_ok() && linked_database_target.is_file(),
9709                "restart cleanup followed a linked staging database",
9710            )?;
9711        }
9712        Ok(())
9713    }
9714
9715    #[test]
9716    fn restart_cleanup_reclaims_schema_nineteen_owned_graph_stage() -> Result<(), Box<dyn Error>> {
9717        let temp = tempfile::tempdir()?;
9718        let root = temp.path().join("schema-19-restart-cleanup");
9719        let atlas_dir = root.join(".projectatlas");
9720        fs::create_dir_all(&atlas_dir)?;
9721        let main_database = atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9722        let store = AtlasStore::open_for_project(&main_database, &root)?;
9723        let project = store
9724            .project_instance_id()?
9725            .ok_or("bound project identity is missing")?;
9726        let prepare_schema_nineteen =
9727            |stage: &Path, stage_root: &Path, stage_project: ProjectInstanceId| {
9728                fs::create_dir(stage)?;
9729                let database = stage.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9730                drop(AtlasStore::create_repository_graph_staging(
9731                    &database,
9732                    stage_root,
9733                    stage_project,
9734                )?);
9735                let connection = Connection::open(database)?;
9736                drop_native_worktree_identity_schema(&connection)?;
9737                connection.execute_batch(
9738                    "DROP TABLE project_root_identity;
9739                 DROP TABLE IF EXISTS graph_identity_rejections;
9740                 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
9741                )?;
9742                Ok::<(), Box<dyn Error>>(())
9743            };
9744
9745        let owned = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}schema19-owned"));
9746        prepare_schema_nineteen(&owned, &root, project)?;
9747        let owned_database = owned.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9748        let owned_matches =
9749            AtlasStore::repository_graph_staging_belongs_to(&owned_database, &root, project)?;
9750        #[cfg(windows)]
9751        require(
9752            owned_matches,
9753            "schema-19 owned staging database was not admitted",
9754        )?;
9755        #[cfg(unix)]
9756        require(
9757            owned_matches,
9758            "schema-19 staging database was not admitted by its durable staging ownership",
9759        )?;
9760        fs::write(owned.join("large-graph-payload"), b"stale graph payload")?;
9761
9762        let foreign_root = temp.path().join("schema-19-foreign-root");
9763        fs::create_dir(&foreign_root)?;
9764        let foreign = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}schema19-foreign"));
9765        let foreign_root_project = ProjectInstanceId::from_bytes([9; 16])?;
9766        prepare_schema_nineteen(&foreign, &foreign_root, foreign_root_project)?;
9767        let foreign_database = foreign.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9768        require(
9769            !AtlasStore::repository_graph_staging_belongs_to(&foreign_database, &root, project)?,
9770            "schema-19 unrelated-root staging database was admitted",
9771        )?;
9772
9773        let schema_eighteen = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}schema18"));
9774        prepare_schema_nineteen(&schema_eighteen, &root, project)?;
9775        let schema_eighteen_database = schema_eighteen.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9776        let connection = Connection::open(&schema_eighteen_database)?;
9777        connection.execute(
9778            "UPDATE metadata SET value = '18' WHERE key = 'schema_version'",
9779            [],
9780        )?;
9781        require(
9782            !AtlasStore::repository_graph_staging_belongs_to(
9783                &schema_eighteen_database,
9784                &root,
9785                project,
9786            )?,
9787            "schema-18 staging database was admitted as a schema-19 predecessor",
9788        )?;
9789
9790        let incomplete_current =
9791            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}current-incomplete"));
9792        fs::create_dir(&incomplete_current)?;
9793        let incomplete_current_database = incomplete_current.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9794        drop(AtlasStore::create_repository_graph_staging(
9795            &incomplete_current_database,
9796            &root,
9797            project,
9798        )?);
9799        let connection = Connection::open(&incomplete_current_database)?;
9800        connection.execute_batch(
9801            "DROP TABLE project_root_identity; DROP TABLE IF EXISTS graph_identity_rejections;",
9802        )?;
9803        require(
9804            !AtlasStore::repository_graph_staging_belongs_to(
9805                &incomplete_current_database,
9806                &root,
9807                project,
9808            )
9809            .unwrap_or(false),
9810            "current staging database without native identity was admitted",
9811        )?;
9812
9813        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9814        cleanup_abandoned_graph_staging(&root, project, &control)?;
9815        #[cfg(windows)]
9816        require(
9817            !owned.exists(),
9818            "restart cleanup retained an owned schema-19 staging database",
9819        )?;
9820        #[cfg(unix)]
9821        require(
9822            !owned.exists(),
9823            "restart cleanup retained an owned schema-19 staging database",
9824        )?;
9825        require(
9826            foreign.exists(),
9827            "restart cleanup removed an unrelated schema-19 staging database",
9828        )?;
9829        require(
9830            schema_eighteen.exists(),
9831            "restart cleanup removed a non-predecessor schema-18 staging database",
9832        )?;
9833        require(
9834            incomplete_current.exists(),
9835            "restart cleanup removed a current staging database without native identity",
9836        )?;
9837        Ok(())
9838    }
9839
9840    #[test]
9841    fn restart_cleanup_observes_cancellation_between_owned_stages() -> Result<(), Box<dyn Error>> {
9842        const STAGE_COUNT: usize = 64;
9843        const FILES_PER_STAGE: usize = 64;
9844
9845        let temp = tempfile::tempdir()?;
9846        let root = temp.path().join("restart-cleanup-cancellation");
9847        let atlas_dir = root.join(".projectatlas");
9848        fs::create_dir_all(&atlas_dir)?;
9849        let store =
9850            AtlasStore::open_for_project(&atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME), &root)?;
9851        let project = store
9852            .project_instance_id()?
9853            .ok_or("bound project identity is missing")?;
9854        let mut stages = Vec::with_capacity(STAGE_COUNT);
9855        for stage_index in 0..STAGE_COUNT {
9856            let stage = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}{stage_index:03}"));
9857            fs::create_dir(&stage)?;
9858            drop(AtlasStore::create_repository_graph_staging(
9859                &stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
9860                &root,
9861                project,
9862            )?);
9863            let payload = stage.join("payload");
9864            fs::create_dir(&payload)?;
9865            for file_index in 0..FILES_PER_STAGE {
9866                fs::write(payload.join(format!("{file_index:03}")), b"x")?;
9867            }
9868            stages.push(stage);
9869        }
9870
9871        let cancellation = IndexCancellation::new();
9872        let control = IndexWorkControl::new(cancellation.clone(), None);
9873        let worker_root = root;
9874        let worker =
9875            thread::spawn(move || cleanup_abandoned_graph_staging(&worker_root, project, &control));
9876        let observation_deadline = Instant::now() + Duration::from_secs(30);
9877        loop {
9878            let remaining = stages.iter().filter(|stage| stage.exists()).count();
9879            if remaining < STAGE_COUNT {
9880                cancellation.cancel();
9881                break;
9882            }
9883            if worker.is_finished() {
9884                return Err(io::Error::other(
9885                    "restart cleanup completed before in-flight cancellation was observed",
9886                )
9887                .into());
9888            }
9889            if Instant::now() >= observation_deadline {
9890                cancellation.cancel();
9891                return Err(io::Error::other(
9892                    "restart cleanup removed no stage within the test deadline",
9893                )
9894                .into());
9895            }
9896            thread::yield_now();
9897        }
9898        let result = worker
9899            .join()
9900            .map_err(|_panic| io::Error::other("restart cleanup worker panicked"))?;
9901        require(
9902            matches!(
9903                result,
9904                Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
9905                    stage: IndexWorkStage::Publication
9906                }))
9907            ),
9908            "restart cleanup did not return typed in-flight cancellation",
9909        )?;
9910        let remaining = stages.iter().filter(|stage| stage.exists()).count();
9911        require(
9912            remaining > 0 && remaining < STAGE_COUNT,
9913            "in-flight cancellation did not preserve a partial cleanup boundary",
9914        )
9915    }
9916
9917    #[test]
9918    fn staged_database_owner_retains_incomplete_creation() -> Result<(), Box<dyn Error>> {
9919        let temp = tempfile::tempdir()?;
9920        let atlas_dir = temp.path().join(".projectatlas");
9921        fs::create_dir(&atlas_dir)?;
9922        let directory = tempfile::Builder::new()
9923            .prefix(GRAPH_STAGE_DIRECTORY_PREFIX)
9924            .tempdir_in(&atlas_dir)?;
9925        let staging_path = directory.path().to_path_buf();
9926        fs::write(
9927            staging_path.join(GRAPH_STAGE_DATABASE_FILE_NAME),
9928            "incomplete",
9929        )?;
9930        fs::write(staging_path.join("payload"), "preserve")?;
9931        let lease = try_graph_stage_lease(&atlas_dir)?
9932            .ok_or("test could not acquire graph staging lease")?;
9933        let owner = super::StagedGraphDatabase {
9934            store: None,
9935            directory: Some(directory),
9936            _lease: lease,
9937        };
9938
9939        drop(owner);
9940
9941        require(
9942            staging_path.join(GRAPH_STAGE_DATABASE_FILE_NAME).is_file()
9943                && staging_path.join("payload").is_file(),
9944            "incomplete staging creation was recursively deleted",
9945        )
9946    }
9947
9948    #[test]
9949    fn database_staging_publishes_and_removes_its_disposable_store() -> Result<(), Box<dyn Error>> {
9950        let temp = tempfile::tempdir()?;
9951        let root = temp.path().join("database-staging");
9952        let atlas_dir = root.join(".projectatlas");
9953        fs::create_dir_all(&atlas_dir)?;
9954        let database = atlas_dir.join("projectatlas.db");
9955        let mut store = AtlasStore::open_for_project(&database, &root)?;
9956        let project = store
9957            .project_instance_id()?
9958            .ok_or("bound project identity is missing")?;
9959        let generation = IndexGeneration::new(1);
9960        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9961        let graphs = vec![extract_symbol_graph(
9962            "src/lib.rs",
9963            Some("rust"),
9964            "pub fn caller() { helper(); }\nfn helper() {}\n",
9965        )];
9966        let nodes = vec![test_file_node("src/lib.rs", "rust")];
9967        let packages = PackageIndex::from_graphs(&graphs)?;
9968        let projection = build_entity_projection(
9969            project, generation, &nodes, &graphs, &packages, true, &control,
9970        )?;
9971        let candidates = resolution_registry_from_exports(&projection, &control)?;
9972        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
9973        let staged = finish_projection_in_database(
9974            &root,
9975            &nodes,
9976            project,
9977            generation,
9978            &graphs,
9979            projection,
9980            &candidates,
9981            &scan_policy,
9982            &control,
9983        )?;
9984        let staging_path = staged
9985            .database
9986            .as_ref()
9987            .ok_or("database staging was not selected")?
9988            .directory()?
9989            .path()
9990            .to_path_buf();
9991        let drop_payload = staging_path.join("drop-payload");
9992        fs::create_dir(&drop_payload)?;
9993        fs::write(drop_payload.join("row"), "discard")?;
9994        let drop_link_target = temp.path().join("drop-link-target");
9995        fs::create_dir(&drop_link_target)?;
9996        fs::write(drop_link_target.join("sentinel"), "preserve")?;
9997        create_directory_link(&drop_link_target, &staging_path.join("drop-linked-payload"))?;
9998        require(
9999            staging_path.exists(),
10000            "database staging directory is missing",
10001        )?;
10002        {
10003            let mut publication = store.begin_index_publication("database-staging")?;
10004            publication.begin_scan_replacement()?;
10005            publication.upsert_scan_node_batch(&nodes)?;
10006            publication.finish_scan_replacement()?;
10007            staged.apply(&mut publication, &control)?;
10008            publication.complete()?;
10009        }
10010        drop(staged);
10011        require(
10012            !staging_path.exists(),
10013            "database staging directory survived publication",
10014        )?;
10015        require(
10016            drop_link_target.join("sentinel").is_file(),
10017            "database staging drop followed a linked payload",
10018        )?;
10019        drop(store);
10020
10021        let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
10022        let coverage = reader.repository_graph_coverage(
10023            project,
10024            &CoverageScope::Path {
10025                path: RepositoryNodePath::new(Path::new("src/lib.rs"))?,
10026            },
10027            8,
10028        )?;
10029        require(
10030            !coverage.truncated,
10031            "database-staged coverage was truncated",
10032        )?;
10033        require(
10034            !coverage.rows.is_empty(),
10035            "database-staged graph rows were not published",
10036        )?;
10037        Ok(())
10038    }
10039
10040    #[test]
10041    fn staged_identity_rejections_count_toward_normal_and_database_bytes()
10042    -> Result<(), Box<dyn Error>> {
10043        let temp = tempfile::tempdir()?;
10044        let root = temp.path().join("identity-rejection-bytes");
10045        fs::create_dir_all(root.join("src"))?;
10046        fs::write(root.join("src/lib.rs"), "pub fn indexed() {}\n")?;
10047        let database = root.join("projectatlas.db");
10048        let store = AtlasStore::open_for_project(&database, &root)?;
10049        let project = store
10050            .project_instance_id()?
10051            .ok_or("identity rejection fixture project identity is missing")?;
10052        let generation = IndexGeneration::new(1);
10053        let control = IndexWorkControl::new(IndexCancellation::new(), None);
10054        let graphs = vec![extract_symbol_graph(
10055            "src/lib.rs",
10056            Some("rust"),
10057            "pub fn indexed() {}\n",
10058        )];
10059        let nodes = vec![test_file_node("src/lib.rs", "rust")];
10060        let packages = PackageIndex::from_graphs(&graphs)?;
10061        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
10062        let mut admission = GraphIdentityAdmission::default();
10063        let rejection_path = format!("src/{}.rs", "long-rejection-path".repeat(64));
10064        admission.record(
10065            &rejection_path,
10066            super::IdentitySpan {
10067                start_line: 4,
10068                start_column: 0,
10069                end_line: 4,
10070                end_column: 12,
10071            },
10072            ParserKind::TreeSitter,
10073            7,
10074            &[(
10075                GraphIdentityField::Symbol,
10076                GraphIdentityRejectionReason::Oversized,
10077            )],
10078            &control,
10079        )?;
10080        let rejection_bytes = identity_rejection_keys_retained_bytes(&admission.rejection_keys)?;
10081        require(
10082            rejection_bytes > 1,
10083            "identity rejection fixture did not retain a bounded detail payload",
10084        )?;
10085        let build_projection = || {
10086            let projection = build_entity_projection(
10087                project, generation, &nodes, &graphs, &packages, true, &control,
10088            )?;
10089            let candidates = resolution_registry_from_exports(&projection, &control)?;
10090            Result::<_, Box<dyn Error>>::Ok((projection, candidates))
10091        };
10092
10093        let (projection, candidates) = build_projection()?;
10094        let baseline = finish_projection_with_documents(
10095            project,
10096            generation,
10097            RepositoryGraphMutation::Full,
10098            &graphs,
10099            &root,
10100            &nodes,
10101            &BTreeMap::new(),
10102            &GraphIdentityAdmission::default(),
10103            projection,
10104            &candidates,
10105            &scan_policy,
10106            &control,
10107        )?;
10108        let (projection, candidates) = build_projection()?;
10109        let in_memory = finish_projection_with_documents(
10110            project,
10111            generation,
10112            RepositoryGraphMutation::Full,
10113            &graphs,
10114            &root,
10115            &nodes,
10116            &BTreeMap::new(),
10117            &admission,
10118            projection,
10119            &candidates,
10120            &scan_policy,
10121            &control,
10122        )?;
10123        require_eq(
10124            &in_memory.retained_bytes(),
10125            &baseline.retained_bytes().saturating_add(rejection_bytes),
10126            "in-memory identity rejection retained bytes",
10127        )?;
10128        require_eq(
10129            &in_memory.identity_rejections.len(),
10130            &1,
10131            "in-memory identity rejection detail count",
10132        )?;
10133        let parent_prefix = super::super::MAX_PUBLICATION_STAGING_BYTES
10134            .checked_sub(in_memory.retained_bytes())
10135            .ok_or("identity rejection fixture exceeded the publication budget")?;
10136        require(
10137            super::super::enforce_publication_staging_budget(
10138                parent_prefix.saturating_add(in_memory.retained_bytes()),
10139            )
10140            .is_ok(),
10141            "parent publication budget rejected exact staged identity bytes",
10142        )?;
10143        require(
10144            super::super::enforce_publication_staging_budget(
10145                parent_prefix
10146                    .saturating_add(in_memory.retained_bytes())
10147                    .saturating_add(1),
10148            )
10149            .is_err(),
10150            "parent publication budget accepted one byte over staged identity bytes",
10151        )?;
10152        drop(in_memory);
10153        drop(baseline);
10154
10155        let (projection, candidates) = build_projection()?;
10156        let baseline_database = finish_projection_in_database_with_documents(
10157            &root,
10158            &nodes,
10159            project,
10160            generation,
10161            &graphs,
10162            &BTreeMap::new(),
10163            &GraphIdentityAdmission::default(),
10164            projection,
10165            &candidates,
10166            &scan_policy,
10167            &control,
10168        )?;
10169        let baseline_database_path_bytes = baseline_database
10170            .database
10171            .as_ref()
10172            .ok_or("database staging baseline was not selected")?
10173            .directory()?
10174            .path()
10175            .join(GRAPH_STAGE_DATABASE_FILE_NAME)
10176            .as_os_str()
10177            .as_encoded_bytes()
10178            .len() as u64;
10179        require_eq(
10180            &baseline_database.retained_bytes(),
10181            &baseline_database_path_bytes,
10182            "database staging baseline retained bytes",
10183        )?;
10184        drop(baseline_database);
10185
10186        let (projection, candidates) = build_projection()?;
10187        let database_staged = finish_projection_in_database_with_documents(
10188            &root,
10189            &nodes,
10190            project,
10191            generation,
10192            &graphs,
10193            &BTreeMap::new(),
10194            &admission,
10195            projection,
10196            &candidates,
10197            &scan_policy,
10198            &control,
10199        )?;
10200        let database_path_bytes = database_staged
10201            .database
10202            .as_ref()
10203            .ok_or("database staging was not selected")?
10204            .directory()?
10205            .path()
10206            .join(GRAPH_STAGE_DATABASE_FILE_NAME)
10207            .as_os_str()
10208            .as_encoded_bytes()
10209            .len() as u64;
10210        require_eq(
10211            &database_staged.retained_bytes(),
10212            &database_path_bytes.saturating_add(rejection_bytes),
10213            "database staging identity rejection retained bytes",
10214        )?;
10215        Ok(())
10216    }
10217
10218    #[test]
10219    fn accepted_relation_families_publish_and_reopen() -> Result<(), Box<dyn Error>> {
10220        let temp = tempfile::tempdir()?;
10221        let root = temp.path().join("accepted-relation-families");
10222        for directory in ["src", "tests", "config", "infra", "data"] {
10223            fs::create_dir_all(root.join(directory))?;
10224        }
10225        let database = root.join("projectatlas.db");
10226        let mut store = AtlasStore::open_for_project(&database, &root)?;
10227        let project = store
10228            .project_instance_id()?
10229            .ok_or_else(|| io::Error::other("relation inventory identity is missing"))?;
10230        let generation = IndexGeneration::new(1);
10231        let control = IndexWorkControl::new(IndexCancellation::new(), None);
10232        let graphs = vec![
10233            extract_symbol_graph(
10234                "Cargo.toml",
10235                Some("cargo-manifest"),
10236                concat!(
10237                    "[package]\nname = \"relation-fixture\"\nversion = \"0.1.0\"\n",
10238                    "\n[dependencies]\nserde = \"1\"\n",
10239                ),
10240            ),
10241            extract_symbol_graph(
10242                "src/lib.rs",
10243                Some("rust"),
10244                concat!(
10245                    "use std::fs;\n",
10246                    "pub struct Router { pub enabled: bool }\n",
10247                    "impl Router { pub fn install(&self) {} }\n",
10248                    "pub fn handler() {}\n",
10249                    "pub fn register() {\n",
10250                    "    route(\"/health\", handler);\n",
10251                    "    let _ = std::env::var(\"ATLAS_MODE\").unwrap_or_else(|_| \"super-secret\".into());\n",
10252                    "    let _ = fs::read_to_string(\"data/input.txt\");\n",
10253                    "    let _ = fs::write(\"data/output.txt\", \"ok\");\n",
10254                    "}\n",
10255                    "fn route(_path: &str, _handler: fn()) {}\n",
10256                ),
10257            ),
10258            extract_symbol_graph(
10259                "tests/feature_test.rs",
10260                Some("rust"),
10261                "fn subject() {}\nfn verifies_subject() { subject(); }\n",
10262            ),
10263            extract_symbol_graph(
10264                "config/appsettings.json",
10265                Some("json"),
10266                "{\"token\":\"super-secret\"}\n",
10267            ),
10268            extract_symbol_graph(
10269                "infra/main.tf",
10270                Some("terraform"),
10271                "resource \"null_resource\" \"fixture\" {}\n",
10272            ),
10273            extract_symbol_graph("data/input.txt", None, "input\n"),
10274            extract_symbol_graph("data/output.txt", None, ""),
10275        ];
10276        let nodes = graphs
10277            .iter()
10278            .map(|graph| {
10279                test_file_node(&graph.path, graph.language.as_deref().unwrap_or("unknown"))
10280            })
10281            .collect::<Vec<_>>();
10282        let packages = PackageIndex::from_graphs(&graphs)?;
10283        let projection = build_entity_projection(
10284            project, generation, &nodes, &graphs, &packages, true, &control,
10285        )?;
10286        let candidates = resolution_registry_from_exports(&projection, &control)?;
10287        let staged = finish_projection(
10288            project,
10289            generation,
10290            RepositoryGraphMutation::Full,
10291            &graphs,
10292            projection,
10293            &candidates,
10294            &control,
10295        )?;
10296        {
10297            let mut publication = store.begin_index_publication("accepted-relation-families")?;
10298            publication.begin_scan_replacement()?;
10299            publication.upsert_scan_node_batch(&nodes)?;
10300            publication.finish_scan_replacement()?;
10301            staged.apply(&mut publication, &control)?;
10302            publication.complete()?;
10303        }
10304        drop(store);
10305
10306        let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
10307        for capability in RELATION_FAMILY_CAPABILITIES
10308            .iter()
10309            .filter(|capability| capability.state == RelationFamilyState::Active)
10310        {
10311            for &family in capability.graph_relations {
10312                let page = reader.repository_graph_relations(
10313                    RepositoryGraphRelationQuery::Family { relation: family },
10314                    128,
10315                )?;
10316                require(!page.truncated, &format!("{family:?} page was truncated"))?;
10317                require(
10318                    !page.rows.is_empty(),
10319                    &format!("{family:?} had no reopened persisted relation"),
10320                )?;
10321                for relation in page.rows {
10322                    let occurrences = reader.repository_graph_occurrences(&relation, 32)?;
10323                    require(
10324                        !occurrences.rows.is_empty() && !occurrences.truncated,
10325                        &format!("{family:?} lost exact source occurrences"),
10326                    )?;
10327                    require(
10328                        !format!("{relation:?}").contains("super-secret"),
10329                        &format!("secret value escaped into persisted {family:?} relation"),
10330                    )?;
10331                }
10332            }
10333        }
10334        Ok(())
10335    }
10336
10337    #[test]
10338    fn accepted_relation_families_abstain_without_static_evidence() -> Result<(), Box<dyn Error>> {
10339        let project = ProjectInstanceId::from_bytes([13; 16])?;
10340        let generation = IndexGeneration::new(1);
10341        let control = IndexWorkControl::new(IndexCancellation::new(), None);
10342        let graphs = vec![
10343            extract_symbol_graph(
10344                "src/dynamic.rs",
10345                Some("rust"),
10346                concat!(
10347                    "use std::fs;\n",
10348                    "struct Client;\n",
10349                    "impl Client { fn get(&self, _path: &str, _handler: fn()) {} }\n",
10350                    "fn handler() {}\n",
10351                    "fn dynamic(client: &Client, route_path: &str, key: &str, file: &str) {\n",
10352                    "    client.get(\"/health\", handler);\n",
10353                    "    route(route_path, handler);\n",
10354                    "    let _ = std::env::var(key);\n",
10355                    "    let _ = fs::read_to_string(file);\n",
10356                    "    let _ = fs::write(file, \"super-secret\");\n",
10357                    "}\n",
10358                    "fn route(_path: &str, _handler: fn()) {}\n",
10359                ),
10360            ),
10361            extract_symbol_graph(
10362                "src/escaping.rs",
10363                Some("rust"),
10364                concat!(
10365                    "use std::fs;\n",
10366                    "fn unsafe_paths() {\n",
10367                    "    let _ = fs::read_to_string(\"../secret.txt\");\n",
10368                    "    let _ = fs::write(\"C:/secret.txt\", \"super-secret\");\n",
10369                    "}\n",
10370                ),
10371            ),
10372            extract_symbol_graph(
10373                "src/dynamic.js",
10374                Some("javascript"),
10375                concat!(
10376                    "function handler() {}\n",
10377                    "function middleware() {}\n",
10378                    "function route(...args) {}\n",
10379                    "route(\"/health\", handler, middleware);\n",
10380                ),
10381            ),
10382            extract_symbol_graph("docs/main.tf.example", None, "not infrastructure\n"),
10383            extract_symbol_graph("docs/k8s/README.md", None, "not infrastructure\n"),
10384            extract_symbol_graph("docs/cloudformation-notes.md", None, "not infrastructure\n"),
10385            extract_symbol_graph("config/settings.json.bak", None, "super-secret\n"),
10386        ];
10387        let packages = PackageIndex::from_graphs(&graphs)?;
10388        let projection =
10389            build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
10390        let candidates = resolution_registry_from_exports(&projection, &control)?;
10391        let staged = finish_projection(
10392            project,
10393            generation,
10394            RepositoryGraphMutation::Full,
10395            &graphs,
10396            projection,
10397            &candidates,
10398            &control,
10399        )?;
10400        for family in [
10401            ExtendedRelationKind::Tests,
10402            ExtendedRelationKind::RoutesTo,
10403            ExtendedRelationKind::Configures,
10404            ExtendedRelationKind::Deploys,
10405            ExtendedRelationKind::Reads,
10406            ExtendedRelationKind::Writes,
10407        ] {
10408            require(
10409                staged
10410                    .relations
10411                    .iter()
10412                    .all(|relation| relation.kind() != GraphRelationKind::Extended(family)),
10413                &format!("dynamic or lookalike input fabricated {family:?}"),
10414            )?;
10415        }
10416        require(
10417            staged
10418                .entities
10419                .iter()
10420                .all(|entity| !entity.key().canonical_identity().contains("super-secret")),
10421            "negative fixture leaked a secret into graph identity",
10422        )?;
10423        Ok(())
10424    }
10425
10426    #[test]
10427    fn qualified_symbol_identity_preserves_boundaries_and_compacts_stably()
10428    -> Result<(), Box<dyn Error>> {
10429        let name = GraphIdentityText::new("leaf")?;
10430        require_eq(
10431            &qualified_symbol_identity(&GraphIdentityText::new("outer")?, &name)?,
10432            &GraphIdentityText::new("outer::leaf")?,
10433            "shallow qualified identity",
10434        )?;
10435
10436        let exact_parent = GraphIdentityText::new(
10437            "x".repeat(MAX_GRAPH_IDENTITY_BYTES - "::".len() - name.as_str().len()),
10438        )?;
10439        let exact = qualified_symbol_identity(&exact_parent, &name)?;
10440        require_eq(
10441            &exact.as_str().len(),
10442            &MAX_GRAPH_IDENTITY_BYTES,
10443            "exact-boundary qualified identity bytes",
10444        )?;
10445        require(
10446            exact.as_str().ends_with("::leaf"),
10447            "exact-boundary qualified identity changed its readable suffix",
10448        )?;
10449
10450        let first_overbound_parent = GraphIdentityText::new(
10451            "x".repeat(MAX_GRAPH_IDENTITY_BYTES - "::".len() - name.as_str().len() + 1),
10452        )?;
10453        let compact = qualified_symbol_identity(&first_overbound_parent, &name)?;
10454        require(
10455            compact.as_str().len() <= MAX_GRAPH_IDENTITY_BYTES,
10456            "first overbound qualified identity remained oversized",
10457        )?;
10458        require(
10459            compact.as_str().ends_with("::leaf"),
10460            "compacted qualified identity lost its nearest symbol name",
10461        )?;
10462        require_eq(
10463            &qualified_symbol_identity(&first_overbound_parent, &name)?,
10464            &compact,
10465            "repeated compact qualified identity",
10466        )?;
10467
10468        let multibyte_parent = GraphIdentityText::new(format!(
10469            "{}x",
10470            "é".repeat((MAX_GRAPH_IDENTITY_BYTES - "::".len() - "界".len() - 1) / "é".len())
10471        ))?;
10472        let multibyte =
10473            qualified_symbol_identity(&multibyte_parent, &GraphIdentityText::new("界")?)?;
10474        require_eq(
10475            &multibyte.as_str().len(),
10476            &MAX_GRAPH_IDENTITY_BYTES,
10477            "multibyte exact-boundary qualified identity bytes",
10478        )?;
10479        require(
10480            multibyte.as_str().ends_with("::界"),
10481            "multibyte qualified identity changed its readable suffix",
10482        )?;
10483
10484        let parent_a = GraphIdentityText::new("a".repeat(MAX_GRAPH_IDENTITY_BYTES))?;
10485        let parent_b = GraphIdentityText::new("b".repeat(MAX_GRAPH_IDENTITY_BYTES))?;
10486        let scoped_a = qualified_symbol_identity(&parent_a, &name)?;
10487        let scoped_b = qualified_symbol_identity(&parent_b, &name)?;
10488        require(
10489            scoped_a != scoped_b,
10490            "distinct deep ancestors with an equal suffix shared one identity",
10491        )?;
10492        require(
10493            scoped_a != qualified_symbol_identity(&parent_a, &GraphIdentityText::new("other")?)?,
10494            "distinct overbound candidates shared one compact identity",
10495        )?;
10496        let (literal_parent, _) = scoped_a
10497            .as_str()
10498            .rsplit_once("::")
10499            .ok_or_else(|| io::Error::other("compact scope omitted its readable suffix"))?;
10500        require(
10501            source_symbol_identity(literal_parent.to_string()).is_err(),
10502            "compact scope namespace remained admissible as an exact source parent",
10503        )?;
10504        Ok(())
10505    }
10506
10507    #[test]
10508    fn qualified_symbol_parents_reject_invalid_raw_components() -> Result<(), Box<dyn Error>> {
10509        for (name, parent) in [
10510            ("bad\nname".to_string(), None),
10511            ("valid".to_string(), Some("bad\nparent".to_string())),
10512            (
10513                format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}literal"),
10514                Some("parent".to_string()),
10515            ),
10516            (
10517                "valid".to_string(),
10518                Some(format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}literal")),
10519            ),
10520            (
10521                "x".repeat(MAX_GRAPH_IDENTITY_BYTES + 1),
10522                Some("parent".to_string()),
10523            ),
10524        ] {
10525            let graph = SymbolGraph {
10526                path: "src/invalid.rs".to_string(),
10527                language: Some("rust".to_string()),
10528                parser: ParserKind::TreeSitter,
10529                symbols: vec![CodeSymbol {
10530                    path: "src/invalid.rs".to_string(),
10531                    language: Some("rust".to_string()),
10532                    name,
10533                    kind: SymbolKind::Function,
10534                    signature: "fn valid()".to_string(),
10535                    exported: false,
10536                    documentation: None,
10537                    line_start: 1,
10538                    line_end: 1,
10539                    source_selector: None,
10540                    parent,
10541                    parser: ParserKind::TreeSitter,
10542                    detail: Some("function_item".to_string()),
10543                }],
10544                relations: Vec::new(),
10545            };
10546            require(
10547                qualified_symbol_parents(&graph).is_err(),
10548                "invalid raw symbol identity reached qualified derivation",
10549            )?;
10550        }
10551        Ok(())
10552    }
10553
10554    #[test]
10555    fn qualified_symbol_parents_bound_four_thousand_deep_scopes() -> Result<(), Box<dyn Error>> {
10556        const DEPTH: usize = 4_000;
10557        let names = (0..DEPTH)
10558            .map(|index| format!("scope_{index:04}_{}", "x".repeat(229)))
10559            .collect::<Vec<_>>();
10560        let graph = SymbolGraph {
10561            path: "src/deep.rs".to_string(),
10562            language: Some("rust".to_string()),
10563            parser: ParserKind::TreeSitter,
10564            symbols: names
10565                .iter()
10566                .enumerate()
10567                .map(|(index, name)| CodeSymbol {
10568                    path: "src/deep.rs".to_string(),
10569                    language: Some("rust".to_string()),
10570                    name: name.clone(),
10571                    kind: SymbolKind::Module,
10572                    signature: name.clone(),
10573                    exported: false,
10574                    documentation: None,
10575                    line_start: index + 1,
10576                    line_end: DEPTH * 2 - index,
10577                    source_selector: None,
10578                    parent: index.checked_sub(1).map(|parent| names[parent].clone()),
10579                    parser: ParserKind::TreeSitter,
10580                    detail: Some("mod_item".to_string()),
10581                })
10582                .collect(),
10583            relations: Vec::new(),
10584        };
10585        let first = qualified_symbol_parents(&graph)?;
10586        require_eq(&first.len(), &DEPTH, "deep qualified parent count")?;
10587        require(
10588            first.first().is_some_and(Option::is_none),
10589            "deep root unexpectedly gained a parent",
10590        )?;
10591        require(
10592            first
10593                .iter()
10594                .flatten()
10595                .all(|parent| parent.as_str().len() <= MAX_GRAPH_IDENTITY_BYTES),
10596            "deep qualification retained an oversized parent",
10597        )?;
10598        require(
10599            first
10600                .iter()
10601                .flatten()
10602                .any(|parent| parent.as_str().starts_with("@projectatlas.scope.v1:")),
10603            "deep qualification never exercised compact scope identity",
10604        )?;
10605        require_eq(
10606            &qualified_symbol_parents(&graph)?,
10607            &first,
10608            "repeated deep qualified parents",
10609        )?;
10610        Ok(())
10611    }
10612
10613    #[test]
10614    fn qualified_symbol_scopes_produce_distinct_graph_entity_keys() -> Result<(), Box<dyn Error>> {
10615        let project = ProjectInstanceId::from_bytes([3; 16])?;
10616        let generation = IndexGeneration::new(1);
10617        let control = IndexWorkControl::new(IndexCancellation::new(), None);
10618        for (path, language, source, expected_parents) in [
10619            (
10620                "src/lib.rs",
10621                "rust",
10622                concat!(
10623                    "mod first { struct Runner; impl Runner { fn run(&self) {} } }\n",
10624                    "mod second { struct Runner; impl Runner { fn run(&self) {} } }\n",
10625                ),
10626                ["first::Runner", "second::Runner"],
10627            ),
10628            (
10629                "src/Runner.java",
10630                "java",
10631                concat!(
10632                    "class First { class Runner { void run() {} } }\n",
10633                    "class Second { class Runner { void run() {} } }\n",
10634                ),
10635                ["First::Runner", "Second::Runner"],
10636            ),
10637        ] {
10638            let graph = extract_symbol_graph(path, Some(language), source);
10639            let method_indices = graph
10640                .symbols
10641                .iter()
10642                .enumerate()
10643                .filter_map(|(index, symbol)| {
10644                    (symbol.kind == SymbolKind::Method && symbol.name == "run").then_some(index)
10645                })
10646                .collect::<Vec<_>>();
10647            require_eq(&method_indices.len(), &2, "scoped method count")?;
10648            let first_symbol = &graph.symbols[method_indices[0]];
10649            let second_symbol = &graph.symbols[method_indices[1]];
10650            require_eq(
10651                &first_symbol.parent.as_deref(),
10652                &Some("Runner"),
10653                "legacy leaf parent",
10654            )?;
10655            require_eq(
10656                &first_symbol.parent,
10657                &second_symbol.parent,
10658                "same legacy leaf parent",
10659            )?;
10660            require_eq(
10661                &first_symbol.signature,
10662                &second_symbol.signature,
10663                "same declaration signature",
10664            )?;
10665
10666            let graphs = vec![graph];
10667            let packages = PackageIndex::from_graphs(&graphs)?;
10668            let projection = build_entity_projection(
10669                project,
10670                generation,
10671                &[],
10672                &graphs,
10673                &packages,
10674                true,
10675                &control,
10676            )?;
10677            let owners = projection
10678                .owners_by_graph
10679                .get(path)
10680                .ok_or("scoped graph owners are missing")?;
10681            let first = owners.symbol_digests[method_indices[0]]
10682                .as_ref()
10683                .and_then(|digest| projection.entity_by_digest.get(digest))
10684                .ok_or("first scoped method entity is missing")?;
10685            let second = owners.symbol_digests[method_indices[1]]
10686                .as_ref()
10687                .and_then(|digest| projection.entity_by_digest.get(digest))
10688                .ok_or("second scoped method entity is missing")?;
10689            let EntitySelector::Symbol {
10690                symbol: first_selector,
10691            } = first.selector()
10692            else {
10693                return Err("first scoped entity is not a symbol".into());
10694            };
10695            let EntitySelector::Symbol {
10696                symbol: second_selector,
10697            } = second.selector()
10698            else {
10699                return Err("second scoped entity is not a symbol".into());
10700            };
10701            require_eq(
10702                &first_selector
10703                    .parent
10704                    .as_ref()
10705                    .map(GraphIdentityText::as_str),
10706                &Some(expected_parents[0]),
10707                "first graph identity parent",
10708            )?;
10709            require_eq(
10710                &second_selector
10711                    .parent
10712                    .as_ref()
10713                    .map(GraphIdentityText::as_str),
10714                &Some(expected_parents[1]),
10715                "second graph identity parent",
10716            )?;
10717            require(
10718                first.key() != second.key(),
10719                "independent semantic scopes shared one graph entity key",
10720            )?;
10721        }
10722        Ok(())
10723    }
10724
10725    #[test]
10726    fn external_classification_follows_effective_semantic_provider() -> Result<(), Box<dyn Error>> {
10727        for (language, target, system, identity) in [
10728            ("html", "import fs from \"node:fs\";", "node", "fs"),
10729            ("svelte", "import url from \"node:url\";", "node", "url"),
10730            ("vue", "import path from \"node:path\";", "node", "path"),
10731        ] {
10732            let case = resolution_case(language, RelationKind::Imports, target, &[]);
10733            let external = explicit_external_selector(&case.graph, &case.relation)?
10734                .ok_or("embedded ECMAScript external classification is missing")?;
10735            require_eq(&external.system.as_str(), &system, "external system")?;
10736            require_eq(&external.identity.as_str(), &identity, "external identity")?;
10737        }
10738
10739        let lock = resolution_case("cargo-lock", RelationKind::DependsOn, "serde-lock", &[]);
10740        require(
10741            explicit_external_selector(&lock.graph, &lock.relation)?.is_none(),
10742            "Cargo.lock was misclassified as an explicit external dependency",
10743        )?;
10744        Ok(())
10745    }
10746
10747    #[test]
10748    fn grouped_rust_imports_use_only_their_common_external_module() -> Result<(), Box<dyn Error>> {
10749        let grouped = resolution_case("rust", RelationKind::Imports, "use std::{fs, io};", &[]);
10750        require_eq(
10751            &rust_toolchain_identity(&grouped.relation),
10752            &Some("std".to_string()),
10753            "grouped Rust external root",
10754        )?;
10755        let nested = resolution_case(
10756            "rust",
10757            RelationKind::Imports,
10758            "use std::fs::{read, write};",
10759            &[],
10760        );
10761        require_eq(
10762            &rust_toolchain_identity(&nested.relation),
10763            &Some("std::fs".to_string()),
10764            "nested grouped Rust external module",
10765        )?;
10766        let ordinary = resolution_case("rust", RelationKind::Imports, "use std::fs;", &[]);
10767        require_eq(
10768            &rust_toolchain_identity(&ordinary.relation),
10769            &Some("std::fs".to_string()),
10770            "ordinary Rust external module",
10771        )?;
10772        Ok(())
10773    }
10774
10775    #[test]
10776    fn registry_candidate_merge_deduplicates_exactly_and_observes_cancellation()
10777    -> Result<(), Box<dyn Error>> {
10778        let project = ProjectInstanceId::from_bytes([6; 16])?;
10779        let generation = IndexGeneration::new(1);
10780        let first_key = test_resolution_key(project, "first-key")?;
10781        let second_key = test_resolution_key(project, "second-key")?;
10782        let first = test_symbol_entity(project, generation, "src/first.rs", "first")?;
10783        let second = test_symbol_entity(project, generation, "src/second.rs", "second")?;
10784        let mut registry = ProjectResolutionRegistry::default();
10785        registry.insert_candidate(&first_key, &first)?;
10786        registry.insert_candidate(&second_key, &first)?;
10787        registry.insert_candidate(&second_key, &second)?;
10788        let control = IndexWorkControl::new(IndexCancellation::new(), None);
10789        let staged_entities = BTreeMap::new();
10790        let matches = registry_resolution_matches(
10791            &[first_key.clone(), second_key.clone()],
10792            &registry,
10793            &staged_entities,
10794            &control,
10795        )?;
10796        require_eq(&matches.count, &2, "distinct merged candidate count")?;
10797        let expected_first = [&first, &second]
10798            .into_iter()
10799            .min_by_key(|entity| entity.key().digest())
10800            .ok_or("candidate fixture is empty")?;
10801        require_eq(
10802            &matches.first.map(|entity| entity.key().digest()),
10803            &Some(expected_first.key().digest()),
10804            "stable first candidate",
10805        )?;
10806
10807        let cancellation = IndexCancellation::new();
10808        let canceled_control = IndexWorkControl::new(cancellation.clone(), None);
10809        cancellation.cancel();
10810        require(
10811            registry_resolution_matches(
10812                &[first_key, second_key],
10813                &registry,
10814                &staged_entities,
10815                &canceled_control,
10816            )
10817            .is_err(),
10818            "candidate merge ignored cancellation",
10819        )?;
10820        Ok(())
10821    }
10822
10823    #[test]
10824    fn duplicate_ambiguous_relations_keep_the_largest_candidate_count() -> Result<(), Box<dyn Error>>
10825    {
10826        let project = ProjectInstanceId::from_bytes([15; 16])?;
10827        let generation = IndexGeneration::new(1);
10828        let source = test_file_entity(project, generation, "src/duplicate.ts")?;
10829        let relation = |candidates| -> Result<LogicalRelation, Box<dyn Error>> {
10830            Ok(LogicalRelation::new(
10831                &source,
10832                GraphRelationKind::from_legacy(RelationKind::Contains),
10833                RelationResolution::Ambiguous {
10834                    reference: GraphIdentityText::new("declarations")?,
10835                    candidates: NonZeroU32::new(candidates).ok_or("candidate count was zero")?,
10836                },
10837                ConfidenceClass::Exact,
10838                Completeness::Complete,
10839                generation,
10840            )?)
10841        };
10842        let mut relations = BTreeMap::new();
10843        insert_relation(
10844            &mut relations,
10845            relation(2)?,
10846            "src/duplicate.ts",
10847            "logical",
10848            0,
10849        )?;
10850        insert_relation(
10851            &mut relations,
10852            relation(3)?,
10853            "src/duplicate.ts",
10854            "logical",
10855            1,
10856        )?;
10857        insert_relation(
10858            &mut relations,
10859            relation(2)?,
10860            "src/duplicate.ts",
10861            "logical",
10862            2,
10863        )?;
10864        let retained = relations
10865            .into_values()
10866            .next()
10867            .ok_or("deduplicated relation was not retained")?;
10868        require(
10869            matches!(
10870                retained.resolution(),
10871                RelationResolution::Ambiguous { candidates, .. } if candidates.get() == 3
10872            ),
10873            "deduplicated ambiguity did not retain the largest candidate count",
10874        )?;
10875
10876        let mut conflicting = BTreeMap::new();
10877        insert_relation(
10878            &mut conflicting,
10879            relation(2)?,
10880            "src/duplicate.ts",
10881            "logical",
10882            0,
10883        )?;
10884        let different_confidence = LogicalRelation::new(
10885            &source,
10886            GraphRelationKind::from_legacy(RelationKind::Contains),
10887            RelationResolution::Ambiguous {
10888                reference: GraphIdentityText::new("declarations")?,
10889                candidates: NonZeroU32::new(2).ok_or("candidate count was zero")?,
10890            },
10891            ConfidenceClass::High,
10892            Completeness::Complete,
10893            generation,
10894        )?;
10895        require(
10896            insert_relation(
10897                &mut conflicting,
10898                different_confidence,
10899                "src/duplicate.ts",
10900                "logical",
10901                1,
10902            )
10903            .is_err(),
10904            "a non-ambiguity conflict was merged",
10905        )?;
10906        Ok(())
10907    }
10908
10909    #[test]
10910    fn same_file_private_calls_resolve_and_duplicate_declarations_stay_ambiguous()
10911    -> Result<(), Box<dyn Error>> {
10912        let project = ProjectInstanceId::from_bytes([5; 16])?;
10913        let generation = IndexGeneration::new(1);
10914        let control = IndexWorkControl::new(IndexCancellation::new(), None);
10915        let private_graph = extract_symbol_graph(
10916            "src/private.rs",
10917            Some("rust"),
10918            "pub fn caller() { helper(); }\nfn helper() {}\n",
10919        );
10920        require(
10921            private_graph
10922                .symbols
10923                .iter()
10924                .any(|symbol| symbol.name == "helper" && !symbol.exported),
10925            "fixture helper was not private",
10926        )?;
10927        let packages = PackageIndex::from_graphs(std::slice::from_ref(&private_graph))?;
10928        let projection = build_entity_projection(
10929            project,
10930            generation,
10931            &[],
10932            std::slice::from_ref(&private_graph),
10933            &packages,
10934            true,
10935            &control,
10936        )?;
10937        let candidates = resolution_registry_from_exports(&projection, &control)?;
10938        let staged = finish_projection(
10939            project,
10940            generation,
10941            RepositoryGraphMutation::Full,
10942            std::slice::from_ref(&private_graph),
10943            projection,
10944            &candidates,
10945            &control,
10946        )?;
10947        let helper_call = staged
10948            .relations
10949            .iter()
10950            .find(|relation| {
10951                matches!(
10952                    relation.resolution(),
10953                    RelationResolution::Resolved {
10954                        selector: ReusableTargetSelector::Symbol { symbol },
10955                        ..
10956                    } if symbol.name.as_str() == "helper"
10957                        && symbol.file.as_str() == "src/private.rs"
10958                )
10959            })
10960            .ok_or("private same-file helper call did not resolve")?;
10961        require_eq(
10962            &helper_call.kind(),
10963            &GraphRelationKind::from_legacy(RelationKind::Calls),
10964            "private same-file relation kind",
10965        )?;
10966
10967        let duplicate_graph = SymbolGraph {
10968            path: "src/duplicate.rs".to_string(),
10969            language: Some("rust".to_string()),
10970            parser: ParserKind::TreeSitter,
10971            symbols: vec![
10972                test_code_symbol("src/duplicate.rs", "caller", Some("Owner"), "fn caller()"),
10973                test_code_symbol(
10974                    "src/duplicate.rs",
10975                    "helper",
10976                    Some("Owner"),
10977                    "fn helper(first: u8)",
10978                ),
10979                test_code_symbol(
10980                    "src/duplicate.rs",
10981                    "helper",
10982                    Some("Owner"),
10983                    "fn helper(second: u16)",
10984                ),
10985                test_code_symbol(
10986                    "src/duplicate.rs",
10987                    "helper",
10988                    Some("Unrelated"),
10989                    "fn helper()",
10990                ),
10991            ],
10992            relations: vec![SymbolRelation {
10993                path: "src/duplicate.rs".to_string(),
10994                source_name: "caller".to_string(),
10995                target_name: "helper".to_string(),
10996                kind: RelationKind::Calls,
10997                line: 1,
10998                context: "helper()".to_string(),
10999                parser: ParserKind::TreeSitter,
11000            }],
11001        };
11002        let packages = PackageIndex::from_graphs(std::slice::from_ref(&duplicate_graph))?;
11003        let projection = build_entity_projection(
11004            project,
11005            generation,
11006            &[],
11007            std::slice::from_ref(&duplicate_graph),
11008            &packages,
11009            true,
11010            &control,
11011        )?;
11012        let candidates = resolution_registry_from_exports(&projection, &control)?;
11013        let staged = finish_projection(
11014            project,
11015            generation,
11016            RepositoryGraphMutation::Full,
11017            std::slice::from_ref(&duplicate_graph),
11018            projection,
11019            &candidates,
11020            &control,
11021        )?;
11022        require(
11023            staged.relations.iter().any(|relation| {
11024                matches!(
11025                    relation.resolution(),
11026                    RelationResolution::Ambiguous {
11027                        reference,
11028                        candidates,
11029                    } if reference.as_str() == "helper" && candidates.get() == 2
11030                )
11031            }),
11032            "duplicate same-file declarations did not remain ambiguous",
11033        )?;
11034        Ok(())
11035    }
11036
11037    #[test]
11038    fn php_scoped_and_case_insensitive_calls_resolve_without_member_edges()
11039    -> Result<(), Box<dyn Error>> {
11040        let project = ProjectInstanceId::from_bytes([31; 16])?;
11041        let generation = IndexGeneration::new(1);
11042        let control = IndexWorkControl::new(IndexCancellation::new(), None);
11043        let php_graph = extract_symbol_graph(
11044            "src/service.php",
11045            Some("php"),
11046            r"<?php
11047class Service {
11048    public string $boot;
11049    public static function prepare(): void {}
11050    public static function finish(): void {}
11051    public static function boot(): void {}
11052    public function run(): void {
11053        self::prepare();
11054        static::finish();
11055        Service::boot();
11056        SERVICE::BOOT();
11057        parent::inherited();
11058        $this->boot();
11059    }
11060}
11061function helper(): void {}
11062function caller(): void { HELPER(); }
11063namespace Atlas\Domain;
11064class QualifiedService {
11065    public static function boot(): void {}
11066    public static function qualified_run(): void {
11067        \Atlas\Domain\QualifiedService::boot();
11068        \Missing\Domain\QualifiedService::boot();
11069        QualifiedService::boot();
11070    }
11071}
11072namespace Other\Domain;
11073class QualifiedService {
11074    public static function boot(): void {}
11075}
11076function relative_run(): void {
11077    Atlas\Domain\QualifiedService::boot();
11078    Alias\QualifiedService::boot();
11079    namespace\QualifiedService::boot();
11080}
11081namespace TopLevel;
11082function top_level_helper(): void {}
11083top_level_helper();
11084namespace Foo;
11085function helper(): void {}
11086function qualified_helper_run(): void {
11087    \Foo\helper();
11088}
11089function rooted_global_helper_run(): void {
11090    \helper();
11091}
11092function missing_helper_run(): void {
11093    \Missing\helper();
11094}
11095class NamespacedService {
11096    public function namespaced_run(): void {
11097        helper();
11098    }
11099}
11100class DuplicateA {
11101    public static function prepare(): void {}
11102    public static function collision_run(): void {
11103        self::prepare();
11104    }
11105}
11106class DuplicateB {
11107    public static function prepare(): void {}
11108    public static function collision_run(): void {
11109        self::prepare();
11110    }
11111}
11112",
11113        );
11114        require(
11115            php_graph
11116                .relations
11117                .iter()
11118                .filter(|relation| relation.kind == RelationKind::Calls)
11119                .all(|relation| relation.target_name != "boot"),
11120            "dynamic member calls must not become unscoped PHP calls",
11121        )?;
11122        let finish_graph = |graph: &SymbolGraph| {
11123            let packages = PackageIndex::from_graphs(std::slice::from_ref(graph))?;
11124            let projection = build_entity_projection(
11125                project,
11126                generation,
11127                &[],
11128                std::slice::from_ref(graph),
11129                &packages,
11130                true,
11131                &control,
11132            )?;
11133            let candidates = resolution_registry_from_exports(&projection, &control)?;
11134            finish_projection(
11135                project,
11136                generation,
11137                RepositoryGraphMutation::Full,
11138                std::slice::from_ref(graph),
11139                projection,
11140                &candidates,
11141                &control,
11142            )
11143        };
11144        let staged = finish_graph(&php_graph)?;
11145        for declaration in [
11146            "class Service {\nconst FLAG = 1;\npublic $value;\nfunction run() {}\n}",
11147            "interface Service {\nconst FLAG = 1;\nfunction run();\n}",
11148            "trait Service {\nconst FLAG = 1;\npublic $value;\nfunction run() {}\n}",
11149            "enum Service {\ncase Ready;\nconst FLAG = 1;\nfunction run() {}\n}",
11150        ] {
11151            for (source, namespaces) in [
11152                (
11153                    format!(
11154                        "<?php namespace A {{\n{declaration}\n}}\nnamespace B {{\n{declaration}\n}}"
11155                    ),
11156                    ["A", "B"],
11157                ),
11158                (
11159                    format!("<?php namespace A;\n{declaration}\nnamespace B;\n{declaration}"),
11160                    ["A", "B"],
11161                ),
11162                (
11163                    format!(
11164                        "<?php namespace Service {{\n{declaration}\n}}\nnamespace B {{\n{declaration}\n}}"
11165                    ),
11166                    ["Service", "B"],
11167                ),
11168                (
11169                    format!("<?php namespace Service;\n{declaration}\nnamespace B;\n{declaration}"),
11170                    ["Service", "B"],
11171                ),
11172            ] {
11173                let graph = extract_symbol_graph("src/containment.php", Some("php"), &source);
11174                require_eq(
11175                    &graph
11176                        .symbols
11177                        .iter()
11178                        .filter(|symbol| {
11179                            symbol.name == "run" && symbol.parent.as_deref() == Some("Service")
11180                        })
11181                        .count(),
11182                    &2,
11183                    "PHP parser retains unqualified member parents",
11184                )?;
11185                let contained = finish_graph(&graph)?;
11186                for namespace in namespaces {
11187                    let parent = format!("{namespace}::Service");
11188                    for name in ["run", "FLAG"] {
11189                        require(
11190                            contained.relations.iter().any(|relation| {
11191                                relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
11192                                    && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
11193                                        if symbol.name.as_str() == name && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(parent.as_str()))
11194                                    && contained.entities.iter().any(|entity| entity.key() == relation.source()
11195                                        && matches!(entity.selector(), EntitySelector::Symbol { symbol }
11196                                            if symbol.name.as_str() == "Service" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(namespace)))
11197                            }),
11198                            &format!("PHP containment must keep its exact namespace owner: {parent}::{name}"),
11199                        )?;
11200                    }
11201                    require(
11202                        contained.relations.iter().any(|relation| {
11203                            relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
11204                                && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
11205                                    if symbol.name.as_str() == "Service" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(namespace))
11206                                && contained.entities.iter().any(|entity| entity.key() == relation.source()
11207                                    && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == namespace))
11208                        }),
11209                        "PHP namespace containment retains its declared type",
11210                    )?;
11211                }
11212            }
11213        }
11214        for declaration in [
11215            "class N { function caller() { helper(); } }",
11216            "trait N { function caller() { helper(); } }",
11217            "enum N { case Ready; function caller() { helper(); } }",
11218        ] {
11219            for source in [
11220                format!(
11221                    "<?php namespace N {{ function helper() {{}} }}\nnamespace M {{ function helper() {{}} {declaration} }}"
11222                ),
11223                format!(
11224                    "<?php namespace M {{ function helper() {{}} {declaration} }}\nnamespace N {{ function helper() {{}} }}"
11225                ),
11226                format!(
11227                    "<?php namespace N; function helper() {{}}\nnamespace M; function helper() {{}} {declaration}"
11228                ),
11229                format!(
11230                    "<?php namespace M; function helper() {{}} {declaration}\nnamespace N; function helper() {{}}"
11231                ),
11232            ] {
11233                let graph = extract_symbol_graph("src/type-namespace.php", Some("php"), &source);
11234                let projected = finish_graph(&graph)?;
11235                let calls = projected
11236                    .relations
11237                    .iter()
11238                    .filter(|relation| {
11239                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
11240                    })
11241                    .collect::<Vec<_>>();
11242                require_eq(
11243                    &calls.len(),
11244                    &1,
11245                    "PHP type/namespace collision retains its call",
11246                )?;
11247                require(
11248                    matches!(calls[0].resolution(), RelationResolution::Resolved {
11249                        selector: ReusableTargetSelector::Symbol { symbol }, ..
11250                    } if symbol.name.as_str() == "helper"
11251                        && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("M")),
11252                    "PHP containing type must retain its namespace despite a same-named module",
11253                )?;
11254            }
11255        }
11256        let overlapping = extract_symbol_graph(
11257            "src/containment.php",
11258            Some("php"),
11259            "<?php namespace A { class Service { function run() {} } } namespace B { class Service { function run() {} } }",
11260        );
11261        let contained = finish_graph(&overlapping)?;
11262        require(
11263            contained.relations.iter().any(|relation| {
11264                relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
11265                    && !matches!(relation.resolution(), RelationResolution::Resolved { .. })
11266            }),
11267            "PHP overlapping line-only owners remain unresolved",
11268        )?;
11269        require(
11270            !contained.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
11271                && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. } if symbol.name.as_str() == "run")),
11272            "PHP overlapping line-only owners must not invent member containment",
11273        )?;
11274        for source in [
11275            "<?php namespace Service { function boot() {} } namespace { class Service {} Service::boot(); }",
11276            "<?php namespace Service {} namespace { class Service { static function boot() {} } \\Service\\boot(); }",
11277        ] {
11278            let graph = extract_symbol_graph("src/call-kind.php", Some("php"), source);
11279            let staged = finish_graph(&graph)?;
11280            let calls: Vec<_> = staged
11281                .relations
11282                .iter()
11283                .filter(|relation| {
11284                    relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
11285                })
11286                .collect();
11287            require_eq(&calls.len(), &1, "PHP callable-kind collision fixture")?;
11288            require(
11289                matches!(calls[0].resolution(), RelationResolution::Unresolved { .. }),
11290                "PHP calls must not resolve across function and method kinds",
11291            )?;
11292        }
11293        for (source, parent) in [
11294            (
11295                "<?php namespace N; function outer() { function inner() {} } function caller() { inner(); }",
11296                Some("N"),
11297            ),
11298            (
11299                "<?php namespace N { class OuterType { function outer() { function inner() {} } } function caller() { inner(); } }",
11300                Some("N"),
11301            ),
11302            (
11303                "<?php function outer() { function inner() {} } function caller() { inner(); }",
11304                None,
11305            ),
11306        ] {
11307            let graph = extract_symbol_graph("src/nested-call.php", Some("php"), source);
11308            let staged = finish_graph(&graph)?;
11309            require(staged.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls) && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. } if symbol.name.as_str() == "inner" && symbol.kind == SymbolKind::Function && symbol.parent.as_ref().map(GraphIdentityText::as_str) == parent)), &format!("nested PHP function identity must resolve in its namespace: {source}"))?;
11310        }
11311        let calls = staged
11312            .relations
11313            .iter()
11314            .filter(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
11315            .collect::<Vec<_>>();
11316        let staged_call = |target: &str, source: &str| {
11317            let parser_relation = php_graph.relations.iter().find(|relation| {
11318                relation.kind == RelationKind::Calls
11319                    && relation.target_name == target
11320                    && relation.source_name == source
11321            })?;
11322            let line = u32::try_from(parser_relation.line).ok()?;
11323            staged
11324                .occurrences
11325                .iter()
11326                .filter(|occurrence| occurrence.span().start_line() == line)
11327                .filter_map(|occurrence| {
11328                    staged
11329                        .relations
11330                        .iter()
11331                        .find(|relation| relation.key() == occurrence.relation())
11332                })
11333                .find(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
11334        };
11335        require(
11336            matches!(
11337                staged_call("\\Atlas\\Domain\\QualifiedService::boot", "qualified_run")
11338                    .map(projectatlas_core::graph::LogicalRelation::resolution),
11339                Some(RelationResolution::Resolved {
11340                    selector: ReusableTargetSelector::Symbol { symbol },
11341                    ..
11342                }) if symbol.name.as_str() == "boot"
11343                    && symbol.parent.as_ref().map(GraphIdentityText::as_str)
11344                        == Some("Atlas\\Domain::QualifiedService")
11345            ),
11346            "fully qualified PHP call did not resolve to the local class method",
11347        )?;
11348        require(
11349            matches!(
11350                staged_call("\\Missing\\Domain\\QualifiedService::boot", "qualified_run")
11351                    .map(projectatlas_core::graph::LogicalRelation::resolution),
11352                Some(RelationResolution::Unresolved { reference })
11353                    if reference.as_str() == "\\Missing\\Domain\\QualifiedService::boot"
11354            ),
11355            "qualified PHP call with an invalid namespace must remain unresolved",
11356        )?;
11357        require(
11358            matches!(
11359                staged_call("QualifiedService::boot", "qualified_run")
11360                    .map(projectatlas_core::graph::LogicalRelation::resolution),
11361                Some(RelationResolution::Resolved {
11362                    selector: ReusableTargetSelector::Symbol { symbol },
11363                    ..
11364                }) if symbol.name.as_str() == "boot"
11365                    && symbol.parent.as_ref().map(GraphIdentityText::as_str)
11366                        == Some("Atlas\\Domain::QualifiedService")
11367            ),
11368            "unqualified PHP class scope must select its caller namespace",
11369        )?;
11370        for target in [
11371            "Atlas\\Domain\\QualifiedService::boot",
11372            "Alias\\QualifiedService::boot",
11373        ] {
11374            require(
11375                matches!(
11376                    staged_call(target, "relative_run")
11377                        .map(projectatlas_core::graph::LogicalRelation::resolution),
11378                    Some(RelationResolution::Unresolved { reference })
11379                        if reference.as_str() == target
11380                ),
11381                &format!("non-leading qualified PHP scope must remain unresolved: {target}"),
11382            )?;
11383        }
11384        require(
11385            matches!(
11386                staged_call("namespace\\QualifiedService::boot", "relative_run")
11387                    .map(projectatlas_core::graph::LogicalRelation::resolution),
11388                Some(RelationResolution::Resolved {
11389                    selector: ReusableTargetSelector::Symbol { symbol },
11390                    ..
11391                }) if symbol.parent.as_ref().map(GraphIdentityText::as_str)
11392                    == Some("Other\\Domain::QualifiedService")
11393            ),
11394            "explicit namespace-relative PHP static scope must resolve in its caller namespace",
11395        )?;
11396        require(
11397            matches!(
11398                staged_call("top_level_helper", "TopLevel")
11399                    .map(projectatlas_core::graph::LogicalRelation::resolution),
11400                Some(RelationResolution::Resolved {
11401                    selector: ReusableTargetSelector::Symbol { symbol },
11402                    ..
11403                }) if symbol.name.as_str() == "top_level_helper"
11404                    && symbol.parent.as_ref().map(GraphIdentityText::as_str)
11405                        == Some("TopLevel")
11406            ),
11407            "semicolon namespace top-level call did not resolve locally",
11408        )?;
11409        require(
11410            matches!(
11411                staged_call("helper", "namespaced_run")
11412                    .map(projectatlas_core::graph::LogicalRelation::resolution),
11413                Some(RelationResolution::Resolved {
11414                    selector: ReusableTargetSelector::Symbol { symbol },
11415                    ..
11416                }) if symbol.name.as_str() == "helper"
11417                    && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Foo")
11418            ),
11419            "namespaced PHP method call did not prefer its namespace helper",
11420        )?;
11421        require(
11422            matches!(
11423                staged_call("\\Foo\\helper", "qualified_helper_run")
11424                    .map(projectatlas_core::graph::LogicalRelation::resolution),
11425                Some(RelationResolution::Resolved {
11426                    selector: ReusableTargetSelector::Symbol { symbol },
11427                    ..
11428                }) if symbol.name.as_str() == "helper"
11429                    && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Foo")
11430            ),
11431            "fully qualified PHP function call did not resolve to its namespace function",
11432        )?;
11433        require(
11434            matches!(
11435                staged_call("\\helper", "rooted_global_helper_run")
11436                    .map(projectatlas_core::graph::LogicalRelation::resolution),
11437                Some(RelationResolution::Resolved {
11438                    selector: ReusableTargetSelector::Symbol { symbol },
11439                    ..
11440                }) if symbol.name.as_str() == "helper" && symbol.parent.is_none()
11441            ),
11442            "rooted PHP global function call did not resolve explicitly to the global helper",
11443        )?;
11444        require(
11445            matches!(
11446                staged_call("\\Missing\\helper", "missing_helper_run")
11447                    .map(projectatlas_core::graph::LogicalRelation::resolution),
11448                Some(RelationResolution::Unresolved { reference })
11449                    if reference.as_str() == "\\Missing\\helper"
11450            ),
11451            "fully qualified PHP function call must not fall back to a global helper",
11452        )?;
11453        let duplicate_method_calls = php_graph
11454            .relations
11455            .iter()
11456            .filter(|relation| {
11457                relation.kind == RelationKind::Calls
11458                    && relation.target_name == "self::prepare"
11459                    && relation.source_name == "collision_run"
11460            })
11461            .count();
11462        require_eq(
11463            &duplicate_method_calls,
11464            &2,
11465            "duplicate PHP method fixture call count",
11466        )?;
11467        let duplicate_method_parents = calls
11468            .iter()
11469            .filter_map(|relation| match relation.resolution() {
11470                RelationResolution::Resolved {
11471                    selector: ReusableTargetSelector::Symbol { symbol },
11472                    ..
11473                } if symbol.name.as_str() == "prepare" => {
11474                    symbol.parent.as_ref().map(GraphIdentityText::as_str)
11475                }
11476                _ => None,
11477            })
11478            .collect::<BTreeSet<_>>();
11479        require_eq(
11480            &duplicate_method_parents,
11481            &BTreeSet::from(["Foo::DuplicateA", "Foo::DuplicateB", "Service"]),
11482            "PHP method callers retain their distinct source owners",
11483        )?;
11484        let duplicate_sources = calls
11485            .iter()
11486            .filter_map(|relation| {
11487                let entity = staged
11488                    .entities
11489                    .iter()
11490                    .find(|entity| entity.key() == relation.source())?;
11491                match entity.selector() {
11492                    EntitySelector::Symbol { symbol }
11493                        if symbol.name.as_str() == "collision_run" =>
11494                    {
11495                        symbol.parent.as_ref().map(GraphIdentityText::as_str)
11496                    }
11497                    _ => None,
11498                }
11499            })
11500            .collect::<BTreeSet<_>>();
11501        require_eq(
11502            &duplicate_sources,
11503            &BTreeSet::from(["Foo::DuplicateA", "Foo::DuplicateB"]),
11504            "duplicate PHP calls must originate from their actual method entities",
11505        )?;
11506        let namespace_call = staged_call("top_level_helper", "TopLevel");
11507        require(namespace_call.is_some_and(|relation| staged.entities.iter().any(|entity| entity.key() == relation.source() && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == "TopLevel"))), "semicolon PHP namespace retains top-level call ownership")?;
11508        for (source, name, parent) in [
11509            (
11510                "<?php class caller { function caller() { helper(); } } function helper() {}",
11511                "caller",
11512                "caller",
11513            ),
11514            (
11515                "<?php\nnamespace Foo;\nfunction Foo() {\nhelper();\n}\nfunction helper() {}",
11516                "Foo",
11517                "Foo",
11518            ),
11519        ] {
11520            let graph = extract_symbol_graph("src/source-collision.php", Some("php"), source);
11521            let staged = finish_graph(&graph)?;
11522            require(staged.relations.iter().filter(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)).any(|relation| staged.entities.iter().any(|entity| entity.key() == relation.source() && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == name && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(parent)))), "PHP callable source must not be hidden by a same-name class or module")?;
11523        }
11524        let trait_graph = extract_symbol_graph(
11525            "src/trait.php",
11526            Some("php"),
11527            "<?php trait T { function run() { self::target(); } function target() {} } class C { use T; function target() {} }",
11528        );
11529        let trait_staged = finish_graph(&trait_graph)?;
11530        require(trait_staged.relations.iter().any(|relation| matches!(relation.resolution(), RelationResolution::Unresolved { reference } if reference.as_str() == "self::target")), "PHP trait self calls depend on the consuming class")?;
11531        let enum_graph = extract_symbol_graph(
11532            "src/enum.php",
11533            Some("php"),
11534            r"<?php
11535namespace {
11536function helper(): void {}
11537}
11538namespace Foo {
11539function helper(): void {}
11540enum NamespacedState {
11541    case Ready;
11542    public function enum_run(): void {
11543        helper();
11544    }
11545}
11546}
11547namespace Bar {
11548function fallback_run(): void {
11549    helper();
11550}
11551}
11552",
11553        );
11554        let enum_staged = finish_graph(&enum_graph)?;
11555        let enum_call = |target: &str, source: &str| {
11556            let parser_relation = enum_graph.relations.iter().find(|relation| {
11557                relation.target_name == target && relation.source_name == source
11558            })?;
11559            let line = u32::try_from(parser_relation.line).ok()?;
11560            let occurrence = enum_staged
11561                .occurrences
11562                .iter()
11563                .find(|occurrence| occurrence.span().start_line() == line)?;
11564            enum_staged
11565                .relations
11566                .iter()
11567                .find(|relation| relation.key() == occurrence.relation())
11568        };
11569        require(
11570            matches!(
11571                enum_call("helper", "enum_run")
11572                    .map(projectatlas_core::graph::LogicalRelation::resolution),
11573                Some(RelationResolution::Resolved {
11574                    selector: ReusableTargetSelector::Symbol { symbol },
11575                    ..
11576                }) if symbol.name.as_str() == "helper"
11577                    && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Foo")
11578            ),
11579            "namespaced PHP enum method call did not prefer its namespace helper",
11580        )?;
11581        require(
11582            matches!(
11583                enum_call("helper", "fallback_run")
11584                    .map(projectatlas_core::graph::LogicalRelation::resolution),
11585                Some(RelationResolution::Resolved {
11586                    selector: ReusableTargetSelector::Symbol { symbol },
11587                    ..
11588                }) if symbol.name.as_str() == "helper" && symbol.parent.is_none()
11589            ),
11590            "namespaced PHP function call did not fall back to the global helper",
11591        )?;
11592        let mixed_call_line = u32::try_from(
11593            php_graph
11594                .relations
11595                .iter()
11596                .find(|relation| relation.target_name == "SERVICE::BOOT")
11597                .ok_or("mixed-case PHP call fixture was missing")?
11598                .line,
11599        )?;
11600        let mixed_call_occurrence = staged
11601            .occurrences
11602            .iter()
11603            .find(|occurrence| occurrence.span().start_line() == mixed_call_line)
11604            .ok_or("mixed-case PHP call occurrence was missing")?;
11605        let mixed_call_relation = staged
11606            .relations
11607            .iter()
11608            .find(|relation| relation.key() == mixed_call_occurrence.relation())
11609            .ok_or("mixed-case PHP logical call was missing")?;
11610        require(
11611            matches!(
11612                mixed_call_relation.resolution(),
11613                RelationResolution::Resolved {
11614                    selector: ReusableTargetSelector::Symbol { symbol },
11615                    ..
11616                } if symbol.name.as_str() == "boot"
11617                    && symbol.kind == SymbolKind::Method
11618                    && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Service")
11619            ),
11620            "mixed-case scoped PHP call did not resolve to Service::boot method",
11621        )?;
11622        for (name, parent) in [
11623            ("prepare", "Service"),
11624            ("boot", "Service"),
11625            ("helper", "<module>"),
11626        ] {
11627            require(
11628                calls.iter().any(|relation| {
11629                    matches!(
11630                        relation.resolution(),
11631                        RelationResolution::Resolved {
11632                            selector: ReusableTargetSelector::Symbol { symbol },
11633                            ..
11634                        } if symbol.name.as_str() == name
11635                            && symbol.parent.as_ref().map(GraphIdentityText::as_str)
11636                                == (parent != "<module>").then_some(parent)
11637                    )
11638                }),
11639                &format!("PHP call did not resolve to {parent}::{name}"),
11640            )?;
11641        }
11642        require(
11643            matches!(
11644                staged_call("static::finish", "run")
11645                    .map(projectatlas_core::graph::LogicalRelation::resolution),
11646                Some(RelationResolution::Unresolved { reference })
11647                    if reference.as_str() == "static::finish"
11648            ),
11649            "late-static PHP calls must remain unresolved without override proof",
11650        )?;
11651        require(
11652            calls.iter().any(|relation| {
11653                matches!(
11654                    relation.resolution(),
11655                    RelationResolution::Unresolved { reference } if reference.as_str() == "parent::inherited"
11656                )
11657            }),
11658            "parent-scoped PHP calls must remain unresolved without a proven base type",
11659        )?;
11660        require(
11661            calls.iter().all(|relation| {
11662                !matches!(
11663                    relation.resolution(),
11664                    RelationResolution::Resolved {
11665                        selector: ReusableTargetSelector::Symbol { symbol },
11666                        ..
11667                    } if symbol.name.as_str() == "boot"
11668                        && symbol.parent.is_none()
11669                )
11670            }),
11671            "PHP scoped calls must not resolve through an unrelated global member",
11672        )?;
11673
11674        let mut ambiguous_graph = php_graph;
11675        let mut duplicate = ambiguous_graph
11676            .symbols
11677            .iter()
11678            .find(|symbol| symbol.name == "helper")
11679            .cloned()
11680            .ok_or("PHP ambiguity fixture helper was missing")?;
11681        duplicate.name = "HELPER".to_string();
11682        ambiguous_graph.symbols.push(duplicate);
11683        let ambiguous = finish_graph(&ambiguous_graph)?;
11684        require(
11685            ambiguous.relations.iter().any(|relation| {
11686                matches!(
11687                    relation.resolution(),
11688                    RelationResolution::Ambiguous {
11689                        reference,
11690                        candidates,
11691                    } if reference.as_str() == "HELPER" && candidates.get() == 2
11692                )
11693            }),
11694            "case-insensitive PHP duplicate declarations must remain ambiguous",
11695        )?;
11696
11697        let mut scope_failures = Vec::new();
11698        for (source, target, expected_parent) in [
11699            (
11700                "<?php\nclass Service { public static function boot() {} }\nService::boot();\n$callable();",
11701                "partial global static call",
11702                Some("Service"),
11703            ),
11704            (
11705                "<?php\nfunction helper() {}\nhelper();\n$callable();",
11706                "partial global function call",
11707                None,
11708            ),
11709            (
11710                "<?php\nrequire 'bootstrap.php';\nfunction helper() {}\nfunction run() { helper(); }",
11711                "require and local helper",
11712                None,
11713            ),
11714            (
11715                "<?php\ninclude 'a.php'; include_once 'b.php'; require_once 'c.php';\nfunction helper() {}\nfunction run() { helper(); }",
11716                "other include forms and local helper",
11717                None,
11718            ),
11719            (
11720                "<?php\nrequire 'bootstrap.php';\nfunction helper() {}\nfunction run() { helper(); $callable(); }",
11721                "partial require and local helper",
11722                None,
11723            ),
11724            (
11725                "<?php\ninclude 'a.php'; include_once 'b.php'; require_once 'c.php';\nfunction helper() {}\nfunction run() { helper(); $callable(); }",
11726                "partial include forms and local helper",
11727                None,
11728            ),
11729            (
11730                "<?php\nrequire/* comment */('bootstrap'); include_once(\"config\"); REQUIRE_ONCE # comment\n'library';\nfunction helper() {}\nfunction run() { helper(); $callable(); }",
11731                "partial commented and parenthesized includes",
11732                None,
11733            ),
11734            (
11735                "<?php\ntrait Shared {}\nclass Service { use Shared; public static function boot() {} }\nfunction run() { Service::boot(); $callable(); }",
11736                "partial trait import and local static call",
11737                Some("Service"),
11738            ),
11739            (
11740                "<?php\ntrait Shared {}\nclass Service { use Shared; public static function boot() {} }\nfunction run() { Service::boot(); }",
11741                "trait import and local static call",
11742                Some("Service"),
11743            ),
11744            (
11745                "<?php\nnamespace Foo;\nfunction run() { Sub\\helper(); }\nnamespace Foo\\Sub;\nfunction helper() {}",
11746                "relative qualified helper",
11747                Some("Foo\\Sub"),
11748            ),
11749            (
11750                "<?php\nnamespace { function run() { Sub\\helper(); } }\nnamespace Sub { function helper() {} }",
11751                "global qualified helper",
11752                Some("Sub"),
11753            ),
11754            (
11755                "<?php\nnamespace Foo;\nfunction run() { Sub\\Service::boot(); }\nnamespace Foo\\Sub;\nclass Service { public static function boot() {} }",
11756                "relative qualified static method",
11757                Some("Foo\\Sub::Service"),
11758            ),
11759            (
11760                "<?php\nclass Service { public static function boot() {} }\nfunction run() { Service::boot(); }",
11761                "Service::boot",
11762                Some("Service"),
11763            ),
11764            (
11765                "<?php\nclass Service { public static function boot() {} }\nService::boot();",
11766                "Service::boot",
11767                Some("Service"),
11768            ),
11769            (
11770                "<?php\nfunction helper() {}\nfunction run() { namespace\\helper(); }",
11771                "namespace\\helper",
11772                None,
11773            ),
11774            (
11775                "<?php\nnamespace Foo;\nfunction helper() {}\nfunction run() { namespace\\helper(); }",
11776                "namespace\\helper",
11777                Some("Foo"),
11778            ),
11779            (
11780                "<?php\nnamespace Foo { use function Other\\helper; function run() { namespace\\helper(); } }\nnamespace Foo { function helper() {} }",
11781                "namespace\\helper",
11782                Some("Foo"),
11783            ),
11784            (
11785                "<?php\nnamespace Foo;\nfunction helper() {}\nNaMeSpAcE\\helper();",
11786                "NaMeSpAcE\\helper",
11787                Some("Foo"),
11788            ),
11789            (
11790                "<?php\nnamespace Foo;\nfunction run() { namespace\\Child\\helper(); }\nnamespace Foo\\Child;\nfunction helper() {}",
11791                "namespace\\Child\\helper",
11792                Some("Foo\\Child"),
11793            ),
11794        ] {
11795            let graph = extract_symbol_graph("src/explicit-scope.php", Some("php"), source);
11796            let staged = finish_graph(&graph)?;
11797            if !staged.relations.iter().any(|relation| {
11798                matches!(
11799                    relation.resolution(),
11800                    RelationResolution::Resolved {
11801                        selector: ReusableTargetSelector::Symbol { symbol },
11802                        ..
11803                    } if symbol.parent.as_ref().map(GraphIdentityText::as_str) == expected_parent
11804                ) && relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
11805            }) {
11806                scope_failures.push(target);
11807            }
11808        }
11809        require(
11810            scope_failures.is_empty(),
11811            &format!("proven PHP call scopes did not resolve: {scope_failures:?}"),
11812        )?;
11813
11814        let mut unknown_caller = extract_symbol_graph(
11815            "src/unknown-scope.php",
11816            Some("php"),
11817            "<?php\nclass Service { public static function boot() {} }\nfunction run() { Service::boot(); }",
11818        );
11819        for relation in &mut unknown_caller.relations {
11820            if relation.kind == RelationKind::Calls {
11821                relation.source_name = "unknown_caller".to_string();
11822            }
11823        }
11824        let ambiguous_caller = extract_symbol_graph(
11825            "src/ambiguous-scope.php",
11826            Some("php"),
11827            "<?php namespace { class Service { public static function boot() {} } function run() { Service::boot(); } } namespace Foo { function run() {} }",
11828        );
11829        for graph in [&unknown_caller, &ambiguous_caller] {
11830            let staged = finish_graph(graph)?;
11831            require(
11832                staged
11833                    .relations
11834                    .iter()
11835                    .filter(|relation| {
11836                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
11837                    })
11838                    .all(|relation| {
11839                        staged.entities.iter().any(|entity| {
11840                            entity.key() == relation.source()
11841                                && matches!(entity.selector(), EntitySelector::File { .. })
11842                        })
11843                    }),
11844                "unknown and same-line ambiguous PHP callers must retain file ownership",
11845            )?;
11846            require(
11847                staged.relations.iter().any(|relation| {
11848                    matches!(
11849                        relation.resolution(),
11850                        RelationResolution::Unresolved { reference } if reference.as_str() == "Service::boot"
11851                    )
11852                }),
11853                "unknown or ambiguous PHP callers must not be treated as proven global functions",
11854            )?;
11855        }
11856        for source in [
11857            "<?php namespace Foo; function Foo() {} Foo();",
11858            "<?php namespace Foo; function Foo() { helper(); } function helper() {}",
11859        ] {
11860            let graph = extract_symbol_graph("src/namespace-boundary.php", Some("php"), source);
11861            let staged = finish_graph(&graph)?;
11862            require(
11863                staged
11864                    .relations
11865                    .iter()
11866                    .filter(|relation| {
11867                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
11868                    })
11869                    .all(|relation| {
11870                        staged.entities.iter().any(|entity| {
11871                            entity.key() == relation.source()
11872                                && matches!(entity.selector(), EntitySelector::File { .. })
11873                        })
11874                    }),
11875                "PHP namespace/callable boundary-line collisions must remain file-owned",
11876            )?;
11877        }
11878
11879        for source in [
11880            r"<?php namespace A { function helper() {} function before() { helper(); } use function Vendor\{helper, other}; function after() { helper(); } }",
11881            r"<?php namespace A; function helper() {} function before() { helper(); } use function Vendor\helper, Vendor\other; function after() { helper(); }",
11882            r"<?php namespace A { use function Vendor\{helper, other}; } namespace B { function helper() {} function before() { helper(); } use function Remote\helper; function after() { helper(); } }",
11883        ] {
11884            let graph = extract_symbol_graph("src/import-groups.php", Some("php"), source);
11885            let grouped = finish_graph(&graph)?;
11886            for caller in ["before", "after"] {
11887                require(
11888                    grouped.relations.iter().any(|relation| {
11889                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
11890                            && matches!(relation.resolution(), RelationResolution::Unresolved { .. })
11891                            && grouped.entities.iter().any(|entity| entity.key() == relation.source()
11892                                && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == caller))
11893                    }),
11894                    "PHP grouped imports with unmatched source occurrences must stay conservative",
11895                )?;
11896            }
11897        }
11898
11899        let collision = extract_symbol_graph(
11900            "src/containment-collision.php",
11901            Some("php"),
11902            "<?php namespace Service { class Service { function run() {} } function outside() {} }",
11903        );
11904        let contained = finish_graph(&collision)?;
11905        for (name, parent) in [
11906            ("Service", "Service"),
11907            ("run", "Service::Service"),
11908            ("outside", "Service"),
11909        ] {
11910            require(
11911                contained.relations.iter().any(|relation| {
11912                    relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
11913                        && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
11914                            if symbol.name.as_str() == name && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(parent))
11915                        && contained.entities.iter().any(|entity| entity.key() == relation.source()
11916                            && matches!(entity.selector(), EntitySelector::Symbol { symbol }
11917                                if symbol.name.as_str() == "Service" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == if name == "run" { Some("Service") } else { None }))
11918                }),
11919                &format!("PHP same-line namespace and type collisions must use exact containment spans: {name} {parent}"),
11920            )?;
11921        }
11922
11923        for source in [
11924            "<?php class A { public $run; function run() {} const run = 1; }",
11925            "<?php trait A { public $run; function run() {} const run = 1; }",
11926        ] {
11927            let graph = extract_symbol_graph("src/member-kinds.php", Some("php"), source);
11928            let members = finish_graph(&graph)?;
11929            require_eq(
11930                &graph
11931                    .symbols
11932                    .iter()
11933                    .filter(|symbol| symbol.name == "run")
11934                    .count(),
11935                &3,
11936                "PHP same-name fixture must retain property, method, and constant",
11937            )?;
11938            for expected in graph.symbols.iter().filter(|symbol| symbol.name == "run") {
11939                require(
11940                    members.relations.iter().any(|relation| {
11941                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
11942                            && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
11943                                if symbol.name.as_str() == "run" && symbol.kind == expected.kind && symbol.signature.as_str() == expected.signature)
11944                            && members.entities.iter().any(|entity| entity.key() == relation.source()
11945                                && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == "A"))
11946                    }),
11947                    &format!("same-line PHP {} must retain its distinct containment edge", expected.signature),
11948                )?;
11949            }
11950        }
11951
11952        let imported_graph = extract_symbol_graph(
11953            "src/imports.php",
11954            Some("php"),
11955            r"<?php
11956namespace Local {
11957    use Remote\Service;
11958    use Remote\NamespaceAlias as Sub;
11959    use function Remote\helper;
11960    function run() { Service::boot(); helper(); Sub\helper(); }
11961}
11962namespace Local {
11963    class Service { public static function boot() {} }
11964    function helper() {}
11965}
11966namespace Local\Sub { function helper() {} }
11967",
11968        );
11969        let staged = finish_graph(&imported_graph)?;
11970        for target in ["Service::boot", "helper", "Sub\\helper"] {
11971            require(
11972                staged.relations.iter().any(|relation| {
11973                    matches!(
11974                        relation.resolution(),
11975                        RelationResolution::Unresolved { reference } if reference.as_str() == target
11976                    )
11977                }),
11978                "PHP imports must not be bypassed by same-file unqualified call matching",
11979            )?;
11980        }
11981
11982        for source in [
11983            r"<?php
11984namespace A { use function Vendor\helper; function run_a() { helper(); } }
11985namespace B { function helper() {} function run_b() { helper(); } }
11986",
11987            r"<?php
11988namespace A;
11989use function Vendor\helper;
11990function run_a() { helper(); }
11991namespace B;
11992function helper() {}
11993function run_b() { helper(); }
11994",
11995        ] {
11996            let graph = extract_symbol_graph("src/import-scopes.php", Some("php"), source);
11997            let scoped = finish_graph(&graph)?;
11998            require(
11999                scoped.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12000                    && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
12001                        if symbol.name.as_str() == "helper" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("B"))),
12002                "PHP import in namespace A must not suppress a proven namespace B call",
12003            )?;
12004            require(
12005                scoped.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12006                    && matches!(relation.resolution(), RelationResolution::Unresolved { reference } if reference.as_str() == "helper")),
12007                "PHP imported alias remains unresolved in its owning namespace",
12008            )?;
12009        }
12010
12011        for source in [
12012            r"<?php namespace A { function helper() {} function before() { helper(); } use function Vendor\helper; function after() { helper(); } }",
12013            r"<?php namespace A; function before() { Sub\helper(); } use Vendor as Sub; function after() { Sub\helper(); } namespace A\Sub; function helper() {}",
12014            r"<?php namespace A { function helper() {} function before() { helper(); } use function Vendor\helper; use Vendor\Other; function after() { helper(); } } namespace B { use function Vendor\helper; function run_b() { helper(); } }",
12015            r"<?php
12016namespace { function helper() {} }
12017namespace A {
12018function before() { helper(); }
12019use function Vendor\helper;
12020function after() { helper(); }
12021}
12022",
12023            r"<?php
12024namespace A;
12025function before() { Sub\helper(); }
12026use Vendor as Sub;
12027function after() { Sub\helper(); }
12028namespace A\Sub;
12029function helper() {}
12030",
12031        ] {
12032            let graph = extract_symbol_graph("src/import-order.php", Some("php"), source);
12033            let staged = finish_graph(&graph)?;
12034            for (caller, resolved) in [("before", true), ("after", false)] {
12035                require(
12036                    staged.relations.iter().any(|relation| {
12037                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12038                            && matches!(relation.resolution(), RelationResolution::Resolved { .. }) == resolved
12039                            && staged.entities.iter().any(|entity| entity.key() == relation.source()
12040                                && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == caller))
12041                    }),
12042                    &format!("PHP {caller} call must apply only imports already declared"),
12043                )?;
12044            }
12045        }
12046
12047        for source in [
12048            r"<?php
12049namespace A {
12050use function Vendor\helper;
12051function imported() { helper(); }
12052}
12053namespace A {
12054function helper() {}
12055function local() { helper(); }
12056}
12057",
12058            r"<?php
12059namespace A;
12060use Vendor as Sub;
12061function imported() { Sub\helper(); }
12062namespace A;
12063function local() { Sub\helper(); }
12064namespace A\Sub;
12065function helper() {}
12066",
12067        ] {
12068            let graph = extract_symbol_graph("src/reopened-namespace.php", Some("php"), source);
12069            let staged = finish_graph(&graph)?;
12070            for (caller, resolved) in [("imported", false), ("local", true)] {
12071                require(
12072                    staged.relations.iter().any(|relation| {
12073                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12074                            && matches!(relation.resolution(), RelationResolution::Resolved { .. }) == resolved
12075                            && staged.entities.iter().any(|entity| entity.key() == relation.source()
12076                                && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == caller))
12077                    }),
12078                    &format!("PHP {caller} call must use only its namespace declaration block"),
12079                )?;
12080            }
12081        }
12082
12083        for source in [
12084            "<?php namespace Foo { function helper() {} } namespace Foo { helper(); }",
12085            "<?php\nnamespace Foo;\nfunction helper() {}\nnamespace Foo;\nhelper();",
12086        ] {
12087            let graph = extract_symbol_graph("src/reopened-top-level.php", Some("php"), source);
12088            let staged = finish_graph(&graph)?;
12089            require(
12090                staged.relations.iter().any(|relation| {
12091                    relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12092                        && staged.entities.iter().any(|entity| {
12093                            entity.key() == relation.source()
12094                                && matches!(entity.selector(), EntitySelector::Symbol { symbol }
12095                                    if symbol.name.as_str() == "Foo" && symbol.kind == SymbolKind::Module)
12096                        })
12097                }),
12098                "Reopened PHP namespace must retain its top-level call source",
12099            )?;
12100        }
12101
12102        for (source, remove_namespaces) in [
12103            (
12104                "<?php namespace A { use function Vendor\\helper; } namespace A { function helper() {} function local() { helper(); } }",
12105                false,
12106            ),
12107            (
12108                "<?php\nnamespace A {\nuse function Vendor\\helper;\n}\nnamespace A {\nfunction helper() {}\nfunction local() { helper(); }\n}",
12109                true,
12110            ),
12111        ] {
12112            let mut graph =
12113                extract_symbol_graph("src/uncertain-namespace.php", Some("php"), source);
12114            if remove_namespaces {
12115                graph
12116                    .symbols
12117                    .retain(|symbol| symbol.kind != SymbolKind::Module);
12118            }
12119            let staged = finish_graph(&graph)?;
12120            require(
12121                staged.relations.iter().any(|relation| {
12122                    relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12123                        && matches!(relation.resolution(), RelationResolution::Unresolved { .. })
12124                }),
12125                "PHP imports with tied or missing namespace boundaries remain unresolved",
12126            )?;
12127            require(
12128                !staged.relations.iter().any(|relation| {
12129                    relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12130                        && matches!(relation.resolution(), RelationResolution::Resolved { .. })
12131                }),
12132                "PHP uncertain namespace boundaries must not invent call resolution",
12133            )?;
12134        }
12135
12136        let oversized_prefix = "N".repeat(241);
12137        for import in [
12138            format!("use Vendor\\{oversized_prefix} as Sub;"),
12139            format!("use Vendor\\{oversized_prefix}\\Sub;"),
12140            format!("use {oversized_prefix}\\{{Sub}};"),
12141            format!("use {}\\{{Sub}};", "N".repeat(238)),
12142            format!("use Vendor\\Kept, Vendor\\{oversized_prefix} as Sub;"),
12143        ] {
12144            let source = format!(
12145                "<?php namespace Foo {{ {import} function run() {{ Sub\\helper(); namespace\\Sub\\helper(); }} function absolute() {{ \\Foo\\Sub\\helper(); }} }} namespace Foo\\Sub {{ function helper() {{}} }}"
12146            );
12147            let graph = extract_symbol_graph("src/oversized-imports.php", Some("php"), &source);
12148            let staged = finish_graph(&graph)?;
12149            require(
12150                staged.relations.iter().any(|relation| {
12151                    matches!(relation.resolution(), RelationResolution::Unresolved { reference }
12152                        if reference.as_str() == "Sub\\helper")
12153                }),
12154                &format!("omitted PHP import must retain alias uncertainty: {import}"),
12155            )?;
12156            require_eq(
12157                &staged
12158                    .relations
12159                    .iter()
12160                    .filter(|relation| {
12161                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12162                            && matches!(relation.resolution(), RelationResolution::Resolved { .. })
12163                    })
12164                    .count(),
12165                &2,
12166                "rooted and namespace-relative PHP calls must remain independently resolvable",
12167            )?;
12168        }
12169
12170        let mut partial_imports = imported_graph;
12171        partial_imports.parser = ParserKind::Fallback;
12172        partial_imports
12173            .symbols
12174            .retain(|symbol| symbol.kind != SymbolKind::Import);
12175        let partial_staged = finish_graph(&partial_imports)?;
12176        require(
12177            partial_staged.relations.iter().any(|relation| {
12178                matches!(relation.resolution(), RelationResolution::Unresolved { reference }
12179                    if reference.as_str() == "Sub\\helper")
12180            }),
12181            "partial PHP import evidence must not prove absence of namespace aliases",
12182        )?;
12183        for context in [
12184            "require\\Sub",
12185            "include_once\\Sub",
12186            "requireé",
12187            "require\u{a0}Alias",
12188            "",
12189            "use function Vendor\\helper;",
12190        ] {
12191            for relation in &mut partial_imports.relations {
12192                if relation.kind == RelationKind::Imports {
12193                    relation.context = context.to_string();
12194                }
12195            }
12196            let staged = finish_graph(&partial_imports)?;
12197            require(
12198                staged.relations.iter().any(|relation| {
12199                    matches!(relation.resolution(), RelationResolution::Unresolved { reference }
12200                        if reference.as_str() == "Sub\\helper")
12201                }),
12202                "unknown, namespace-prefix, and fallback use contexts must retain alias uncertainty",
12203            )?;
12204        }
12205        let missing_relative = extract_symbol_graph(
12206            "src/missing-relative.php",
12207            Some("php"),
12208            "<?php\nnamespace { function helper() {} }\nnamespace Foo { function run() { Missing\\helper(); } }",
12209        );
12210        let staged = finish_graph(&missing_relative)?;
12211        require(
12212            staged.relations.iter().any(|relation| {
12213                matches!(relation.resolution(), RelationResolution::Unresolved { reference }
12214                    if reference.as_str() == "Missing\\helper")
12215            }),
12216            "qualified PHP functions must not fall back to the global function",
12217        )?;
12218
12219        let wrong_namespace_graph = extract_symbol_graph(
12220            "src/scoped.php",
12221            Some("php"),
12222            "<?php\nnamespace Caller { function run(): void { Service::boot(); } }\nnamespace Other { class Service { public static function boot(): void {} } }\n",
12223        );
12224        let staged = finish_graph(&wrong_namespace_graph)?;
12225        require(
12226            staged.relations.iter().any(|relation| {
12227                matches!(
12228                    relation.resolution(),
12229                    RelationResolution::Unresolved { reference } if reference.as_str() == "Service::boot"
12230                )
12231            }),
12232            "unqualified PHP static calls must not resolve to a class in another namespace",
12233        )?;
12234
12235        let case_sensitive_graph = extract_symbol_graph(
12236            "src/case-sensitive.js",
12237            Some("javascript"),
12238            "function helper() {}\nfunction caller() { HELPER(); }\n",
12239        );
12240        let staged = finish_graph(&case_sensitive_graph)?;
12241        require(
12242            staged.relations.iter().any(|relation| {
12243                matches!(
12244                    relation.resolution(),
12245                    RelationResolution::Unresolved { reference } if reference.as_str() == "HELPER"
12246                )
12247            }),
12248            "non-PHP call matching must remain case-sensitive",
12249        )?;
12250        Ok(())
12251    }
12252
12253    #[test]
12254    fn configured_module_targets_reuse_graph_ambiguity_ownership() -> Result<(), Box<dyn Error>> {
12255        let project = ProjectInstanceId::from_bytes([19; 16])?;
12256        let generation = IndexGeneration::new(1);
12257        let control = IndexWorkControl::new(IndexCancellation::new(), None);
12258        let graphs = vec![
12259            extract_symbol_graph(
12260                "src/first/controller.ts",
12261                Some("typescript"),
12262                "export function useController() { return 'first'; }\n",
12263            ),
12264            extract_symbol_graph(
12265                "src/second/controller.ts",
12266                Some("typescript"),
12267                "export function useController() { return 'second'; }\n",
12268            ),
12269            extract_symbol_graph(
12270                "src/page.ts",
12271                Some("typescript"),
12272                "import { useController } from '@/controller';\nexport const value = useController();\n",
12273            ),
12274        ];
12275        let packages = PackageIndex::from_graphs(&graphs)?;
12276        let configured = ConfiguredModuleResolution::new(vec![EcmaScriptModuleConfig::new(
12277            "tsconfig.json",
12278            EcmaScriptConfigKind::TypeScript,
12279            None,
12280            vec![EcmaScriptPathMapping::new(
12281                "@/*",
12282                vec!["src/first/*".to_string(), "src/second/*".to_string()],
12283            )?],
12284        )?])?;
12285        let projection = build_entity_projection_with_config(
12286            project,
12287            generation,
12288            &[],
12289            &graphs,
12290            &packages,
12291            &configured,
12292            None,
12293            true,
12294            &control,
12295        )?;
12296        let candidates = resolution_registry_from_exports(&projection, &control)?;
12297        let staged = finish_projection(
12298            project,
12299            generation,
12300            RepositoryGraphMutation::Full,
12301            &graphs,
12302            projection,
12303            &candidates,
12304            &control,
12305        )?;
12306        for kind in [RelationKind::Imports, RelationKind::Calls] {
12307            require(
12308                staged.relations.iter().any(|relation| {
12309                    relation.kind() == GraphRelationKind::from_legacy(kind)
12310                        && matches!(
12311                            relation.resolution(),
12312                            RelationResolution::Ambiguous { candidates, .. }
12313                                if candidates.get() == 2
12314                        )
12315                }),
12316                "configured mapping did not retain all ambiguity candidates",
12317            )?;
12318        }
12319        Ok(())
12320    }
12321
12322    #[test]
12323    fn extracted_provider_matrix_survives_sqlite_publication_and_reopen()
12324    -> Result<(), Box<dyn Error>> {
12325        let temp = tempfile::tempdir()?;
12326        let root = temp.path().join("extracted-provider-matrix");
12327        fs::create_dir_all(&root)?;
12328        let database = root.join("projectatlas.db");
12329        let mut store = AtlasStore::open_for_project(&database, &root)?;
12330        let project = store
12331            .project_instance_id()?
12332            .ok_or("bound project identity is missing")?;
12333        let generation = IndexGeneration::new(1);
12334        let control = IndexWorkControl::new(IndexCancellation::new(), None);
12335        let graphs = vec![
12336            extract_symbol_graph(
12337                "src/lib.rs",
12338                Some("rust"),
12339                "use std::{fs, io};\npub fn caller() { private_helper(); }\nfn private_helper() {}\n",
12340            ),
12341            extract_symbol_graph(
12342                "src/config.rs",
12343                Some("rust"),
12344                "pub fn load_timeout_millis() -> u64 { 250 }\n",
12345            ),
12346            extract_symbol_graph(
12347                "src/handler.rs",
12348                Some("rust"),
12349                "use crate::config;\npub fn health_response() { config::load_timeout_millis(); }\n",
12350            ),
12351            extract_symbol_graph(
12352                "src/router.rs",
12353                Some("rust"),
12354                "use crate::handler;\npub fn dispatch(path: &str) -> Option<()> { (path == \"/health\").then(handler::health_response) }\n",
12355            ),
12356            extract_symbol_graph(
12357                "src/app.js",
12358                Some("javascript"),
12359                "import path from \"node:path\";\nexport function run() { return path.join('a', 'b'); }\n",
12360            ),
12361            extract_symbol_graph(
12362                "src/app.py",
12363                Some("python"),
12364                "import requests\n\ndef run():\n    return requests.get('https://example.test')\n",
12365            ),
12366            extract_symbol_graph(
12367                "Cargo.toml",
12368                Some("cargo-manifest"),
12369                "[package]\nname = \"matrix-app\"\nversion = \"0.1.0\"\n\n[dependencies]\nduplicate = \"1\"\n",
12370            ),
12371            extract_symbol_graph(
12372                "vendor/first/Cargo.toml",
12373                Some("cargo-manifest"),
12374                "[package]\nname = \"duplicate\"\nversion = \"1.0.0\"\n",
12375            ),
12376            extract_symbol_graph(
12377                "vendor/second/Cargo.toml",
12378                Some("cargo-manifest"),
12379                "[package]\nname = \"duplicate\"\nversion = \"2.0.0\"\n",
12380            ),
12381            extract_symbol_graph(
12382                "public/index.html",
12383                Some("html"),
12384                "<script type=\"module\">\nimport fs from \"node:fs\";\nexport function boot() { return fs.readFile; }\n</script>\n",
12385            ),
12386            extract_symbol_graph(
12387                "src/Page.svelte",
12388                Some("svelte"),
12389                "<script lang=\"ts\">\nimport url from \"node:url\";\nexport function page() { return url.parse('https://example.test'); }\n</script>\n",
12390            ),
12391        ];
12392        for (language, expected_relation) in [
12393            ("rust", "std"),
12394            ("javascript", "node:path"),
12395            ("python", "requests"),
12396            ("cargo-manifest", "duplicate"),
12397            ("html", "node:fs"),
12398            ("svelte", "node:url"),
12399        ] {
12400            require(
12401                graphs.iter().any(|graph| {
12402                    graph.language.as_deref() == Some(language)
12403                        && graph
12404                            .relations
12405                            .iter()
12406                            .any(|relation| relation.target_name.contains(expected_relation))
12407                }),
12408                &format!("extracted {language} provider relation is missing"),
12409            )?;
12410        }
12411        let nodes = graphs
12412            .iter()
12413            .map(|graph| {
12414                test_file_node(&graph.path, graph.language.as_deref().unwrap_or("unknown"))
12415            })
12416            .collect::<Vec<_>>();
12417        let packages = PackageIndex::from_graphs(&graphs)?;
12418        let projection = build_entity_projection(
12419            project, generation, &nodes, &graphs, &packages, true, &control,
12420        )?;
12421        let candidates = resolution_registry_from_exports(&projection, &control)?;
12422        let staged = finish_projection(
12423            project,
12424            generation,
12425            RepositoryGraphMutation::Full,
12426            &graphs,
12427            projection,
12428            &candidates,
12429            &control,
12430        )?;
12431        let source_keys = staged
12432            .entities
12433            .iter()
12434            .map(|entity| entity.key().clone())
12435            .collect::<Vec<_>>();
12436        {
12437            let mut publication = store.begin_index_publication("extracted-provider-matrix")?;
12438            publication.begin_scan_replacement()?;
12439            publication.upsert_scan_node_batch(&nodes)?;
12440            publication.finish_scan_replacement()?;
12441            staged.apply(&mut publication, &control)?;
12442            publication.complete()?;
12443        }
12444        drop(store);
12445
12446        let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
12447        let mut reopened = Vec::new();
12448        for source in source_keys {
12449            let page = reader.repository_graph_relations(
12450                RepositoryGraphRelationQuery::Outbound { source },
12451                128,
12452            )?;
12453            require(!page.truncated, "extracted provider matrix was truncated")?;
12454            reopened.extend(page.rows);
12455        }
12456        let mut external = BTreeSet::new();
12457        let mut unresolved = BTreeSet::new();
12458        let mut ambiguous = BTreeMap::new();
12459        let mut resolved_symbols = BTreeSet::new();
12460        for relation in reopened {
12461            require_eq(
12462                &relation.generation(),
12463                &generation,
12464                "extracted relation generation",
12465            )?;
12466            match relation.resolution() {
12467                RelationResolution::External {
12468                    external: target, ..
12469                } => {
12470                    external.insert((
12471                        target.system.as_str().to_string(),
12472                        target.identity.as_str().to_string(),
12473                    ));
12474                }
12475                RelationResolution::Unresolved { reference } => {
12476                    unresolved.insert(reference.as_str().to_string());
12477                }
12478                RelationResolution::Ambiguous {
12479                    reference,
12480                    candidates,
12481                } => {
12482                    ambiguous.insert(reference.as_str().to_string(), candidates.get());
12483                }
12484                RelationResolution::Resolved { selector, .. } => {
12485                    if let ReusableTargetSelector::Symbol { symbol } = selector {
12486                        resolved_symbols.insert((
12487                            symbol.file.as_str().to_string(),
12488                            symbol.name.as_str().to_string(),
12489                        ));
12490                    }
12491                }
12492            }
12493        }
12494        for expected in [
12495            ("rust-toolchain".to_string(), "std".to_string()),
12496            ("node".to_string(), "path".to_string()),
12497            ("node".to_string(), "fs".to_string()),
12498            ("node".to_string(), "url".to_string()),
12499        ] {
12500            require(
12501                external.contains(&expected),
12502                &format!("reopened external target is missing: {expected:?}"),
12503            )?;
12504        }
12505        require(
12506            unresolved
12507                .iter()
12508                .any(|reference| reference.contains("requests")),
12509            "reopened Python unresolved import is missing",
12510        )?;
12511        require_eq(
12512            &ambiguous.get("duplicate"),
12513            &Some(&2),
12514            "reopened Cargo duplicate ambiguity",
12515        )?;
12516        require(
12517            resolved_symbols.contains(&("src/lib.rs".to_string(), "private_helper".to_string())),
12518            "reopened private Rust helper resolution is missing",
12519        )?;
12520        require(
12521            resolved_symbols.contains(&(
12522                "src/config.rs".to_string(),
12523                "load_timeout_millis".to_string(),
12524            )),
12525            "reopened Rust module-qualified call resolution is missing",
12526        )?;
12527        require(
12528            resolved_symbols
12529                .contains(&("src/handler.rs".to_string(), "health_response".to_string())),
12530            "reopened Rust callback resolution is missing",
12531        )?;
12532        reader.finish_index_read_snapshot()?;
12533        Ok(())
12534    }
12535
12536    #[test]
12537    fn closed_resolution_states_survive_sqlite_publication_and_reopen() -> Result<(), Box<dyn Error>>
12538    {
12539        let temp = tempfile::tempdir()?;
12540        let root = temp.path().join("closed-resolution-states");
12541        fs::create_dir_all(&root)?;
12542        let database = root.join("projectatlas.db");
12543        let mut store = AtlasStore::open_for_project(&database, &root)?;
12544        let project = store
12545            .project_instance_id()?
12546            .ok_or("bound project identity is missing")?;
12547        let generation = IndexGeneration::new(1);
12548        let control = IndexWorkControl::new(IndexCancellation::new(), None);
12549        let source = test_file_entity(project, generation, "src/main.rs")?;
12550        let unique_target = test_symbol_entity(project, generation, "src/unique.rs", "unique")?;
12551        let first_shared = test_symbol_entity(project, generation, "src/first.rs", "shared")?;
12552        let second_shared = test_symbol_entity(project, generation, "src/second.rs", "shared")?;
12553        let local_package = GraphEntity::new(
12554            project,
12555            EntitySelector::Package {
12556                package: PackageSelector {
12557                    manager: GraphIdentityText::new("cargo")?,
12558                    name: GraphIdentityText::new("local-package")?,
12559                    manifest: RepositoryFilePath::new(Path::new("vendor/local/Cargo.toml"))?,
12560                },
12561            },
12562            generation,
12563        )?;
12564        let unique_key = test_resolution_key(project, "unique")?;
12565        let shared_key = test_resolution_key(project, "shared")?;
12566        let local_package_key = test_resolution_key(project, "local-package")?;
12567        let mut registry = ProjectResolutionRegistry::default();
12568        registry.insert_candidate(&unique_key, &unique_target)?;
12569        registry.insert_candidate(&shared_key, &first_shared)?;
12570        registry.insert_candidate(&shared_key, &second_shared)?;
12571        registry.insert_candidate(&local_package_key, &local_package)?;
12572        let source_digest = source.key().digest().to_string();
12573        let owners = GraphOwners {
12574            file_digest: source_digest.clone(),
12575            symbol_digests: Vec::new(),
12576        };
12577        let staged_entities = BTreeMap::from([(source_digest, source.clone())]);
12578        let mut external_entities = BTreeMap::new();
12579        let cases = [
12580            resolution_case("rust", RelationKind::Calls, "unique", &[&unique_key]),
12581            resolution_case("rust", RelationKind::Calls, "shared", &[&shared_key]),
12582            resolution_case("rust", RelationKind::Calls, "missing", &[]),
12583            resolution_case("rust", RelationKind::Imports, "use std::{fs, io};", &[]),
12584            resolution_case("cargo-manifest", RelationKind::DependsOn, "serde", &[]),
12585            resolution_case("cargo-lock", RelationKind::DependsOn, "serde-lock", &[]),
12586            resolution_case(
12587                "javascript",
12588                RelationKind::Imports,
12589                "import path from \"node:path\";",
12590                &[],
12591            ),
12592            resolution_case(
12593                "html",
12594                RelationKind::Imports,
12595                "import fs from \"node:fs\";",
12596                &[],
12597            ),
12598            resolution_case(
12599                "svelte",
12600                RelationKind::Imports,
12601                "import url from \"node:url\";",
12602                &[],
12603            ),
12604            resolution_case(
12605                "javascript",
12606                RelationKind::Imports,
12607                "import value from \"left-pad\";",
12608                &[],
12609            ),
12610            resolution_case("python", RelationKind::Imports, "import requests", &[]),
12611            resolution_case(
12612                "cargo-manifest",
12613                RelationKind::DependsOn,
12614                "local-package",
12615                &[&local_package_key],
12616            ),
12617        ];
12618        let mut relations = Vec::new();
12619        let mut dependencies = Vec::new();
12620        for case in &cases {
12621            let symbol_index = GraphSymbolIndex::new(&case.graph, &control)?;
12622            let resolution = relation_resolution(
12623                project,
12624                generation,
12625                &case.relation,
12626                None,
12627                &owners,
12628                &case.graph,
12629                &symbol_index,
12630                &case.keys,
12631                &registry,
12632                &staged_entities,
12633                &mut external_entities,
12634                &control,
12635            )?;
12636            let relation = LogicalRelation::new(
12637                &source,
12638                GraphRelationKind::from_legacy(case.relation.kind),
12639                resolution,
12640                ConfidenceClass::High,
12641                Completeness::Complete,
12642                generation,
12643            )?;
12644            for key in &case.keys {
12645                dependencies.push(RelationDependencyKey::new(
12646                    relation.key().clone(),
12647                    key.clone(),
12648                )?);
12649            }
12650            relations.push(relation);
12651        }
12652        let external_keys = external_entities
12653            .values()
12654            .map(|entity| entity.key().clone())
12655            .collect::<Vec<_>>();
12656        let entities = [
12657            source.clone(),
12658            unique_target,
12659            first_shared,
12660            second_shared,
12661            local_package,
12662        ]
12663        .into_iter()
12664        .chain(external_entities.into_values())
12665        .collect::<Vec<_>>();
12666        let exports = [
12667            EntityResolutionKey::new(entities[1].key().clone(), unique_key.clone())?,
12668            EntityResolutionKey::new(entities[2].key().clone(), shared_key.clone())?,
12669            EntityResolutionKey::new(entities[3].key().clone(), shared_key.clone())?,
12670            EntityResolutionKey::new(entities[4].key().clone(), local_package_key.clone())?,
12671        ];
12672        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
12673        let staged = StagedRepositoryGraph {
12674            project,
12675            mutation: RepositoryGraphMutation::Full,
12676            entities,
12677            relations,
12678            occurrences: Vec::new(),
12679            coverage: Vec::new(),
12680            entity_exports: exports.into(),
12681            relation_dependencies: dependencies,
12682            document_unresolved_reasons: Vec::new(),
12683            identity_rejections: Vec::new(),
12684            resolution_derivations: BTreeMap::new(),
12685            peak_retained_bytes: 0,
12686            projection_removals_before_entities: Vec::new(),
12687            scan_policy,
12688            document_target_states: Vec::new(),
12689            database: None,
12690            retained_bytes: 0,
12691        };
12692        {
12693            let mut publication = store.begin_index_publication("closed-resolution-states")?;
12694            publication.begin_scan_replacement()?;
12695            publication.upsert_scan_node_batch(&[
12696                test_file_node("src/main.rs", "rust"),
12697                test_file_node("src/unique.rs", "rust"),
12698                test_file_node("src/first.rs", "rust"),
12699                test_file_node("src/second.rs", "rust"),
12700                test_file_node("vendor/local/Cargo.toml", "cargo-manifest"),
12701            ])?;
12702            publication.finish_scan_replacement()?;
12703            staged.apply(&mut publication, &control)?;
12704            publication.complete()?;
12705        }
12706        drop(store);
12707
12708        let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
12709        let page = reader.repository_graph_relations(
12710            RepositoryGraphRelationQuery::Outbound {
12711                source: source.key().clone(),
12712            },
12713            32,
12714        )?;
12715        require(!page.truncated, "closed resolution proof was truncated")?;
12716        require_eq(&page.rows.len(), &cases.len(), "reopened relation count")?;
12717        let mut resolved = BTreeSet::new();
12718        let mut ambiguous = BTreeMap::new();
12719        let mut unresolved = BTreeSet::new();
12720        let mut external = BTreeSet::new();
12721        for relation in &page.rows {
12722            require_eq(
12723                &relation.generation(),
12724                &generation,
12725                "reopened relation generation",
12726            )?;
12727            match relation.resolution() {
12728                RelationResolution::Resolved {
12729                    selector,
12730                    generation: target_generation,
12731                    ..
12732                } => {
12733                    require_eq(target_generation, &generation, "resolved target generation")?;
12734                    match selector {
12735                        ReusableTargetSelector::Symbol { symbol } => {
12736                            resolved.insert(symbol.name.as_str().to_string());
12737                        }
12738                        ReusableTargetSelector::Package { package } => {
12739                            resolved.insert(package.name.as_str().to_string());
12740                        }
12741                        ReusableTargetSelector::Folder { .. }
12742                        | ReusableTargetSelector::File { .. } => {}
12743                    }
12744                }
12745                RelationResolution::Ambiguous {
12746                    reference,
12747                    candidates,
12748                } => {
12749                    ambiguous.insert(reference.as_str().to_string(), candidates.get());
12750                }
12751                RelationResolution::Unresolved { reference } => {
12752                    unresolved.insert(reference.as_str().to_string());
12753                }
12754                RelationResolution::External {
12755                    external: selector,
12756                    generation: target_generation,
12757                    ..
12758                } => {
12759                    require_eq(target_generation, &generation, "external target generation")?;
12760                    external.insert((
12761                        selector.system.as_str().to_string(),
12762                        selector.identity.as_str().to_string(),
12763                    ));
12764                }
12765            }
12766        }
12767        require_eq(
12768            &resolved,
12769            &BTreeSet::from(["local-package".to_string(), "unique".to_string()]),
12770            "resolved targets",
12771        )?;
12772        require_eq(
12773            &ambiguous,
12774            &BTreeMap::from([("shared".to_string(), 2)]),
12775            "ambiguous targets",
12776        )?;
12777        require_eq(
12778            &unresolved,
12779            &BTreeSet::from([
12780                "import requests".to_string(),
12781                "import value from \"left-pad\";".to_string(),
12782                "missing".to_string(),
12783                "serde-lock".to_string(),
12784            ]),
12785            "unresolved targets",
12786        )?;
12787        require_eq(
12788            &external,
12789            &BTreeSet::from([
12790                ("cargo".to_string(), "serde".to_string()),
12791                ("node".to_string(), "fs".to_string()),
12792                ("node".to_string(), "path".to_string()),
12793                ("node".to_string(), "url".to_string()),
12794                ("rust-toolchain".to_string(), "std".to_string()),
12795            ]),
12796            "external targets",
12797        )?;
12798        for key in &external_keys {
12799            let entity = reader
12800                .repository_graph_entity(key)?
12801                .ok_or("reopened external entity is missing")?;
12802            require(
12803                matches!(entity.selector(), EntitySelector::External { .. }),
12804                "external relation target reopened as a local entity",
12805            )?;
12806            require_eq(
12807                &entity.generation(),
12808                &generation,
12809                "external entity generation",
12810            )?;
12811        }
12812        let affected = reader.repository_affected_source_paths(
12813            project,
12814            &[unique_key, shared_key, local_package_key],
12815            32,
12816        )?;
12817        require(!affected.truncated, "dependency source proof was truncated")?;
12818        require_eq(
12819            &affected.rows,
12820            &vec![RepositoryFilePath::new(Path::new("src/main.rs"))?],
12821            "reopened dependency source paths",
12822        )?;
12823        reader.finish_index_read_snapshot()?;
12824        Ok(())
12825    }
12826
12827    struct ResolutionCase {
12828        graph: SymbolGraph,
12829        relation: SymbolRelation,
12830        keys: Vec<CanonicalResolutionKey>,
12831    }
12832
12833    fn resolution_case(
12834        language: &str,
12835        kind: RelationKind,
12836        target: &str,
12837        keys: &[&CanonicalResolutionKey],
12838    ) -> ResolutionCase {
12839        let parser = if language.starts_with("cargo-") {
12840            ParserKind::Manifest
12841        } else {
12842            ParserKind::TreeSitter
12843        };
12844        ResolutionCase {
12845            graph: SymbolGraph {
12846                path: "src/main.rs".to_string(),
12847                language: Some(language.to_string()),
12848                parser,
12849                symbols: Vec::new(),
12850                relations: Vec::new(),
12851            },
12852            relation: SymbolRelation {
12853                path: "src/main.rs".to_string(),
12854                source_name: "src/main.rs".to_string(),
12855                target_name: target.to_string(),
12856                kind,
12857                line: 1,
12858                context: target.to_string(),
12859                parser,
12860            },
12861            keys: keys.iter().map(|key| (*key).clone()).collect(),
12862        }
12863    }
12864
12865    fn test_resolution_key(
12866        project: ProjectInstanceId,
12867        identity: &str,
12868    ) -> Result<CanonicalResolutionKey, Box<dyn Error>> {
12869        let provider = GraphIdentityText::new("test-provider")?;
12870        let language = GraphIdentityText::new("test-language")?;
12871        let identity = GraphIdentityText::new(identity)?;
12872        Ok(CanonicalResolutionKey::new(
12873            project,
12874            ResolutionKeyDomain::Declaration,
12875            &provider,
12876            &language,
12877            None,
12878            None,
12879            None,
12880            &identity,
12881        ))
12882    }
12883
12884    fn test_file_entity(
12885        project: ProjectInstanceId,
12886        generation: IndexGeneration,
12887        path: &str,
12888    ) -> Result<GraphEntity, Box<dyn Error>> {
12889        Ok(GraphEntity::new(
12890            project,
12891            EntitySelector::File {
12892                path: RepositoryFilePath::new(Path::new(path))?,
12893            },
12894            generation,
12895        )?)
12896    }
12897
12898    fn test_symbol_entity(
12899        project: ProjectInstanceId,
12900        generation: IndexGeneration,
12901        path: &str,
12902        name: &str,
12903    ) -> Result<GraphEntity, Box<dyn Error>> {
12904        Ok(GraphEntity::new(
12905            project,
12906            EntitySelector::Symbol {
12907                symbol: SymbolSelector {
12908                    file: RepositoryFilePath::new(Path::new(path))?,
12909                    name: GraphIdentityText::new(name)?,
12910                    kind: SymbolKind::Function,
12911                    parent: None,
12912                    signature: GraphIdentityText::new(format!("fn {name}()"))?,
12913                },
12914            },
12915            generation,
12916        )?)
12917    }
12918
12919    fn test_code_symbol(
12920        path: &str,
12921        name: &str,
12922        parent: Option<&str>,
12923        signature: &str,
12924    ) -> CodeSymbol {
12925        CodeSymbol {
12926            path: path.to_string(),
12927            language: Some("rust".to_string()),
12928            name: name.to_string(),
12929            kind: SymbolKind::Function,
12930            signature: signature.to_string(),
12931            exported: false,
12932            documentation: None,
12933            line_start: 1,
12934            line_end: 1,
12935            source_selector: None,
12936            parent: parent.map(ToString::to_string),
12937            parser: ParserKind::TreeSitter,
12938            detail: Some("function_item".to_string()),
12939        }
12940    }
12941
12942    #[test]
12943    fn source_graph_admission_keeps_valid_rows_and_typed_rejection_coverage()
12944    -> Result<(), Box<dyn Error>> {
12945        let valid = test_code_symbol("src/lib.rs", "valid", None, "fn valid()");
12946        let invalid_name = test_code_symbol("src/lib.rs", "bad\u{0}name", None, "fn bad()");
12947        let invalid_signature = test_code_symbol("src/lib.rs", "padded", None, " fn padded() ");
12948        let invalid_parent = test_code_symbol("src/lib.rs", "child", Some(" parent "), "child()");
12949        let invalid_reserved = test_code_symbol(
12950            "src/lib.rs",
12951            &format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}derived"),
12952            None,
12953            "fn derived()",
12954        );
12955        let invalid_oversized = test_code_symbol(
12956            "src/lib.rs",
12957            &"x".repeat(MAX_GRAPH_IDENTITY_BYTES + 1),
12958            None,
12959            "fn oversized()",
12960        );
12961        let graph = SymbolGraph {
12962            path: "src/lib.rs".to_string(),
12963            language: Some("rust".to_string()),
12964            parser: ParserKind::TreeSitter,
12965            symbols: vec![
12966                valid,
12967                invalid_name,
12968                invalid_signature,
12969                invalid_parent,
12970                invalid_reserved,
12971                invalid_oversized,
12972            ],
12973            relations: vec![
12974                SymbolRelation {
12975                    path: "src/lib.rs".to_string(),
12976                    source_name: "valid".to_string(),
12977                    target_name: "helper".to_string(),
12978                    kind: RelationKind::Calls,
12979                    line: 1,
12980                    context: "helper()".to_string(),
12981                    parser: ParserKind::TreeSitter,
12982                },
12983                SymbolRelation {
12984                    path: "src/lib.rs".to_string(),
12985                    source_name: "valid".to_string(),
12986                    target_name: "bad\u{0}target".to_string(),
12987                    kind: RelationKind::Calls,
12988                    line: 2,
12989                    context: "bad()".to_string(),
12990                    parser: ParserKind::TreeSitter,
12991                },
12992                SymbolRelation {
12993                    path: "src/lib.rs".to_string(),
12994                    source_name: " valid ".to_string(),
12995                    target_name: "helper".to_string(),
12996                    kind: RelationKind::Calls,
12997                    line: 3,
12998                    context: "helper()".to_string(),
12999                    parser: ParserKind::TreeSitter,
13000                },
13001            ],
13002        };
13003        let control = IndexWorkControl::new(IndexCancellation::new(), None);
13004        let (admitted, report) = super::admit_symbol_graph(Cow::Owned(graph), &control)?;
13005        require_eq(&admitted.symbols.len(), &1, "valid symbol admission")?;
13006        require_eq(&admitted.relations.len(), &1, "valid relation admission")?;
13007        require_eq(
13008            &report.rejected_facts_for("src/lib.rs"),
13009            &7,
13010            "rejected fact count",
13011        )?;
13012        require_eq(
13013            &admitted.symbols[0].signature,
13014            &"fn valid()".to_string(),
13015            "valid signature preservation",
13016        )?;
13017
13018        let coverage = super::coverage_for_graph(
13019            &admitted,
13020            IndexGeneration::new(1),
13021            &report,
13022            &super::GraphIdentityAdmission::default(),
13023        )?;
13024        require_eq(
13025            &coverage.state(),
13026            &CoverageState::Partial,
13027            "admission coverage state",
13028        )?;
13029        require_eq(&coverage.covered(), &2, "admission covered count")?;
13030        require_eq(&coverage.omitted(), &7, "admission omitted count")?;
13031        let reason = coverage
13032            .reason()
13033            .ok_or_else(|| io::Error::other("admission coverage omitted its reason"))?
13034            .as_str();
13035        require_eq(
13036            &reason,
13037            &PARTIAL_COVERAGE_REASON,
13038            "admission coverage keeps the stable coarse reason",
13039        )?;
13040        require_eq(&report.rejections.len(), &7, "typed rejection detail count")?;
13041        require(
13042            report.rejections.iter().all(|rejection| {
13043                rejection.path.as_str() == "src/lib.rs"
13044                    && rejection.parser == ParserKind::TreeSitter
13045                    && rejection.span.start_line() >= 1
13046                    && rejection.span.end_line() >= rejection.span.start_line()
13047            }),
13048            "typed rejection details lost path/parser/span ownership",
13049        )?;
13050        require(
13051            report
13052                .rejections
13053                .iter()
13054                .any(|rejection| rejection.field == GraphIdentityField::RelationTarget),
13055            "typed rejection details lost relation-target ownership",
13056        )?;
13057
13058        let temp = tempfile::tempdir()?;
13059        fs::create_dir_all(temp.path().join("src"))?;
13060        let nodes = vec![test_file_node("src/lib.rs", "rust")];
13061        let project = ProjectInstanceId::from_bytes([71; 16])?;
13062        let generation = IndexGeneration::new(1);
13063        let packages = PackageIndex::from_graphs(std::slice::from_ref(&admitted))?;
13064        let projection = build_entity_projection(
13065            project,
13066            generation,
13067            &nodes,
13068            std::slice::from_ref(&admitted),
13069            &packages,
13070            true,
13071            &control,
13072        )?;
13073        let candidates = resolution_registry_from_exports(&projection, &control)?;
13074        let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
13075        let staged = super::finish_projection_with_documents(
13076            project,
13077            generation,
13078            RepositoryGraphMutation::Full,
13079            std::slice::from_ref(&admitted),
13080            temp.path(),
13081            &nodes,
13082            &BTreeMap::new(),
13083            &report,
13084            projection,
13085            &candidates,
13086            &scan_policy,
13087            &control,
13088        )?;
13089        require_eq(&staged.relations.len(), &1, "valid relation publication")?;
13090        require(
13091            staged.entities.iter().any(|entity| {
13092                matches!(
13093                    entity.selector(),
13094                    EntitySelector::Symbol { symbol } if symbol.name.as_str() == "valid"
13095                )
13096            }),
13097            "valid symbol publication was lost with invalid siblings",
13098        )?;
13099        require_eq(
13100            &staged.identity_rejections.len(),
13101            &7,
13102            "typed rejection coverage was not staged with valid rows",
13103        )?;
13104        Ok(())
13105    }
13106
13107    #[test]
13108    fn invalid_sibling_does_not_fail_symbol_only_coverage() -> Result<(), Box<dyn Error>> {
13109        let graph = SymbolGraph {
13110            path: "src/symbol-only.rs".to_string(),
13111            language: Some("rust".to_string()),
13112            parser: ParserKind::TreeSitter,
13113            symbols: vec![
13114                test_code_symbol("src/symbol-only.rs", "valid", None, "fn valid()"),
13115                test_code_symbol("src/symbol-only.rs", "bad\u{0}name", None, "fn bad()"),
13116            ],
13117            relations: Vec::new(),
13118        };
13119        let control = IndexWorkControl::new(IndexCancellation::new(), None);
13120        let (admitted, report) = super::admit_symbol_graph(Cow::Owned(graph), &control)?;
13121        let coverage = super::coverage_for_graph(
13122            &admitted,
13123            IndexGeneration::new(1),
13124            &report,
13125            &super::GraphIdentityAdmission::default(),
13126        )?;
13127        require_eq(
13128            &coverage.state(),
13129            &CoverageState::Partial,
13130            "valid symbols keep mixed identity coverage partial",
13131        )?;
13132        require_eq(
13133            &coverage.covered(),
13134            &1,
13135            "valid symbol is counted as covered",
13136        )?;
13137        require_eq(
13138            &coverage.omitted(),
13139            &1,
13140            "invalid sibling is counted as omitted",
13141        )?;
13142        require_eq(
13143            &coverage.reason().map(GraphIdentityText::as_str),
13144            &Some(PARTIAL_COVERAGE_REASON),
13145            "symbol-only coverage keeps the coarse reason",
13146        )?;
13147        Ok(())
13148    }
13149
13150    #[test]
13151    fn document_target_normalization_is_relative_bounded_and_platform_neutral()
13152    -> Result<(), Box<dyn Error>> {
13153        require_eq(
13154            &normalize_document_target("docs/guide.md", "../src/lib.rs:L12-L20?view=raw#entry")
13155                .map_err(|reason| io::Error::other(reason.to_string()))?,
13156            &DocumentTargetIdentity {
13157                path: "src/lib.rs".to_string(),
13158                fragment: Some("entry".to_string()),
13159            },
13160            "relative document target",
13161        )?;
13162        require_eq(
13163            &normalize_document_target("guide.md", "README")
13164                .map_err(|reason| io::Error::other(reason.to_string()))?,
13165            &DocumentTargetIdentity {
13166                path: "README".to_string(),
13167                fragment: None,
13168            },
13169            "root extensionless target",
13170        )?;
13171        require_eq(
13172            &normalize_document_target("docs/guide.md", "../../../private.txt"),
13173            &Err(DocumentTargetUnresolvedReason::OutsideRoot),
13174            "outside-root selector",
13175        )?;
13176        require_eq(
13177            &normalize_document_target("docs/guide.md", "target.md#runtime value"),
13178            &Err(DocumentTargetUnresolvedReason::NoStaticTarget),
13179            "non-static fragment refusal",
13180        )?;
13181        Ok(())
13182    }
13183
13184    #[test]
13185    fn document_rows_resolve_exact_files_and_headings_with_typed_absence()
13186    -> Result<(), Box<dyn Error>> {
13187        let temp = tempfile::tempdir()?;
13188        let root = temp.path();
13189        fs::create_dir_all(root.join("docs"))?;
13190        fs::write(root.join(".gitignore"), "docs/ignored.md\n")?;
13191        fs::write(root.join("docs/ignored.md"), "ignored")?;
13192        let source = "[lib](../src/lib.rs)\n[heading](target.md#api)\n[missing heading](target.md#absent)\n[missing](missing.md)\n[ignored](ignored.md)\n[case](../SRC/lib.rs)\n[folder](../src)\n[outside](../../../private.txt)\n[self](guide.md)\n[self heading](guide.md#api)\n";
13193        let source_facts = projectatlas_symbols::extract_markdown_facts(source);
13194        let target_facts = projectatlas_symbols::extract_markdown_facts("# API\n");
13195        let source_graph = source_facts.symbol_graph("docs/guide.md", Some("markdown"));
13196        let target_graph = target_facts.symbol_graph("docs/target.md", Some("markdown"));
13197        let source_code_graph = SymbolGraph {
13198            path: "src/lib.rs".to_string(),
13199            language: Some("rust".to_string()),
13200            parser: ParserKind::TreeSitter,
13201            symbols: Vec::new(),
13202            relations: Vec::new(),
13203        };
13204        let graphs = vec![source_graph, target_graph, source_code_graph];
13205        let mut nodes = vec![
13206            test_file_node("docs/guide.md", "markdown"),
13207            test_file_node("docs/target.md", "markdown"),
13208            test_file_node("src/lib.rs", "rust"),
13209        ];
13210        let mut folder = test_file_node("src", "unknown");
13211        folder.kind = NodeKind::Folder;
13212        folder.language = None;
13213        folder.extension = None;
13214        nodes.push(folder);
13215        let project = ProjectInstanceId::from_bytes([31; 16])?;
13216        let generation = IndexGeneration::new(4);
13217        let packages = PackageIndex::from_graphs(&graphs)?;
13218        let control = super::super::standalone_index_work_control();
13219        let mut projection = build_entity_projection(
13220            project, generation, &nodes, &graphs, &packages, true, &control,
13221        )?;
13222        let candidates = resolution_registry_from_exports(&projection, &control)?;
13223        let owners = projection
13224            .owners_by_graph
13225            .remove("docs/guide.md")
13226            .ok_or_else(|| io::Error::other("document owners were not projected"))?;
13227        let scan_policy = RootScanPolicy::discover(root, &ScanOptions::default(), &control)?;
13228        let index = DocumentResolutionIndex::new(root, &nodes, &scan_policy)?;
13229        let rows = project_document_rows(
13230            project,
13231            generation,
13232            &graphs[0],
13233            &source_facts,
13234            &owners,
13235            &index,
13236            &candidates,
13237            &projection.entity_by_digest,
13238            &control,
13239        )?;
13240        require_eq(&rows.relations.len(), &9, "document relation count")?;
13241        require_eq(&rows.occurrences.len(), &9, "document occurrence count")?;
13242        require_eq(
13243            &rows
13244                .relations
13245                .iter()
13246                .filter(|relation| {
13247                    matches!(relation.resolution(), RelationResolution::Resolved { .. })
13248                })
13249                .count(),
13250            &2,
13251            "resolved document targets",
13252        )?;
13253        let reasons = rows
13254            .document_unresolved_reasons
13255            .iter()
13256            .map(|(_key, reason)| *reason)
13257            .collect::<BTreeSet<_>>();
13258        require_eq(
13259            &reasons,
13260            &BTreeSet::from([
13261                DocumentTargetUnresolvedReason::Missing,
13262                DocumentTargetUnresolvedReason::Ignored,
13263                DocumentTargetUnresolvedReason::OutsideRoot,
13264                DocumentTargetUnresolvedReason::CaseConflict,
13265                DocumentTargetUnresolvedReason::Unsupported,
13266            ]),
13267            "typed unresolved reasons",
13268        )?;
13269        require(
13270            rows.relations.iter().any(|relation| {
13271                matches!(
13272                    relation.resolution(),
13273                    RelationResolution::Resolved {
13274                        selector: projectatlas_core::graph::ReusableTargetSelector::Symbol {
13275                            symbol
13276                        },
13277                        ..
13278                    } if symbol.kind == SymbolKind::Heading && symbol.signature.as_str() == "api"
13279                )
13280            }),
13281            "heading fragment did not resolve to its heading entity",
13282        )?;
13283        Ok(())
13284    }
13285
13286    #[test]
13287    fn markdown_identity_admission_keeps_valid_document_siblings() -> Result<(), Box<dyn Error>> {
13288        let temp = tempfile::tempdir()?;
13289        let root = fs::canonicalize(temp.path())?;
13290        fs::create_dir_all(root.join("docs"))?;
13291        let guide_source = "# Guide\n\n[invalid](<target.md\u{1}>)\n[valid](target.md#target)\n";
13292        fs::write(root.join("docs/guide.md"), guide_source)?;
13293        fs::write(root.join("docs/target.md"), "# Target\n")?;
13294        let source_facts = projectatlas_symbols::extract_markdown_facts(guide_source);
13295        require_eq(
13296            &source_facts.link_candidates.len(),
13297            &2,
13298            "parser Markdown sibling candidate count",
13299        )?;
13300        require_eq(
13301            &source_facts.link_candidates[0].selector,
13302            &"target.md\u{1}".to_string(),
13303            "parser Markdown control selector",
13304        )?;
13305        let source_graph = source_facts.symbol_graph("docs/guide.md", Some("markdown"));
13306        let target_facts = projectatlas_symbols::extract_markdown_facts("# Target\n");
13307        let target_graph = target_facts.symbol_graph("docs/target.md", Some("markdown"));
13308        let nodes = vec![
13309            test_file_node("docs/guide.md", "markdown"),
13310            test_file_node("docs/target.md", "markdown"),
13311        ];
13312        let control = super::super::standalone_index_work_control();
13313        let mut symbols = empty_symbol_build_stage();
13314        symbols.report.candidates = 2;
13315        symbols.report.parsed = 2;
13316        symbols.report.summaries = 2;
13317        symbols.changes = vec![
13318            SymbolProjectionChange::Parsed(SymbolParseSuccess {
13319                path: "docs/guide.md".to_string(),
13320                graph: source_graph.clone(),
13321                markdown_facts: Some(Box::new(source_facts)),
13322                source_parser: ParserKind::Structural,
13323                summary: "Guide".to_string(),
13324                summary_is_structural: true,
13325                purpose_suggestion: None,
13326            }),
13327            SymbolProjectionChange::Parsed(SymbolParseSuccess {
13328                path: "docs/target.md".to_string(),
13329                graph: target_graph.clone(),
13330                markdown_facts: Some(Box::new(target_facts)),
13331                source_parser: ParserKind::Structural,
13332                summary: "Target".to_string(),
13333                summary_is_structural: true,
13334                purpose_suggestion: None,
13335            }),
13336        ];
13337        symbols.identity_admission = super::admit_symbol_build_stage(&mut symbols, &control)?;
13338        let admitted_counts = symbols
13339            .changes
13340            .iter()
13341            .filter_map(|change| match change {
13342                SymbolProjectionChange::Parsed(parsed) => {
13343                    Some((parsed.graph.symbols.len(), parsed.graph.relations.len()))
13344                }
13345                SymbolProjectionChange::Clear { .. } => None,
13346            })
13347            .fold(
13348                (0, 0),
13349                |(symbols, relations), (next_symbols, next_relations)| {
13350                    (symbols + next_symbols, relations + next_relations)
13351                },
13352            );
13353        require_eq(
13354            &(symbols.report.symbols, symbols.report.relations),
13355            &admitted_counts,
13356            "post-admission symbol report counts",
13357        )?;
13358        require_eq(
13359            &symbols.identity_admission.rejections.len(),
13360            &1,
13361            "Markdown rejection detail count",
13362        )?;
13363        let rejection = symbols
13364            .identity_admission
13365            .rejections
13366            .first()
13367            .ok_or_else(|| io::Error::other("Markdown rejection detail is missing"))?;
13368        require_eq(
13369            &rejection.path.as_str(),
13370            &"docs/guide.md",
13371            "Markdown rejection path",
13372        )?;
13373        require_eq(
13374            &rejection.parser,
13375            &ParserKind::Structural,
13376            "Markdown rejection parser",
13377        )?;
13378        require_eq(
13379            &rejection.field,
13380            &GraphIdentityField::RelationTarget,
13381            "Markdown rejection field",
13382        )?;
13383        require_eq(
13384            &rejection.reason,
13385            &GraphIdentityRejectionReason::ControlCharacters,
13386            "Markdown rejection reason",
13387        )?;
13388        require_eq(
13389            &rejection.span.start_line(),
13390            &3,
13391            "Markdown rejection start line",
13392        )?;
13393        require_eq(
13394            &rejection.span.start_column(),
13395            &0,
13396            "Markdown rejection start column",
13397        )?;
13398        require_eq(
13399            &rejection.span.end_line(),
13400            &3,
13401            "Markdown rejection end line",
13402        )?;
13403        require_eq(
13404            &rejection.span.end_column(),
13405            &23,
13406            "Markdown rejection end column",
13407        )?;
13408        let database = root.join("projectatlas.db");
13409        let mut store = AtlasStore::open_for_project(&database, &root)?;
13410        store.replace_scan(&nodes)?;
13411        store.replace_symbol_graph(&source_graph)?;
13412        store.replace_symbol_graph(&target_graph)?;
13413        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
13414        let staged = stage_full_repository_graph(
13415            &store,
13416            &root,
13417            IndexGeneration::ZERO,
13418            &nodes,
13419            &scan_policy,
13420            &symbols,
13421            &control,
13422        )?;
13423        require_eq(
13424            &staged.identity_rejections.len(),
13425            &1,
13426            "staged Markdown rejection detail count",
13427        )?;
13428        require_eq(
13429            &staged.relations.len(),
13430            &1,
13431            "staged valid Markdown relation count",
13432        )?;
13433        require(
13434            staged.coverage.iter().any(|coverage| {
13435                matches!(
13436                    coverage.scope(),
13437                    CoverageScope::Path { path } if path.as_str() == "docs/guide.md"
13438                ) && coverage.state() == CoverageState::Complete
13439                    && coverage.covered() == 1
13440                    && coverage.omitted() == 0
13441            }),
13442            "invalid Markdown selector changed valid-sibling coverage semantics",
13443        )?;
13444        require(
13445            staged.relations.iter().any(|relation| {
13446                matches!(
13447                    relation.resolution(),
13448                    RelationResolution::Resolved {
13449                        selector: projectatlas_core::graph::ReusableTargetSelector::Symbol {
13450                            symbol
13451                        },
13452                        ..
13453                    } if symbol.file.as_str() == "docs/target.md"
13454                        && symbol.signature.as_str() == "target"
13455                )
13456            }),
13457            "staged valid Markdown sibling was not resolved",
13458        )?;
13459        publish_full_staged_graph(&mut store, &nodes, &staged, &control, "markdown-admission")?;
13460        drop(store);
13461        let mut store = AtlasStore::open_for_project(&database, &root)?;
13462        let project = store
13463            .project_instance_id()?
13464            .ok_or("Markdown admission project identity is missing")?;
13465        let paths = nodes
13466            .iter()
13467            .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
13468            .collect::<Result<Vec<_>, _>>()?;
13469        let persisted_rejections =
13470            store.repository_graph_identity_rejections(project, &paths, 16, None)?;
13471        require_eq(
13472            &persisted_rejections.len(),
13473            &1,
13474            "persisted Markdown rejection detail count",
13475        )?;
13476        let persisted_rejection = persisted_rejections
13477            .first()
13478            .ok_or("persisted Markdown rejection detail is missing")?;
13479        require_eq(
13480            &persisted_rejection.span.start_line(),
13481            &3,
13482            "persisted Markdown rejection start line",
13483        )?;
13484        require_eq(
13485            &persisted_rejection.span.start_column(),
13486            &0,
13487            "persisted Markdown rejection start column",
13488        )?;
13489        require_eq(
13490            &persisted_rejection.span.end_line(),
13491            &3,
13492            "persisted Markdown rejection end line",
13493        )?;
13494        require_eq(
13495            &persisted_rejection.span.end_column(),
13496            &23,
13497            "persisted Markdown rejection end column",
13498        )?;
13499        let persisted_wire = serde_json::to_string(&persisted_rejections)?;
13500        require(
13501            !persisted_wire.contains("\\u0001") && !persisted_wire.contains("target.md"),
13502            "persisted Markdown rejection retained the invalid selector",
13503        )?;
13504        let persisted_relations = store.repository_graph_relation_rows(
13505            RepositoryGraphRelationQuery::Family {
13506                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
13507            },
13508            GraphLimits::MAX_ROWS,
13509            None,
13510        )?;
13511        require_eq(
13512            &persisted_relations.rows.len(),
13513            &1,
13514            "reopened valid Markdown relation count",
13515        )?;
13516        require(
13517            persisted_relations.rows.iter().any(|relation| {
13518                matches!(
13519                    relation.relation.resolution(),
13520                    RelationResolution::Resolved {
13521                        selector: projectatlas_core::graph::ReusableTargetSelector::Symbol {
13522                            symbol
13523                        },
13524                        ..
13525                    } if symbol.file.as_str() == "docs/target.md"
13526                        && symbol.signature.as_str() == "target"
13527                )
13528            }),
13529            "reopened valid Markdown sibling was not resolved",
13530        )?;
13531
13532        let base_generation = store
13533            .index_publication()?
13534            .ok_or("Markdown admission publication is missing")?
13535            .generation;
13536        let mut incremental_symbols = symbol_build_stage_for_markdown(
13537            source_graph.clone(),
13538            projectatlas_symbols::extract_markdown_facts(guide_source),
13539        );
13540        incremental_symbols.identity_admission =
13541            super::admit_symbol_build_stage(&mut incremental_symbols, &control)?;
13542        let incremental_stage = stage_incremental_repository_graph(
13543            &store,
13544            &root,
13545            base_generation,
13546            &nodes,
13547            &["docs/guide.md".to_string()],
13548            &scan_policy,
13549            &incremental_symbols,
13550            &control,
13551        )?;
13552        require_eq(
13553            &incremental_stage.identity_rejections.len(),
13554            &1,
13555            "incremental Markdown rejection detail count",
13556        )?;
13557        require_eq(
13558            &incremental_stage.relations.len(),
13559            &1,
13560            "incremental valid Markdown relation count",
13561        )?;
13562        let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
13563        canceled.cancel();
13564        {
13565            let mut publication = store.begin_index_publication("markdown-admission-cancel")?;
13566            let error = incremental_stage.apply(&mut publication, &canceled).err();
13567            require(
13568                matches!(
13569                    error,
13570                    Some(CliError::IndexWork(IndexWorkFailure::Cancelled {
13571                        stage: IndexWorkStage::Publication
13572                    }))
13573                ),
13574                "incremental Markdown cancellation was not observed",
13575            )?;
13576        }
13577        require_eq(
13578            &store
13579                .index_publication()?
13580                .map(|publication| publication.generation),
13581            &Some(base_generation),
13582            "publication after canceled Markdown refresh",
13583        )?;
13584        {
13585            let mut publication =
13586                store.begin_index_publication("markdown-admission-incremental")?;
13587            incremental_stage.apply(&mut publication, &control)?;
13588            publication.complete()?;
13589        }
13590        let incremental_publication = store
13591            .index_publication()?
13592            .ok_or("incremental Markdown publication is missing")?;
13593        require_eq(
13594            &incremental_publication.generation,
13595            &base_generation
13596                .checked_next()
13597                .ok_or("Markdown generation overflowed")?,
13598            "incremental Markdown generation",
13599        )?;
13600        drop(store);
13601        let mut store = AtlasStore::open_for_project(&database, &root)?;
13602        let reopened_incremental =
13603            store.repository_graph_identity_rejections(project, &paths, 16, None)?;
13604        require_eq(
13605            &reopened_incremental,
13606            &persisted_rejections,
13607            "reopened incremental Markdown rejection details",
13608        )?;
13609
13610        let fault_generation = store
13611            .index_publication()?
13612            .ok_or("incremental Markdown publication disappeared")?
13613            .generation;
13614        let mut fault_symbols = symbol_build_stage_for_markdown(
13615            source_graph.clone(),
13616            projectatlas_symbols::extract_markdown_facts(guide_source),
13617        );
13618        fault_symbols.identity_admission =
13619            super::admit_symbol_build_stage(&mut fault_symbols, &control)?;
13620        let mut fault_stage = stage_incremental_repository_graph(
13621            &store,
13622            &root,
13623            fault_generation,
13624            &nodes,
13625            &["docs/guide.md".to_string()],
13626            &scan_policy,
13627            &fault_symbols,
13628            &control,
13629        )?;
13630        fault_stage.identity_rejections.resize(
13631            usize::try_from(GraphLimits::MAX_ROWS)
13632                .unwrap_or(usize::MAX)
13633                .saturating_add(1),
13634            fault_stage.identity_rejections[0].clone(),
13635        );
13636        {
13637            let mut publication = store.begin_index_publication("markdown-admission-fault")?;
13638            require(
13639                fault_stage.apply(&mut publication, &control).is_err(),
13640                "late Markdown rejection-detail fault did not fail",
13641            )?;
13642        }
13643        require_eq(
13644            &store
13645                .index_publication()?
13646                .map(|publication| publication.generation),
13647            &Some(fault_generation),
13648            "generation after late Markdown rejection-detail fault",
13649        )?;
13650        require_eq(
13651            &store.repository_graph_identity_rejections(project, &paths, 16, None)?,
13652            &reopened_incremental,
13653            "previous Markdown generation after late fault",
13654        )?;
13655        let retry_symbols = symbol_build_stage_for_markdown(
13656            source_graph,
13657            projectatlas_symbols::extract_markdown_facts(guide_source),
13658        );
13659        let retry_stage = stage_incremental_repository_graph(
13660            &store,
13661            &root,
13662            fault_generation,
13663            &nodes,
13664            &["docs/guide.md".to_string()],
13665            &scan_policy,
13666            &retry_symbols,
13667            &control,
13668        )?;
13669        {
13670            let mut publication = store.begin_index_publication("markdown-admission-retry")?;
13671            retry_stage.apply(&mut publication, &control)?;
13672            publication.complete()?;
13673        }
13674        require_eq(
13675            &store.repository_graph_identity_rejections(project, &paths, 16, None)?,
13676            &reopened_incremental,
13677            "deterministic Markdown retry",
13678        )?;
13679        Ok(())
13680    }
13681
13682    #[test]
13683    fn document_rows_use_file_identity_and_deduplicate_across_headings()
13684    -> Result<(), Box<dyn Error>> {
13685        let temp = tempfile::tempdir()?;
13686        fs::create_dir_all(temp.path().join("src"))?;
13687        fs::write(temp.path().join("src/lib.rs"), "pub fn entry() {}\n")?;
13688        let source = "# First\n[target](../src/lib.rs)\n[second](guide.md#second)\n[self](guide.md#first)\n\n# Second\n[target](../src/lib.rs)\n[target](../src/lib.rs)\n[first](guide.md#first)\n[self](guide.md#second)\n";
13689        let facts = projectatlas_symbols::extract_markdown_facts(source);
13690        let enclosing_heading_bytes = facts
13691            .link_candidates
13692            .iter()
13693            .map(|candidate| candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64)
13694            .sum::<u64>();
13695        let mut facts_without_heading_owners = facts.clone();
13696        for candidate in &mut facts_without_heading_owners.link_candidates {
13697            candidate.enclosing_heading = None;
13698        }
13699        let retained_with_heading_owners = document_fact_map_retained_bytes(&BTreeMap::from([(
13700            "docs/guide.md".to_string(),
13701            Cow::Owned(facts.clone()),
13702        )]));
13703        let retained_without_heading_owners =
13704            document_fact_map_retained_bytes(&BTreeMap::from([(
13705                "docs/guide.md".to_string(),
13706                Cow::Owned(facts_without_heading_owners),
13707            )]));
13708        require_eq(
13709            &retained_with_heading_owners.saturating_sub(retained_without_heading_owners),
13710            &enclosing_heading_bytes,
13711            "document registry enclosing-heading bytes",
13712        )?;
13713        let source_graph = facts.symbol_graph("docs/guide.md", Some("markdown"));
13714        let target_graph = SymbolGraph {
13715            path: "src/lib.rs".to_string(),
13716            language: Some("rust".to_string()),
13717            parser: ParserKind::TreeSitter,
13718            symbols: Vec::new(),
13719            relations: Vec::new(),
13720        };
13721        let graphs = vec![source_graph, target_graph];
13722        let nodes = vec![
13723            test_file_node("docs/guide.md", "markdown"),
13724            test_file_node("src/lib.rs", "rust"),
13725        ];
13726        let project = ProjectInstanceId::from_bytes([33; 16])?;
13727        let generation = IndexGeneration::new(5);
13728        let packages = PackageIndex::from_graphs(&graphs)?;
13729        let control = super::super::standalone_index_work_control();
13730        let mut projection = build_entity_projection(
13731            project, generation, &nodes, &graphs, &packages, true, &control,
13732        )?;
13733        let candidates = resolution_registry_from_exports(&projection, &control)?;
13734        let owners = projection
13735            .owners_by_graph
13736            .remove("docs/guide.md")
13737            .ok_or_else(|| io::Error::other("document owners were not projected"))?;
13738        let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
13739        let index = DocumentResolutionIndex::new(temp.path(), &nodes, &scan_policy)?;
13740        let rows = project_document_rows(
13741            project,
13742            generation,
13743            &graphs[0],
13744            &facts,
13745            &owners,
13746            &index,
13747            &candidates,
13748            &projection.entity_by_digest,
13749            &control,
13750        )?;
13751        require_eq(&rows.relations.len(), &3, "file-owned relation count")?;
13752        require_eq(&rows.occurrences.len(), &7, "distinct link occurrences")?;
13753        require(
13754            rows.relations.iter().all(|relation| {
13755                projection
13756                    .entity_by_digest
13757                    .get(relation.source().digest())
13758                    .is_some_and(|entity| {
13759                        matches!(
13760                            entity.selector(),
13761                            EntitySelector::File { path } if path.as_str() == "docs/guide.md"
13762                        )
13763                    })
13764            }),
13765            "document relations were not owned by the document file",
13766        )?;
13767        let heading_targets = rows
13768            .relations
13769            .iter()
13770            .filter_map(|relation| {
13771                let RelationResolution::Resolved {
13772                    selector: projectatlas_core::graph::ReusableTargetSelector::Symbol { symbol },
13773                    ..
13774                } = relation.resolution()
13775                else {
13776                    return None;
13777                };
13778                (symbol.kind == SymbolKind::Heading).then(|| symbol.signature.as_str().to_string())
13779            })
13780            .collect::<BTreeSet<_>>();
13781        require_eq(
13782            &heading_targets,
13783            &BTreeSet::from(["first".to_string(), "second".to_string()]),
13784            "file-owned heading targets",
13785        )?;
13786        let source_target_occurrences = rows
13787            .occurrences
13788            .iter()
13789            .filter(|occurrence| occurrence.file().as_str() == "docs/guide.md")
13790            .count();
13791        require_eq(
13792            &source_target_occurrences,
13793            &7,
13794            "file-owned relation occurrences",
13795        )?;
13796        Ok(())
13797    }
13798
13799    #[test]
13800    fn complete_document_without_static_candidates_reports_no_candidates()
13801    -> Result<(), Box<dyn Error>> {
13802        let facts = projectatlas_symbols::extract_markdown_facts(
13803            "# Overview\n\nLong prose without a repository reference.\n",
13804        );
13805        let coverage = document_coverage("docs/overview.md", &facts, IndexGeneration::new(6))?;
13806        require_eq(
13807            &coverage.state(),
13808            &CoverageState::NoCandidates,
13809            "empty document relation coverage",
13810        )?;
13811        require_eq(&coverage.total(), &0, "empty document relation total")?;
13812        Ok(())
13813    }
13814
13815    #[test]
13816    fn partial_markdown_evidence_limit_maps_to_intermediate_bytes_coverage()
13817    -> Result<(), Box<dyn Error>> {
13818        let label = "l".repeat(MAX_MARKDOWN_LABEL_BYTES);
13819        let selector = format!(
13820            "src/{}.rs",
13821            "s".repeat(MAX_DOCUMENT_SELECTOR_BYTES - "src/".len() - ".rs".len())
13822        );
13823        let evidence_bytes = label.len() + selector.len();
13824        let source = format!("[{label}]({selector})\n")
13825            .repeat(MAX_MARKDOWN_EVIDENCE_BYTES / evidence_bytes + 1);
13826        let facts = projectatlas_symbols::extract_markdown_facts(&source);
13827        require(
13828            facts
13829                .coverage
13830                .limits
13831                .contains(&MarkdownFactLimit::EvidenceBytes),
13832            "real Markdown extraction did not reach its evidence-byte limit",
13833        )?;
13834        require(
13835            !facts.link_candidates.is_empty(),
13836            "evidence-limited Markdown extraction lost every valid candidate",
13837        )?;
13838
13839        let coverage = document_coverage("docs/limited.md", &facts, IndexGeneration::new(7))?;
13840        require_eq(
13841            &coverage.state(),
13842            &CoverageState::Partial,
13843            "evidence-limited document coverage state",
13844        )?;
13845        require_eq(
13846            &coverage.reached_limit(),
13847            &Some(GraphLimitKind::IntermediateBytes),
13848            "evidence-limited document graph limit",
13849        )?;
13850        Ok(())
13851    }
13852
13853    #[test]
13854    fn document_cycles_emit_only_canonical_bounded_edges() -> Result<(), Box<dyn Error>> {
13855        let temp = tempfile::tempdir()?;
13856        fs::create_dir_all(temp.path().join("docs"))?;
13857        fs::write(temp.path().join("docs/a.md"), "# A\n\n[b](b.md#b)\n")?;
13858        fs::write(temp.path().join("docs/b.md"), "# B\n\n[a](a.md#a)\n")?;
13859        let facts = [
13860            projectatlas_symbols::extract_markdown_facts("# A\n\n[b](b.md#b)\n"),
13861            projectatlas_symbols::extract_markdown_facts("# B\n\n[a](a.md#a)\n"),
13862        ];
13863        let graphs = vec![
13864            facts[0].symbol_graph("docs/a.md", Some("markdown")),
13865            facts[1].symbol_graph("docs/b.md", Some("markdown")),
13866        ];
13867        let nodes = vec![
13868            test_file_node("docs/a.md", "markdown"),
13869            test_file_node("docs/b.md", "markdown"),
13870        ];
13871        let project = ProjectInstanceId::from_bytes([35; 16])?;
13872        let generation = IndexGeneration::new(1);
13873        let packages = PackageIndex::from_graphs(&graphs)?;
13874        let control = super::super::standalone_index_work_control();
13875        let mut projection = build_entity_projection(
13876            project, generation, &nodes, &graphs, &packages, true, &control,
13877        )?;
13878        let candidates = resolution_registry_from_exports(&projection, &control)?;
13879        let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
13880        let index = DocumentResolutionIndex::new(temp.path(), &nodes, &scan_policy)?;
13881        let mut relations = Vec::new();
13882        for (graph, facts) in graphs.iter().zip(&facts) {
13883            let owners = projection
13884                .owners_by_graph
13885                .remove(&graph.path)
13886                .ok_or_else(|| io::Error::other("document owners were not projected"))?;
13887            relations.extend(
13888                project_document_rows(
13889                    project,
13890                    generation,
13891                    graph,
13892                    facts,
13893                    &owners,
13894                    &index,
13895                    &candidates,
13896                    &projection.entity_by_digest,
13897                    &control,
13898                )?
13899                .relations,
13900            );
13901        }
13902        require_eq(&relations.len(), &2, "document cycle relation count")?;
13903        require(
13904            relations.iter().all(|relation| {
13905                relation.kind() == GraphRelationKind::Extended(ExtendedRelationKind::Documents)
13906                    && matches!(
13907                        relation.resolution(),
13908                        RelationResolution::Resolved {
13909                            selector: ReusableTargetSelector::Symbol { symbol },
13910                            ..
13911                        } if symbol.kind == SymbolKind::Heading
13912                    )
13913            }),
13914            "document cycle emitted a non-canonical or unresolved edge",
13915        )?;
13916        Ok(())
13917    }
13918
13919    #[test]
13920    fn document_casefold_collisions_refuse_exact_winners_and_share_invalidation_keys()
13921    -> Result<(), Box<dyn Error>> {
13922        let temp = tempfile::tempdir()?;
13923        let nodes = vec![
13924            test_file_node("src/lib.rs", "rust"),
13925            test_file_node("SRC/lib.rs", "rust"),
13926        ];
13927        let control = super::super::standalone_index_work_control();
13928        let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
13929        let index = DocumentResolutionIndex::new(temp.path(), &nodes, &scan_policy)?;
13930        require_eq(
13931            &index.unresolved_reason("src/lib.rs")?,
13932            &Some(DocumentTargetUnresolvedReason::CaseConflict),
13933            "exact case-collision target",
13934        )?;
13935        let project = ProjectInstanceId::from_bytes([32; 16])?;
13936        require_eq(
13937            &document_casefold_resolution_key(project, "src/lib.rs")?,
13938            &document_casefold_resolution_key(project, "SRC/lib.rs")?,
13939            "casefold invalidation key",
13940        )?;
13941        Ok(())
13942    }
13943
13944    #[test]
13945    fn document_target_state_change_refuses_stale_publication() -> Result<(), Box<dyn Error>> {
13946        let temp = tempfile::tempdir()?;
13947        let control = super::super::standalone_index_work_control();
13948        let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
13949        let index = DocumentResolutionIndex::new(temp.path(), &[], &scan_policy)?;
13950        let actual = index
13951            .unresolved_reason("docs/target.md")?
13952            .ok_or_else(|| std::io::Error::other("absent target unexpectedly resolved"))?;
13953        let stale = if actual == DocumentTargetUnresolvedReason::Missing {
13954            DocumentTargetUnresolvedReason::Ignored
13955        } else {
13956            DocumentTargetUnresolvedReason::Missing
13957        };
13958        let document_target_states = vec![("docs/target.md".to_string(), stale)];
13959        drop(index);
13960        let staged = StagedRepositoryGraph {
13961            project: ProjectInstanceId::from_bytes([34; 16])?,
13962            mutation: RepositoryGraphMutation::Full,
13963            entities: Vec::new(),
13964            relations: Vec::new(),
13965            occurrences: Vec::new(),
13966            coverage: Vec::new(),
13967            entity_exports: Vec::new(),
13968            relation_dependencies: Vec::new(),
13969            document_unresolved_reasons: Vec::new(),
13970            identity_rejections: Vec::new(),
13971            resolution_derivations: BTreeMap::new(),
13972            peak_retained_bytes: 0,
13973            projection_removals_before_entities: Vec::new(),
13974            scan_policy,
13975            document_target_states,
13976            database: None,
13977            retained_bytes: 0,
13978        };
13979        require(
13980            matches!(
13981                staged.revalidate_document_targets(temp.path()),
13982                Err(CliError::RefreshRequired(_))
13983            ),
13984            "changed non-indexed target state reached publication",
13985        )?;
13986        Ok(())
13987    }
13988
13989    fn test_file_node(path: &str, language: &str) -> Node {
13990        Node {
13991            path: path.to_string(),
13992            kind: NodeKind::File,
13993            parent_path: path
13994                .rsplit_once('/')
13995                .map(|(parent, _name)| parent.to_string()),
13996            extension: Path::new(path)
13997                .extension()
13998                .map(|extension| format!(".{}", extension.to_string_lossy())),
13999            language: Some(language.to_string()),
14000            size_bytes: Some(1),
14001            mtime_ns: Some(1),
14002            content_hash: Some(format!("hash:{path}")),
14003        }
14004    }
14005
14006    #[test]
14007    fn large_document_projection_publishes_in_memory_staging_and_incremental()
14008    -> Result<(), Box<dyn Error>> {
14009        const DOCUMENT_COUNT: usize = 10;
14010        const LINKS_PER_DOCUMENT: usize = 1_024;
14011        const INCREMENTAL_LINKS: usize = 1_024;
14012        let temp = tempfile::tempdir()?;
14013        let root = fs::canonicalize(temp.path())?;
14014        fs::create_dir_all(root.join("content/real-target"))?;
14015        fs::write(root.join("content/resolved.md"), "# Resolved\n")?;
14016        let symlink_available = match create_directory_link(
14017            &root.join("content/real-target"),
14018            &root.join("content/alias"),
14019        ) {
14020            Ok(()) => true,
14021            Err(source) if cfg!(windows) && source.raw_os_error() == Some(1314) => {
14022                fs::create_dir(root.join("content/alias"))?;
14023                false
14024            }
14025            Err(source) => return Err(source.into()),
14026        };
14027        let database = root.join("projectatlas.db");
14028        let mut store = AtlasStore::open_for_project(&database, &root)?;
14029        let project = store
14030            .project_instance_id()?
14031            .ok_or("large document fixture project identity is missing")?;
14032        let control = IndexWorkControl::new(IndexCancellation::new(), None);
14033        let mut nodes = Vec::with_capacity(DOCUMENT_COUNT + 2);
14034        let mut docs_node = test_file_node("content", "unknown");
14035        docs_node.kind = NodeKind::Folder;
14036        docs_node.language = None;
14037        docs_node.extension = None;
14038        nodes.push(docs_node);
14039        let mut graphs = Vec::with_capacity(DOCUMENT_COUNT + 1);
14040        let mut document_facts: BTreeMap<
14041            String,
14042            Cow<'static, projectatlas_symbols::MarkdownFacts>,
14043        > = BTreeMap::new();
14044        for document in 0..DOCUMENT_COUNT {
14045            let path = format!("content/source-{document:02}.md");
14046            let mut links = Vec::with_capacity(LINKS_PER_DOCUMENT);
14047            if document == 0 {
14048                links.push("[resolved](resolved.md)".to_string());
14049                links.push("[symlink](alias)".to_string());
14050            }
14051            while links.len() < LINKS_PER_DOCUMENT {
14052                let link = links.len();
14053                links.push(format!(
14054                    "[missing-{document:02}-{link:04}](missing-{document:02}-{link:04}.md)"
14055                ));
14056            }
14057            let facts = projectatlas_symbols::extract_markdown_facts(&links.join("\n"));
14058            require_eq(
14059                &facts.link_candidates.len(),
14060                &LINKS_PER_DOCUMENT,
14061                "large document candidate count",
14062            )?;
14063            graphs.push(facts.symbol_graph(&path, Some("markdown")));
14064            document_facts.insert(path.clone(), Cow::Owned(facts));
14065            nodes.push(test_file_node(&path, "markdown"));
14066        }
14067        let resolved_facts = projectatlas_symbols::extract_markdown_facts("# Resolved\n");
14068        graphs.push(resolved_facts.symbol_graph("content/resolved.md", Some("markdown")));
14069        nodes.push(test_file_node("content/resolved.md", "markdown"));
14070        let candidate_count = document_facts
14071            .values()
14072            .map(|facts| facts.link_candidates.len())
14073            .sum::<usize>();
14074        require_eq(
14075            &candidate_count,
14076            &(DOCUMENT_COUNT * LINKS_PER_DOCUMENT),
14077            "large document projection candidate total",
14078        )?;
14079        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
14080        let packages = PackageIndex::from_graphs(&graphs)?;
14081
14082        let generation = IndexGeneration::new(1);
14083        let entities = build_entity_projection(
14084            project, generation, &nodes, &graphs, &packages, true, &control,
14085        )?;
14086        let candidates = resolution_registry_from_exports(&entities, &control)?;
14087        let in_memory = finish_projection_with_documents(
14088            project,
14089            generation,
14090            RepositoryGraphMutation::Full,
14091            &graphs,
14092            &root,
14093            &nodes,
14094            &document_facts,
14095            &GraphIdentityAdmission::default(),
14096            entities,
14097            &candidates,
14098            &scan_policy,
14099            &control,
14100        )?;
14101        require(
14102            in_memory.database.is_none(),
14103            "normal projection unexpectedly selected disposable staging",
14104        )?;
14105        require(
14106            in_memory.document_unresolved_reasons.len() > GraphLimits::MAX_ROWS as usize,
14107            "normal projection did not retain a multi-ceiling reason vector",
14108        )?;
14109        require_eq(
14110            &in_memory.relations.len(),
14111            &candidate_count,
14112            "normal projection relation count",
14113        )?;
14114        require(
14115            in_memory.relations.iter().any(|relation| {
14116                matches!(relation.resolution(), RelationResolution::Resolved { .. })
14117            }),
14118            "normal projection lost the resolved document result",
14119        )?;
14120        let symlink_relation = in_memory
14121            .relations
14122            .iter()
14123            .find(|relation| {
14124                matches!(
14125                    relation.resolution(),
14126                    RelationResolution::Unresolved { reference }
14127                        if reference.as_str() == "alias"
14128                )
14129            })
14130            .ok_or("normal projection lost the symlink document result")?;
14131        let symlink_reason = in_memory
14132            .document_unresolved_reasons
14133            .iter()
14134            .find(|(key, _reason)| key == symlink_relation.key())
14135            .map(|(_key, reason)| *reason);
14136        require_eq(
14137            &symlink_reason,
14138            &Some(DocumentTargetUnresolvedReason::Unsupported),
14139            "symlink document result did not retain its closed reason",
14140        )?;
14141        require(
14142            !symlink_available || root.join("content/alias").exists(),
14143            "symlink document fixture disappeared before publication",
14144        )?;
14145        {
14146            let mut publication = store.begin_index_publication("large-document-projection")?;
14147            publication.upsert_scan_node_batch(&nodes)?;
14148            in_memory.apply(&mut publication, &control)?;
14149            publication.complete()?;
14150        }
14151        let published_page = store.repository_graph_relation_rows(
14152            RepositoryGraphRelationQuery::Family {
14153                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
14154            },
14155            GraphLimits::MAX_ROWS,
14156            None,
14157        )?;
14158        require(
14159            published_page.truncated && published_page.rows.len() == GraphLimits::MAX_ROWS as usize,
14160            "normal projection publication did not expose a bounded multi-page result",
14161        )?;
14162
14163        let incremental_path = "content/source-00.md".to_string();
14164        let incremental_source = (0..INCREMENTAL_LINKS)
14165            .map(|link| format!("[incremental-{link:04}](incremental-{link:04}.md)"))
14166            .collect::<Vec<_>>()
14167            .join("\n");
14168        let incremental_facts = projectatlas_symbols::extract_markdown_facts(&incremental_source);
14169        let incremental_graph = incremental_facts.symbol_graph(&incremental_path, Some("markdown"));
14170        let incremental_nodes = vec![test_file_node(&incremental_path, "markdown")];
14171        let incremental_packages =
14172            PackageIndex::from_graphs(std::slice::from_ref(&incremental_graph))?;
14173        let incremental_entities = build_entity_projection(
14174            project,
14175            IndexGeneration::new(2),
14176            &incremental_nodes,
14177            std::slice::from_ref(&incremental_graph),
14178            &incremental_packages,
14179            false,
14180            &control,
14181        )?;
14182        let incremental_candidates =
14183            resolution_registry_from_exports(&incremental_entities, &control)?;
14184        let incremental_staged = finish_projection_with_documents(
14185            project,
14186            IndexGeneration::new(2),
14187            RepositoryGraphMutation::AffectedPaths(vec![incremental_path.clone()]),
14188            std::slice::from_ref(&incremental_graph),
14189            &root,
14190            &nodes,
14191            &BTreeMap::from([(incremental_path.clone(), Cow::Owned(incremental_facts))]),
14192            &GraphIdentityAdmission::default(),
14193            incremental_entities,
14194            &incremental_candidates,
14195            &scan_policy,
14196            &control,
14197        )?;
14198        require(
14199            incremental_staged.document_unresolved_reasons.len() <= GraphLimits::MAX_ROWS as usize,
14200            "incremental projection exceeded its aggregate row budget",
14201        )?;
14202        enforce_incremental_projection_limits(
14203            &root,
14204            &BTreeSet::from([incremental_path]),
14205            RepositoryAffectedSourceFootprint {
14206                rows: 0,
14207                retained_bytes: 0,
14208                truncated: false,
14209            },
14210            &incremental_staged,
14211        )?;
14212        {
14213            let mut publication =
14214                store.begin_index_projection_refresh("large-document-projection")?;
14215            incremental_staged.apply(&mut publication, &control)?;
14216            publication.complete()?;
14217        }
14218        require_eq(
14219            &store.repository_graph_generation()?,
14220            &Some(IndexGeneration::new(2)),
14221            "incremental publication generation",
14222        )?;
14223
14224        let staging_generation = IndexGeneration::new(3);
14225        let staging_entities = build_entity_projection(
14226            project,
14227            staging_generation,
14228            &nodes,
14229            &graphs,
14230            &packages,
14231            true,
14232            &control,
14233        )?;
14234        let staging_candidates = resolution_registry_from_exports(&staging_entities, &control)?;
14235        let staged = finish_projection_in_database_with_documents(
14236            &root,
14237            &nodes,
14238            project,
14239            staging_generation,
14240            &graphs,
14241            &document_facts,
14242            &GraphIdentityAdmission::default(),
14243            staging_entities,
14244            &staging_candidates,
14245            &scan_policy,
14246            &control,
14247        )?;
14248        let database_stage = staged
14249            .database
14250            .as_ref()
14251            .ok_or("large projection did not select disposable staging")?;
14252        let staged_database_bytes = fs::metadata(
14253            database_stage
14254                .directory()?
14255                .path()
14256                .join(GRAPH_STAGE_DATABASE_FILE_NAME),
14257        )?
14258        .len();
14259        require(
14260            staged_database_bytes > 0,
14261            "disposable staging database retained no durable bytes",
14262        )?;
14263        {
14264            let mut publication =
14265                store.begin_index_projection_refresh("large-document-projection")?;
14266            publication.upsert_scan_node_batch(&nodes)?;
14267            staged.apply(&mut publication, &control)?;
14268            publication.complete()?;
14269        }
14270        let staged_page = store.repository_graph_relation_rows(
14271            RepositoryGraphRelationQuery::Family {
14272                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
14273            },
14274            GraphLimits::MAX_ROWS,
14275            None,
14276        )?;
14277        require(
14278            staged_page.truncated && staged_page.rows.len() == GraphLimits::MAX_ROWS as usize,
14279            "disposable staging publication did not expose a bounded multi-page result",
14280        )?;
14281        Ok(())
14282    }
14283
14284    #[test]
14285    fn incremental_document_admission_uses_emitted_rows() -> Result<(), Box<dyn Error>> {
14286        const DISCARDED_DOCUMENT_COUNT: usize = 513;
14287        const EMITTED_DOCUMENT_COUNT: usize = 10;
14288        const CANDIDATES_PER_DOCUMENT: usize = 1_024;
14289        let temp = tempfile::tempdir()?;
14290        let root = fs::canonicalize(temp.path())?;
14291        fs::create_dir_all(root.join("content"))?;
14292        let database = root.join("projectatlas.db");
14293        let mut store = AtlasStore::open_for_project(&database, &root)?;
14294        let paths = (0..DISCARDED_DOCUMENT_COUNT)
14295            .map(|index| format!("content/links-{index:03}.md"))
14296            .collect::<Vec<_>>();
14297        let mut nodes = paths
14298            .iter()
14299            .map(|path| test_file_node(path, "markdown"))
14300            .collect::<Vec<_>>();
14301        store.replace_scan(&nodes)?;
14302        let control = IndexWorkControl::new(IndexCancellation::new(), None);
14303        let symbols = empty_symbol_build_stage();
14304
14305        for path in &paths {
14306            let file_name = path
14307                .rsplit_once('/')
14308                .map(|(_parent, file_name)| file_name)
14309                .ok_or("same-file document fixture path has no parent")?;
14310            let self_source = (0..CANDIDATES_PER_DOCUMENT)
14311                .map(|index| format!("[self-{index:05}]({file_name})"))
14312                .collect::<Vec<_>>()
14313                .join("\n");
14314            fs::write(root.join(path), &self_source)?;
14315            let self_facts = projectatlas_symbols::extract_markdown_facts(&self_source);
14316            require_eq(
14317                &self_facts.link_candidates.len(),
14318                &CANDIDATES_PER_DOCUMENT,
14319                "same-file raw candidates per document",
14320            )?;
14321            store.replace_symbol_graph(&self_facts.symbol_graph(path, Some("markdown")))?;
14322        }
14323        for (node, path) in nodes.iter_mut().zip(&paths) {
14324            let bytes = fs::read(root.join(path))?;
14325            node.content_hash = Some(blake3::hash(&bytes).to_hex().to_string());
14326        }
14327        store.replace_scan(&nodes)?;
14328        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
14329        let incremental = stage_incremental_repository_graph(
14330            &store,
14331            &root,
14332            IndexGeneration::new(0),
14333            &nodes,
14334            &paths,
14335            &scan_policy,
14336            &symbols,
14337            &control,
14338        )?;
14339        require(
14340            matches!(
14341                &incremental.mutation,
14342                RepositoryGraphMutation::AffectedPaths(affected) if affected == &paths
14343            ),
14344            "same-file candidates were not retained as an incremental projection",
14345        )?;
14346        require(
14347            incremental.relations.is_empty() && incremental.document_unresolved_reasons.is_empty(),
14348            "same-file candidates emitted document rows despite the no-fragment filter",
14349        )?;
14350        require(
14351            store.repository_graph_generation()?.is_none(),
14352            "incremental admission test unexpectedly published a generation",
14353        )?;
14354
14355        let emitted_paths = paths
14356            .iter()
14357            .take(EMITTED_DOCUMENT_COUNT)
14358            .cloned()
14359            .collect::<Vec<_>>();
14360        for path in &emitted_paths {
14361            let unsupported_source = (0..CANDIDATES_PER_DOCUMENT)
14362                .map(|index| format!("[external-{index:05}](target-{index:05}.md#invalid?)"))
14363                .collect::<Vec<_>>()
14364                .join("\n");
14365            fs::write(root.join(path), &unsupported_source)?;
14366            let unsupported_facts =
14367                projectatlas_symbols::extract_markdown_facts(&unsupported_source);
14368            require_eq(
14369                &unsupported_facts.link_candidates.len(),
14370                &CANDIDATES_PER_DOCUMENT,
14371                "unsupported raw candidates per document",
14372            )?;
14373            store.replace_symbol_graph(&unsupported_facts.symbol_graph(path, Some("markdown")))?;
14374        }
14375        for (node, path) in nodes.iter_mut().zip(&paths) {
14376            let bytes = fs::read(root.join(path))?;
14377            node.content_hash = Some(blake3::hash(&bytes).to_hex().to_string());
14378        }
14379        store.replace_scan(&nodes)?;
14380        let error = stage_incremental_repository_graph(
14381            &store,
14382            &root,
14383            IndexGeneration::new(0),
14384            &nodes,
14385            &emitted_paths,
14386            &scan_policy,
14387            &symbols,
14388            &control,
14389        )
14390        .err()
14391        .ok_or("emitted document rows over the ceiling were admitted incrementally")?;
14392        let CliError::RefreshRequired(report) = error else {
14393            return Err(io::Error::other(format!(
14394                "expected typed full-refresh guidance, found {error:?}"
14395            ))
14396            .into());
14397        };
14398        require_eq(
14399            &report.reason,
14400            &IndexRefreshReason::DependencyClosureLimit,
14401            "emitted-row overflow reason",
14402        )?;
14403        require_eq(
14404            &report.scope,
14405            &IndexRefreshScope::Full,
14406            "emitted-row overflow scope",
14407        )?;
14408        require(
14409            store.repository_graph_generation()?.is_none(),
14410            "emitted-row overflow changed the current generation",
14411        )?;
14412        Ok(())
14413    }
14414
14415    #[test]
14416    fn reopened_source_parse_success_does_not_promote_fallback_graph_facts()
14417    -> Result<(), Box<dyn Error>> {
14418        let temp = tempfile::tempdir()?;
14419        let database = temp.path().join("projectatlas.db");
14420        let mut store = AtlasStore::open(&database)?;
14421        let graph = SymbolGraph {
14422            path: "src/optional.lang".to_string(),
14423            language: Some("optional-language".to_string()),
14424            parser: ParserKind::Fallback,
14425            symbols: vec![CodeSymbol {
14426                path: "src/optional.lang".to_string(),
14427                language: Some("optional-language".to_string()),
14428                name: "entry".to_string(),
14429                kind: SymbolKind::Function,
14430                signature: "entry()".to_string(),
14431                exported: false,
14432                documentation: None,
14433                line_start: 1,
14434                line_end: 1,
14435                source_selector: None,
14436                parent: None,
14437                parser: ParserKind::Fallback,
14438                detail: None,
14439            }],
14440            relations: vec![SymbolRelation {
14441                path: "src/optional.lang".to_string(),
14442                source_name: "entry".to_string(),
14443                target_name: "helper".to_string(),
14444                kind: RelationKind::Calls,
14445                line: 1,
14446                context: "helper()".to_string(),
14447                parser: ParserKind::Fallback,
14448            }],
14449        };
14450        store.replace_symbol_graph_with_metadata(
14451            &graph,
14452            &SourceParseMetadata {
14453                path: graph.path.clone(),
14454                language: graph.language.clone(),
14455                parser: ParserKind::TreeSitter,
14456                symbol_count: graph.symbols.len(),
14457                relation_count: graph.relations.len(),
14458            },
14459        )?;
14460        drop(store);
14461
14462        let reader = AtlasStore::open_read_only(&database)?;
14463        let graphs = reader.load_symbol_graphs_for_paths(std::slice::from_ref(&graph.path))?;
14464        require_eq(&graphs, &vec![graph], "reopened fact graph")?;
14465        let project = ProjectInstanceId::from_bytes([7; 16])?;
14466        let generation = IndexGeneration::new(1);
14467        let control = IndexWorkControl::new(IndexCancellation::new(), None);
14468        let packages = PackageIndex::from_graphs(&graphs)?;
14469        let entities =
14470            build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
14471        let candidates = resolution_registry_from_exports(&entities, &control)?;
14472        let staged = finish_projection(
14473            project,
14474            generation,
14475            RepositoryGraphMutation::Full,
14476            &graphs,
14477            entities,
14478            &candidates,
14479            &control,
14480        )?;
14481        require_eq(&staged.relations.len(), &1, "normalized relation count")?;
14482        require_eq(
14483            &staged.relations[0].confidence(),
14484            &ConfidenceClass::Low,
14485            "fallback relation confidence after reopen",
14486        )?;
14487        require_eq(
14488            &staged.relations[0].completeness(),
14489            &Completeness::Partial,
14490            "fallback relation completeness after reopen",
14491        )?;
14492        require(
14493            staged
14494                .relations
14495                .iter()
14496                .any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)),
14497            "reopened graph lost its legacy call relation",
14498        )?;
14499        require_eq(&staged.coverage.len(), &1, "normalized coverage count")?;
14500        require_eq(
14501            &staged.coverage[0].state(),
14502            &CoverageState::Partial,
14503            "fallback coverage after reopen",
14504        )?;
14505        reader.finish_index_read_snapshot()?;
14506        Ok(())
14507    }
14508
14509    #[test]
14510    fn partial_php_graph_publishes_existing_partial_coverage() -> Result<(), Box<dyn Error>> {
14511        let graph = extract_symbol_graph(
14512            "src/dynamic.php",
14513            Some("php"),
14514            "<?php function run(): void { $callable(); helper(); }",
14515        );
14516        require_eq(
14517            &graph.parser,
14518            &ParserKind::Fallback,
14519            "partial PHP fact parser",
14520        )?;
14521        let coverage = coverage_for_graph(
14522            &graph,
14523            IndexGeneration::new(1),
14524            &GraphIdentityAdmission::default(),
14525            &GraphIdentityAdmission::default(),
14526        )?;
14527        require_eq(
14528            &coverage.state(),
14529            &CoverageState::Partial,
14530            "partial PHP coverage state",
14531        )?;
14532        require_eq(&coverage.covered(), &1, "partial PHP covered relations")?;
14533        require_eq(&coverage.omitted(), &1, "partial PHP omitted relations")?;
14534        require(
14535            coverage.reason().is_some(),
14536            "partial PHP coverage must disclose its reason",
14537        )?;
14538        Ok(())
14539    }
14540
14541    #[test]
14542    fn mixed_php_graph_publishes_complete_coverage() -> Result<(), Box<dyn Error>> {
14543        for (path, source, symbol_name) in [
14544            (
14545                "src/inline-output.php",
14546                "//x<?php function marker(): void { helper(); }",
14547                "marker",
14548            ),
14549            (
14550                "src/inline-echo.php",
14551                "#output<?= $value ?><?php function after_echo(): void { helper(); }",
14552                "after_echo",
14553            ),
14554        ] {
14555            let graph = extract_symbol_graph(path, Some("php"), source);
14556            require_eq(
14557                &graph.parser,
14558                &ParserKind::TreeSitter,
14559                "mixed PHP fact parser",
14560            )?;
14561            require(
14562                graph
14563                    .symbols
14564                    .iter()
14565                    .any(|symbol| symbol.name == symbol_name),
14566                "mixed PHP declaration is missing before coverage projection",
14567            )?;
14568            let coverage = coverage_for_graph(
14569                &graph,
14570                IndexGeneration::new(1),
14571                &GraphIdentityAdmission::default(),
14572                &GraphIdentityAdmission::default(),
14573            )?;
14574            require_eq(
14575                &coverage.state(),
14576                &CoverageState::Complete,
14577                "mixed PHP coverage state",
14578            )?;
14579            require(
14580                coverage.covered() > 0,
14581                "mixed PHP complete coverage must retain static relations",
14582            )?;
14583            require_eq(&coverage.omitted(), &0, "mixed PHP omitted relations")?;
14584            require(
14585                coverage.reason().is_none(),
14586                "complete mixed PHP coverage must not disclose a partial reason",
14587            )?;
14588        }
14589        Ok(())
14590    }
14591
14592    #[test]
14593    fn persisted_closure_overflow_preserves_the_complete_generation() -> Result<(), Box<dyn Error>>
14594    {
14595        let temp = tempfile::tempdir()?;
14596        let root = temp.path().join("persisted-closure-overflow");
14597        fs::create_dir_all(&root)?;
14598        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
14599        let project = store
14600            .project_instance_id()?
14601            .ok_or("bound project identity is missing")?;
14602        let control = IndexWorkControl::new(IndexCancellation::new(), None);
14603        let graphs = vec![function_graph("src/lib.rs", 1)];
14604        let packages = PackageIndex::from_graphs(&graphs)?;
14605        let entity_projection = build_entity_projection(
14606            project,
14607            IndexGeneration::new(1),
14608            &[],
14609            &graphs,
14610            &packages,
14611            true,
14612            &control,
14613        )?;
14614        let dependency_keys = entity_projection
14615            .keys_by_graph
14616            .values()
14617            .flat_map(projectatlas_symbols::ResolutionKeyProjection::relation_keys)
14618            .flat_map(|relation| relation.keys().iter().cloned())
14619            .collect::<BTreeSet<_>>()
14620            .into_iter()
14621            .collect::<Vec<_>>();
14622        let candidates = resolution_registry_from_exports(&entity_projection, &control)?;
14623        let staged = finish_projection(
14624            project,
14625            IndexGeneration::new(1),
14626            RepositoryGraphMutation::Full,
14627            &graphs,
14628            entity_projection,
14629            &candidates,
14630            &control,
14631        )?;
14632        let file_key = staged
14633            .entities
14634            .iter()
14635            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
14636            .ok_or("staged file entity is missing")?
14637            .key()
14638            .clone();
14639        {
14640            let mut publication = store.begin_index_publication("closure-overflow")?;
14641            publication.begin_scan_replacement()?;
14642            publication.upsert_scan_node_batch(&[Node {
14643                path: "src/lib.rs".to_string(),
14644                kind: NodeKind::File,
14645                parent_path: Some("src".to_string()),
14646                extension: Some(".rs".to_string()),
14647                language: Some("rust".to_string()),
14648                size_bytes: Some(1),
14649                mtime_ns: Some(1),
14650                content_hash: Some("initial".to_string()),
14651            }])?;
14652            publication.finish_scan_replacement()?;
14653            staged.apply(&mut publication, &control)?;
14654            publication.complete()?;
14655        }
14656
14657        store.replace_symbol_graph(&function_graph(
14658            "src/lib.rs",
14659            usize::try_from(MAX_INCREMENTAL_GRAPH_ROWS).unwrap_or(usize::MAX),
14660        ))?;
14661        let publication_before = store
14662            .index_publication()?
14663            .ok_or("complete publication is missing")?;
14664        let entity_before = store
14665            .repository_graph_entity(&file_key)?
14666            .ok_or("published file entity is missing")?;
14667        let exports_before =
14668            store.repository_export_keys_for_paths(project, &["src/lib.rs".to_string()], 100)?;
14669        let dependencies_before =
14670            store.repository_affected_source_paths(project, &dependency_keys, 100)?;
14671        let empty_symbols = empty_symbol_build_stage();
14672        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
14673
14674        let error = stage_incremental_repository_graph(
14675            &store,
14676            &root,
14677            publication_before.generation,
14678            &[],
14679            &["src/lib.rs".to_string()],
14680            &scan_policy,
14681            &empty_symbols,
14682            &control,
14683        )
14684        .err()
14685        .ok_or_else(|| io::Error::other("oversized closure acquired the publication writer"))?;
14686        let CliError::RefreshRequired(report) = error else {
14687            return Err(io::Error::other(format!(
14688                "expected typed full-refresh guidance, found {error:?}"
14689            ))
14690            .into());
14691        };
14692        require_eq(
14693            &report.reason,
14694            &IndexRefreshReason::DependencyClosureLimit,
14695            "persisted overflow reason",
14696        )?;
14697        require_eq(
14698            &report.scope,
14699            &IndexRefreshScope::Full,
14700            "persisted overflow scope",
14701        )?;
14702        require_eq(
14703            &store.index_publication()?,
14704            &Some(publication_before),
14705            "publication after overflow",
14706        )?;
14707        require_eq(
14708            &store.repository_graph_entity(&file_key)?,
14709            &Some(entity_before),
14710            "file entity after overflow",
14711        )?;
14712        require_eq(
14713            &store.repository_export_keys_for_paths(project, &["src/lib.rs".to_string()], 100)?,
14714            &exports_before,
14715            "export keys after overflow",
14716        )?;
14717        require_eq(
14718            &store.repository_affected_source_paths(project, &dependency_keys, 100)?,
14719            &dependencies_before,
14720            "dependency owners after overflow",
14721        )?;
14722        Ok(())
14723    }
14724
14725    #[test]
14726    fn semantic_provider_key_rejection_keeps_valid_exports_and_relations()
14727    -> Result<(), Box<dyn Error>> {
14728        let temp = tempfile::tempdir()?;
14729        let root = temp.path().join("semantic-resolution-key-admission");
14730        fs::create_dir_all(&root)?;
14731        let long_path = |folder: &str, component: char, file: &str| {
14732            let components = (0..20)
14733                .map(|_| std::iter::repeat_n(component, 200).collect::<String>())
14734                .collect::<Vec<_>>()
14735                .join("/");
14736            format!("src/{folder}/{components}/{file}")
14737        };
14738        let path = long_path("first", 'd', "page.rs");
14739        let sibling_path = long_path("second", 'e', "sibling.rs");
14740        let nodes = vec![
14741            test_file_node(&path, "rust"),
14742            test_file_node(&sibling_path, "rust"),
14743        ];
14744        let project_database = root.join(".projectatlas/projectatlas.db");
14745        fs::create_dir_all(root.join(".projectatlas"))?;
14746        let mut store = AtlasStore::open_for_project(&project_database, &root)?;
14747        let project = store
14748            .project_instance_id()?
14749            .ok_or("semantic resolution project identity is missing")?;
14750        let initial_graphs = vec![
14751            semantic_resolution_key_graph(&path, true),
14752            semantic_resolution_key_graph(&sibling_path, false),
14753        ];
14754        let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
14755        let scan_policy =
14756            RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
14757        let initial_stage = stage_full_repository_graph(
14758            &store,
14759            &root,
14760            IndexGeneration::ZERO,
14761            &nodes,
14762            &scan_policy,
14763            &symbol_build_stage_for_graphs(initial_graphs.clone()),
14764            &initial_control,
14765        )?;
14766        require(
14767            initial_stage
14768                .identity_rejections
14769                .iter()
14770                .filter(|row| row.field == GraphIdentityField::ResolutionKey)
14771                .count()
14772                >= 2,
14773            "semantic provider full-stage did not retain every invalid resolution-key fact",
14774        )?;
14775        let initial_coverage = initial_stage
14776            .coverage
14777            .iter()
14778            .find(|coverage| {
14779                matches!(
14780                    coverage.scope(),
14781                    CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
14782                )
14783            })
14784            .ok_or("semantic provider coverage row is missing")?;
14785        require_eq(
14786            &initial_coverage.state(),
14787            &CoverageState::Partial,
14788            "semantic provider valid-symbol coverage state",
14789        )?;
14790        require(
14791            initial_coverage.covered() > 0,
14792            "semantic provider valid symbols were not counted as covered",
14793        )?;
14794        let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
14795        canceled.cancel();
14796        {
14797            let mut publication = store.begin_index_publication("semantic-resolution-cancel")?;
14798            publication.begin_scan_replacement()?;
14799            publication.upsert_scan_node_batch(&nodes)?;
14800            publication.finish_scan_replacement()?;
14801            let error = initial_stage.apply(&mut publication, &canceled).err();
14802            require(
14803                matches!(
14804                    error,
14805                    Some(CliError::IndexWork(IndexWorkFailure::Cancelled {
14806                        stage: IndexWorkStage::Publication
14807                    }))
14808                ),
14809                "semantic provider cancellation was not observed after admission",
14810            )?;
14811        }
14812        require_eq(
14813            &store.index_publication()?,
14814            &None,
14815            "publication after semantic provider cancellation",
14816        )?;
14817        publish_full_staged_graph(
14818            &mut store,
14819            &nodes,
14820            &initial_stage,
14821            &initial_control,
14822            "semantic-resolution-initial",
14823        )?;
14824        let base_generation = store
14825            .index_publication()?
14826            .ok_or("semantic resolution initial publication is missing")?
14827            .generation;
14828        let rejection_paths = nodes
14829            .iter()
14830            .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
14831            .collect::<Result<Vec<_>, _>>()?;
14832        let rejections =
14833            store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
14834        let rejections = rejections
14835            .into_iter()
14836            .filter(|row| row.field == GraphIdentityField::ResolutionKey)
14837            .collect::<Vec<_>>();
14838        require(
14839            rejections.len() >= 2,
14840            "semantic provider full-stage rejection detail count",
14841        )?;
14842        require(
14843            rejections.iter().all(|row| {
14844                row.path.as_str() == path
14845                    && row.reason == GraphIdentityRejectionReason::Oversized
14846                    && row.parser == ParserKind::TreeSitter
14847            }),
14848            "semantic provider rejection provenance was not exact",
14849        )?;
14850        require(
14851            rejections.iter().any(|row| row.span.start_line() == 4)
14852                && rejections.iter().any(|row| row.span.start_line() == 5),
14853            "semantic provider did not retain distinct invalid symbol spans",
14854        )?;
14855        let rejection_wire = serde_json::to_string(&rejections)?;
14856        require(
14857            !rejection_wire.contains("LeakedIdentity"),
14858            "semantic provider rejection retained raw identity text",
14859        )?;
14860
14861        let file_entities = store.repository_graph_entities_by_path(
14862            project,
14863            &RepositoryNodePath::new(Path::new(&path))?,
14864            64,
14865        )?;
14866        require(
14867            file_entities.rows.iter().any(|entity| {
14868                matches!(
14869                    entity.selector(),
14870                    EntitySelector::Symbol { symbol } if symbol.name.as_str() == "page_helper"
14871                )
14872            }),
14873            "valid semantic sibling export was dropped",
14874        )?;
14875        let sibling_entities = store.repository_graph_entities_by_path(
14876            project,
14877            &RepositoryNodePath::new(Path::new(&sibling_path))?,
14878            64,
14879        )?;
14880        require(
14881            sibling_entities.rows.iter().any(|entity| {
14882                matches!(
14883                    entity.selector(),
14884                    EntitySelector::Symbol { symbol } if symbol.name.as_str() == "sibling_helper"
14885                )
14886            }),
14887            "valid semantic sibling-folder export was dropped",
14888        )?;
14889        let calls = store.repository_graph_relations(
14890            RepositoryGraphRelationQuery::Family {
14891                relation: GraphRelationKind::Legacy(RelationKind::Calls),
14892            },
14893            32,
14894        )?;
14895        require(
14896            calls
14897                .rows
14898                .iter()
14899                .any(|relation| relation.resolution().resolved_target().is_some()),
14900            "valid semantic sibling relation was not resolved",
14901        )?;
14902        let imports = store.repository_graph_relations(
14903            RepositoryGraphRelationQuery::Family {
14904                relation: GraphRelationKind::Legacy(RelationKind::Imports),
14905            },
14906            32,
14907        )?;
14908        require(
14909            !imports.rows.is_empty(),
14910            "valid semantic import relation was dropped beside invalid imports",
14911        )?;
14912        let exports =
14913            store.repository_export_keys_for_paths(project, std::slice::from_ref(&path), 128)?;
14914        require(
14915            exports.rows.len() > 2,
14916            "valid semantic provider resolution exports were dropped",
14917        )?;
14918        let sibling_exports = store.repository_export_keys_for_paths(
14919            project,
14920            std::slice::from_ref(&sibling_path),
14921            128,
14922        )?;
14923        require(
14924            sibling_exports.rows.len() > 2,
14925            "valid semantic sibling-folder resolution exports were dropped",
14926        )?;
14927        let sibling_exports_before_incremental = sibling_exports.rows;
14928
14929        drop(store);
14930        let mut store = AtlasStore::open_for_project(&project_database, &root)?;
14931        require_eq(
14932            &store.project_instance_id()?,
14933            &Some(project),
14934            "semantic provider project identity after SQLite reopen",
14935        )?;
14936        let reopened_sibling_exports = store.repository_export_keys_for_paths(
14937            project,
14938            std::slice::from_ref(&sibling_path),
14939            128,
14940        )?;
14941        require_eq(
14942            &reopened_sibling_exports.rows,
14943            &sibling_exports_before_incremental,
14944            "semantic provider valid exports after SQLite reopen",
14945        )?;
14946
14947        let invalid_sibling_control = IndexWorkControl::new(IndexCancellation::new(), None);
14948        let invalid_sibling_policy =
14949            RootScanPolicy::discover(&root, &ScanOptions::default(), &invalid_sibling_control)?;
14950        let invalid_sibling_stage = stage_incremental_repository_graph(
14951            &store,
14952            &root,
14953            base_generation,
14954            &nodes,
14955            std::slice::from_ref(&sibling_path),
14956            &invalid_sibling_policy,
14957            &symbol_build_stage_for_graphs(vec![semantic_resolution_key_graph(
14958                &sibling_path,
14959                true,
14960            )]),
14961            &invalid_sibling_control,
14962        )?;
14963        let incremental_generation = base_generation
14964            .checked_next()
14965            .ok_or("semantic resolution incremental generation overflowed")?;
14966        let direct_derivations = invalid_sibling_stage
14967            .resolution_derivations
14968            .get(&(sibling_path.clone(), incremental_generation))
14969            .copied();
14970        require_eq(
14971            &direct_derivations,
14972            &Some(1),
14973            "semantic provider direct resolution derivation count",
14974        )?;
14975        require(
14976            invalid_sibling_stage
14977                .identity_rejections
14978                .iter()
14979                .filter(|row| row.field == GraphIdentityField::ResolutionKey)
14980                .count()
14981                >= 2,
14982            "semantic provider incremental resolution-key rejection",
14983        )?;
14984        {
14985            let mut publication =
14986                store.begin_index_publication("semantic-resolution-incremental-invalid")?;
14987            invalid_sibling_stage.apply(&mut publication, &invalid_sibling_control)?;
14988            publication.complete()?;
14989        }
14990        let incremented_generation = store
14991            .index_publication()?
14992            .ok_or("semantic resolution incremental publication is missing")?
14993            .generation;
14994        let with_two_rejections =
14995            store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
14996        require(
14997            with_two_rejections.len() >= 4,
14998            "semantic provider incremental rejection detail count",
14999        )?;
15000        require(
15001            with_two_rejections
15002                .iter()
15003                .any(|row| row.path.as_str() == sibling_path),
15004            "semantic provider incremental rejection was not added for the changed path",
15005        )?;
15006        let invalid_sibling_exports = store.repository_export_keys_for_paths(
15007            project,
15008            std::slice::from_ref(&sibling_path),
15009            128,
15010        )?;
15011        require(
15012            sibling_exports_before_incremental
15013                .iter()
15014                .all(|key| invalid_sibling_exports.rows.contains(key)),
15015            "semantic provider valid exports were lost beside incremental rejection",
15016        )?;
15017        let invalid_sibling_calls = store.repository_graph_relations(
15018            RepositoryGraphRelationQuery::Family {
15019                relation: GraphRelationKind::Legacy(RelationKind::Calls),
15020            },
15021            32,
15022        )?;
15023        require(
15024            invalid_sibling_calls
15025                .rows
15026                .iter()
15027                .any(|relation| relation.resolution().resolved_target().is_some()),
15028            "semantic provider valid resolved relation was lost beside incremental rejection",
15029        )?;
15030
15031        let repaired_control = IndexWorkControl::new(IndexCancellation::new(), None);
15032        let repaired_policy =
15033            RootScanPolicy::discover(&root, &ScanOptions::default(), &repaired_control)?;
15034        let repaired_stage = stage_incremental_repository_graph(
15035            &store,
15036            &root,
15037            incremented_generation,
15038            &nodes,
15039            std::slice::from_ref(&sibling_path),
15040            &repaired_policy,
15041            &symbol_build_stage_for_graphs(vec![semantic_resolution_key_graph(
15042                &sibling_path,
15043                false,
15044            )]),
15045            &repaired_control,
15046        )?;
15047        {
15048            let mut publication =
15049                store.begin_index_publication("semantic-resolution-incremental-repair")?;
15050            repaired_stage.apply(&mut publication, &repaired_control)?;
15051            publication.complete()?;
15052        }
15053        let repaired_generation = store
15054            .index_publication()?
15055            .ok_or("semantic resolution repair publication is missing")?
15056            .generation;
15057        let repaired_rejections =
15058            store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
15059        require(
15060            repaired_rejections.len() >= 2,
15061            "semantic provider repaired rejection detail count",
15062        )?;
15063        require(
15064            repaired_rejections
15065                .iter()
15066                .all(|row| row.path.as_str() == path),
15067            "semantic provider repair removed or replaced an unrelated path detail",
15068        )?;
15069        let repaired_sibling_exports = store.repository_export_keys_for_paths(
15070            project,
15071            std::slice::from_ref(&sibling_path),
15072            128,
15073        )?;
15074        require_eq(
15075            &repaired_sibling_exports.rows,
15076            &sibling_exports_before_incremental,
15077            "semantic provider repair did not restore valid exports",
15078        )?;
15079
15080        let fault_control = IndexWorkControl::new(IndexCancellation::new(), None);
15081        let fault_policy =
15082            RootScanPolicy::discover(&root, &ScanOptions::default(), &fault_control)?;
15083        let mut fault_stage = stage_full_repository_graph(
15084            &store,
15085            &root,
15086            repaired_generation,
15087            &nodes,
15088            &fault_policy,
15089            &symbol_build_stage_for_graphs(initial_graphs.clone()),
15090            &fault_control,
15091        )?;
15092        fault_stage.identity_rejections.resize(
15093            usize::try_from(GraphLimits::MAX_ROWS)
15094                .unwrap_or(usize::MAX)
15095                .saturating_add(1),
15096            fault_stage.identity_rejections[0].clone(),
15097        );
15098        {
15099            let mut publication = store.begin_index_publication("semantic-resolution-fault")?;
15100            publication.begin_scan_replacement()?;
15101            publication.upsert_scan_node_batch(&nodes)?;
15102            publication.finish_scan_replacement()?;
15103            require(
15104                fault_stage.apply(&mut publication, &fault_control).is_err(),
15105                "semantic provider late rejection-detail fault did not fail",
15106            )?;
15107        }
15108        require_eq(
15109            &store
15110                .index_publication()?
15111                .map(|publication| publication.generation),
15112            &Some(repaired_generation),
15113            "semantic provider generation after late rejection-detail fault",
15114        )?;
15115        require_eq(
15116            &store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?,
15117            &repaired_rejections,
15118            "semantic provider prior generation after fault",
15119        )?;
15120
15121        let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
15122        let retry_policy =
15123            RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
15124        let retry_stage = stage_full_repository_graph(
15125            &store,
15126            &root,
15127            repaired_generation,
15128            &nodes,
15129            &retry_policy,
15130            &symbol_build_stage_for_graphs(initial_graphs),
15131            &retry_control,
15132        )?;
15133        publish_full_staged_graph(
15134            &mut store,
15135            &nodes,
15136            &retry_stage,
15137            &retry_control,
15138            "semantic-resolution-retry",
15139        )?;
15140        require_eq(
15141            &store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?,
15142            &rejections,
15143            "semantic provider deterministic retry",
15144        )?;
15145        Ok(())
15146    }
15147
15148    #[test]
15149    fn full_stage_admits_siblings_and_reopens_typed_rejections_with_cancel_retry()
15150    -> Result<(), Box<dyn Error>> {
15151        let temp = tempfile::tempdir()?;
15152        let root = temp.path().join("full-identity-admission");
15153        fs::create_dir_all(root.join("src"))?;
15154        fs::create_dir_all(root.join("tests"))?;
15155        fs::write(
15156            root.join("src/one.rs"),
15157            "pub fn caller() { helper(); }\nfn helper() {}\n",
15158        )?;
15159        fs::write(
15160            root.join("tests/two.rs"),
15161            "pub fn caller() { helper(); }\nfn helper() {}\n",
15162        )?;
15163        let database = root.join(".projectatlas/projectatlas.db");
15164        fs::create_dir_all(root.join(".projectatlas"))?;
15165        let mut store = AtlasStore::open_for_project(&database, &root)?;
15166        let project = store
15167            .project_instance_id()?
15168            .ok_or("full admission project identity is missing")?;
15169        let nodes = vec![
15170            test_file_node("src/one.rs", "rust"),
15171            test_file_node("tests/two.rs", "rust"),
15172        ];
15173        let mut first_graph =
15174            identity_sibling_graph("src/one.rs", GraphIdentityField::RelationTarget);
15175        first_graph.relations.push(SymbolRelation {
15176            path: "src/one.rs".to_string(),
15177            source_name: "bad\u{0}source".to_string(),
15178            target_name: "bad\u{0}target".to_string(),
15179            kind: RelationKind::Calls,
15180            line: 4,
15181            context: "identity admission dual-field fixture".to_string(),
15182            parser: ParserKind::TreeSitter,
15183        });
15184        let graphs = vec![
15185            first_graph,
15186            identity_sibling_graph("tests/two.rs", GraphIdentityField::RelationSource),
15187        ];
15188        let symbols = symbol_build_stage_for_graphs(graphs);
15189        let control = IndexWorkControl::new(IndexCancellation::new(), None);
15190        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
15191        let staged = stage_full_repository_graph(
15192            &store,
15193            &root,
15194            IndexGeneration::ZERO,
15195            &nodes,
15196            &scan_policy,
15197            &symbols,
15198            &control,
15199        )?;
15200        require_eq(
15201            &staged.identity_rejections.len(),
15202            &4,
15203            "full-stage typed rejection count",
15204        )?;
15205        let staged_dual_rejections = staged
15206            .identity_rejections
15207            .iter()
15208            .filter(|row| {
15209                row.path.as_str() == "src/one.rs"
15210                    && row.span.start_line() == 4
15211                    && row.reason == GraphIdentityRejectionReason::ControlCharacters
15212            })
15213            .collect::<Vec<_>>();
15214        require_eq(
15215            &staged_dual_rejections.len(),
15216            &2,
15217            "full-stage dual-field rejection details",
15218        )?;
15219        require(
15220            staged_dual_rejections
15221                .iter()
15222                .any(|row| row.field == GraphIdentityField::RelationSource)
15223                && staged_dual_rejections
15224                    .iter()
15225                    .any(|row| row.field == GraphIdentityField::RelationTarget),
15226            "full-stage dual-field rejection lost source or target provenance",
15227        )?;
15228        require(
15229            staged_dual_rejections
15230                .iter()
15231                .map(|row| row.fact_index)
15232                .all(|fact_index| fact_index == staged_dual_rejections[0].fact_index),
15233            "full-stage dual-field rejection lost parser fact identity",
15234        )?;
15235        let staged_first_coverage = staged
15236            .coverage
15237            .iter()
15238            .find(|coverage| {
15239                matches!(
15240                    coverage.scope(),
15241                    CoverageScope::Path { path } if path.as_str() == "src/one.rs"
15242                )
15243            })
15244            .ok_or("full-stage first coverage row is missing")?;
15245        require_eq(
15246            &staged_first_coverage.omitted(),
15247            &2,
15248            "full-stage dual-field rejection counted once per parser fact",
15249        )?;
15250        let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
15251        canceled.cancel();
15252        {
15253            let mut publication = store.begin_index_publication("full-identity-cancel")?;
15254            publication.begin_scan_replacement()?;
15255            publication.upsert_scan_node_batch(&nodes)?;
15256            publication.finish_scan_replacement()?;
15257            let error = staged.apply(&mut publication, &canceled).err();
15258            require(
15259                matches!(
15260                    error,
15261                    Some(CliError::IndexWork(IndexWorkFailure::Cancelled {
15262                        stage: IndexWorkStage::Publication
15263                    }))
15264                ),
15265                "full-stage cancellation was not observed after admission",
15266            )?;
15267        }
15268        require_eq(
15269            &store.index_publication()?,
15270            &None,
15271            "publication after canceled full-stage admission",
15272        )?;
15273
15274        publish_full_staged_graph(&mut store, &nodes, &staged, &control, "full-identity")?;
15275        let first_publication = store
15276            .index_publication()?
15277            .ok_or("full identity publication is missing")?;
15278        require_eq(
15279            &first_publication.generation,
15280            &IndexGeneration::new(1),
15281            "first full identity generation",
15282        )?;
15283        drop(store);
15284
15285        let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
15286        let rejection_paths = nodes
15287            .iter()
15288            .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
15289            .collect::<Result<Vec<_>, _>>()?;
15290        let reopened_rejections =
15291            reopened.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
15292        require_eq(
15293            &reopened_rejections.len(),
15294            &4,
15295            "reopened full-stage typed rejection count",
15296        )?;
15297        let reopened_dual_rejections = reopened_rejections
15298            .iter()
15299            .filter(|row| {
15300                row.path.as_str() == "src/one.rs"
15301                    && row.span.start_line() == 4
15302                    && row.reason == GraphIdentityRejectionReason::ControlCharacters
15303            })
15304            .collect::<Vec<_>>();
15305        require_eq(
15306            &reopened_dual_rejections.len(),
15307            &2,
15308            "reopened dual-field rejection details",
15309        )?;
15310        require(
15311            reopened_dual_rejections
15312                .iter()
15313                .any(|row| row.field == GraphIdentityField::RelationSource)
15314                && reopened_dual_rejections
15315                    .iter()
15316                    .any(|row| row.field == GraphIdentityField::RelationTarget),
15317            "reopened dual-field rejection lost source or target provenance",
15318        )?;
15319        require(
15320            reopened_dual_rejections
15321                .iter()
15322                .map(|row| row.fact_index)
15323                .all(|fact_index| fact_index == reopened_dual_rejections[0].fact_index),
15324            "reopened dual-field rejection lost parser fact identity",
15325        )?;
15326        let target_rejection = reopened_rejections
15327            .iter()
15328            .find(|row| {
15329                row.field == GraphIdentityField::RelationTarget
15330                    && row.path.as_str() == "src/one.rs"
15331                    && row.span.start_line() == 3
15332            })
15333            .ok_or("relation-target rejection is missing")?;
15334        require_eq(
15335            &target_rejection.path.as_str(),
15336            &"src/one.rs",
15337            "relation-target rejection path",
15338        )?;
15339        require_eq(
15340            &target_rejection.parser,
15341            &ParserKind::TreeSitter,
15342            "relation-target rejection parser",
15343        )?;
15344        require_eq(
15345            &target_rejection.reason,
15346            &GraphIdentityRejectionReason::ControlCharacters,
15347            "relation-target rejection reason",
15348        )?;
15349        require_eq(
15350            &target_rejection.span.start_line(),
15351            &3,
15352            "relation-target rejection start line",
15353        )?;
15354        require_eq(
15355            &target_rejection.span.end_line(),
15356            &3,
15357            "relation-target rejection end line",
15358        )?;
15359        let source_rejection = reopened_rejections
15360            .iter()
15361            .find(|row| {
15362                row.field == GraphIdentityField::RelationSource
15363                    && row.path.as_str() == "tests/two.rs"
15364            })
15365            .ok_or("relation-source rejection is missing")?;
15366        require_eq(
15367            &source_rejection.path.as_str(),
15368            &"tests/two.rs",
15369            "relation-source rejection path",
15370        )?;
15371        require_eq(
15372            &source_rejection.parser,
15373            &ParserKind::TreeSitter,
15374            "relation-source rejection parser",
15375        )?;
15376        require_eq(
15377            &source_rejection.reason,
15378            &GraphIdentityRejectionReason::ControlCharacters,
15379            "relation-source rejection reason",
15380        )?;
15381        require_eq(
15382            &source_rejection.span.start_line(),
15383            &3,
15384            "relation-source rejection start line",
15385        )?;
15386        require_eq(
15387            &source_rejection.span.end_line(),
15388            &3,
15389            "relation-source rejection end line",
15390        )?;
15391        let wire = serde_json::to_string(&reopened_rejections)?;
15392        require(
15393            !wire.contains("bad") && !wire.contains("target\u{0}"),
15394            "full-stage typed rejection retained raw invalid identity material",
15395        )?;
15396        for path in &rejection_paths {
15397            let entities = reopened.repository_graph_entities_by_path(project, path, 64)?;
15398            require(
15399                entities.rows.iter().any(|entity| {
15400                    matches!(
15401                        entity.selector(),
15402                        EntitySelector::Symbol { symbol } if symbol.name.as_str() == "caller"
15403                    )
15404                }),
15405                "valid sibling symbol was not navigable after SQLite reopen",
15406            )?;
15407        }
15408        let calls = reopened.repository_graph_relations(
15409            RepositoryGraphRelationQuery::Family {
15410                relation: GraphRelationKind::Legacy(RelationKind::Calls),
15411            },
15412            16,
15413        )?;
15414        require_eq(
15415            &calls.rows.len(),
15416            &2,
15417            "valid sibling call relations after SQLite reopen",
15418        )?;
15419        reopened.finish_index_read_snapshot()?;
15420
15421        let mut writer = AtlasStore::open_for_project(&database, &root)?;
15422        let fault_control = IndexWorkControl::new(IndexCancellation::new(), None);
15423        let fault_policy =
15424            RootScanPolicy::discover(&root, &ScanOptions::default(), &fault_control)?;
15425        let mut fault_stage = stage_full_repository_graph(
15426            &writer,
15427            &root,
15428            first_publication.generation,
15429            &nodes,
15430            &fault_policy,
15431            &symbols,
15432            &fault_control,
15433        )?;
15434        fault_stage.identity_rejections.resize(
15435            usize::try_from(GraphLimits::MAX_ROWS)
15436                .unwrap_or(usize::MAX)
15437                .saturating_add(1),
15438            fault_stage.identity_rejections[0].clone(),
15439        );
15440        {
15441            let mut publication = writer.begin_index_publication("full-identity-fault")?;
15442            publication.begin_scan_replacement()?;
15443            publication.upsert_scan_node_batch(&nodes)?;
15444            publication.finish_scan_replacement()?;
15445            require(
15446                fault_stage.apply(&mut publication, &fault_control).is_err(),
15447                "oversized rejection detail did not fault after graph replacement",
15448            )?;
15449        }
15450        require_eq(
15451            &writer
15452                .index_publication()?
15453                .map(|publication| publication.generation),
15454            &Some(first_publication.generation),
15455            "generation after late rejection-detail fault",
15456        )?;
15457        let retained =
15458            writer.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
15459        require_eq(
15460            &retained,
15461            &reopened_rejections,
15462            "prior complete typed rejection generation after fault",
15463        )?;
15464        let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
15465        let retry_policy =
15466            RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
15467        let retry_stage = stage_full_repository_graph(
15468            &writer,
15469            &root,
15470            first_publication.generation,
15471            &nodes,
15472            &retry_policy,
15473            &symbols,
15474            &retry_control,
15475        )?;
15476        publish_full_staged_graph(
15477            &mut writer,
15478            &nodes,
15479            &retry_stage,
15480            &retry_control,
15481            "full-identity-retry",
15482        )?;
15483        let retried =
15484            writer.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
15485        require_eq(
15486            &retried,
15487            &reopened_rejections,
15488            "deterministic full-stage retry",
15489        )?;
15490        Ok(())
15491    }
15492
15493    #[test]
15494    fn full_stage_reuse_carries_persisted_rejection_coverage_and_replaces_it()
15495    -> Result<(), Box<dyn Error>> {
15496        let temp = tempfile::tempdir()?;
15497        let root = temp.path().join("full-reuse-identity-admission");
15498        fs::create_dir_all(root.join("src"))?;
15499        fs::write(
15500            root.join("src/reused.rs"),
15501            "pub fn caller() { helper(); }\nfn helper() {}\n",
15502        )?;
15503        let database = root.join(".projectatlas/projectatlas.db");
15504        fs::create_dir_all(root.join(".projectatlas"))?;
15505        let mut store = AtlasStore::open_for_project(&database, &root)?;
15506        let project = store
15507            .project_instance_id()?
15508            .ok_or("full reuse project identity is missing")?;
15509        let path = "src/reused.rs";
15510        let nodes = vec![test_file_node(path, "rust")];
15511        let graph = identity_sibling_graph(path, GraphIdentityField::RelationTarget);
15512        let first_control = IndexWorkControl::new(IndexCancellation::new(), None);
15513        let first_policy =
15514            RootScanPolicy::discover(&root, &ScanOptions::default(), &first_control)?;
15515        let mut first_symbols = symbol_build_stage_for_graphs(vec![graph]);
15516        first_symbols.identity_admission =
15517            super::admit_symbol_build_stage(&mut first_symbols, &first_control)?;
15518        let first_graph = first_symbols
15519            .changes
15520            .iter()
15521            .find_map(|change| match change {
15522                SymbolProjectionChange::Parsed(parsed) => Some(parsed.graph.clone()),
15523                SymbolProjectionChange::Clear { .. } => None,
15524            })
15525            .ok_or("full reuse parsed graph is missing")?;
15526        let first_stage = stage_full_repository_graph(
15527            &store,
15528            &root,
15529            IndexGeneration::ZERO,
15530            &nodes,
15531            &first_policy,
15532            &first_symbols,
15533            &first_control,
15534        )?;
15535        require_eq(
15536            &first_stage.identity_rejections.len(),
15537            &1,
15538            "full reuse initial rejection detail count",
15539        )?;
15540        publish_full_staged_graph(
15541            &mut store,
15542            &nodes,
15543            &first_stage,
15544            &first_control,
15545            "full-reuse-initial",
15546        )?;
15547        // This is the same persistence sink used by apply_symbol_build_stage;
15548        // the next full stage intentionally receives no parser changes.
15549        store.replace_symbol_graph(&first_graph)?;
15550        let first_generation = store
15551            .index_publication()?
15552            .ok_or("full reuse initial publication is missing")?
15553            .generation;
15554        drop(store);
15555
15556        let mut store = AtlasStore::open_for_project(&database, &root)?;
15557        let reuse_control = IndexWorkControl::new(IndexCancellation::new(), None);
15558        let reuse_policy =
15559            RootScanPolicy::discover(&root, &ScanOptions::default(), &reuse_control)?;
15560        let reused_stage = stage_full_repository_graph(
15561            &store,
15562            &root,
15563            first_generation,
15564            &nodes,
15565            &reuse_policy,
15566            &empty_symbol_build_stage(),
15567            &reuse_control,
15568        )?;
15569        require_eq(
15570            &reused_stage.identity_rejections,
15571            &first_stage.identity_rejections,
15572            "full reuse retained exact persisted rejection details",
15573        )?;
15574        let reused_coverage = reused_stage
15575            .coverage
15576            .iter()
15577            .find(|coverage| {
15578                matches!(
15579                    coverage.scope(),
15580                    CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
15581                ) && coverage.relation().is_none()
15582            })
15583            .ok_or("full reuse coverage row is missing")?;
15584        require_eq(
15585            &reused_coverage.state(),
15586            &CoverageState::Partial,
15587            "full reuse retained partial coverage",
15588        )?;
15589        require_eq(
15590            &reused_coverage.omitted(),
15591            &1,
15592            "full reuse retained rejection omission count",
15593        )?;
15594        require(
15595            reused_stage
15596                .relations
15597                .iter()
15598                .any(|relation| relation.resolution().resolved_target().is_some()),
15599            "full reuse dropped the valid sibling relation",
15600        )?;
15601        publish_full_staged_graph(
15602            &mut store,
15603            &nodes,
15604            &reused_stage,
15605            &reuse_control,
15606            "full-reuse-republish",
15607        )?;
15608        let persisted_paths = vec![RepositoryNodePath::new(Path::new(path))?];
15609        let persisted =
15610            store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?;
15611        require_eq(
15612            &persisted,
15613            &first_stage.identity_rejections,
15614            "full reuse persisted exact rejection details",
15615        )?;
15616
15617        let cancel_control = IndexWorkControl::new(IndexCancellation::new(), None);
15618        let canceled_stage = stage_full_repository_graph(
15619            &store,
15620            &root,
15621            first_generation
15622                .checked_next()
15623                .ok_or("full reuse generation overflow")?,
15624            &nodes,
15625            &reuse_policy,
15626            &empty_symbol_build_stage(),
15627            &cancel_control,
15628        )?;
15629        cancel_control.cancel();
15630        {
15631            let mut publication = store.begin_index_publication("full-reuse-cancel")?;
15632            publication.begin_scan_replacement()?;
15633            publication.upsert_scan_node_batch(&nodes)?;
15634            publication.finish_scan_replacement()?;
15635            require(
15636                canceled_stage
15637                    .apply(&mut publication, &cancel_control)
15638                    .is_err(),
15639                "full reuse cancellation was ignored",
15640            )?;
15641        }
15642        let retained_generation = store
15643            .index_publication()?
15644            .ok_or("full reuse republished generation is missing")?
15645            .generation;
15646        require_eq(
15647            &retained_generation,
15648            &first_generation
15649                .checked_next()
15650                .ok_or("full reuse generation overflow")?,
15651            "full reuse cancellation changed the current generation",
15652        )?;
15653        require_eq(
15654            &store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?,
15655            &persisted,
15656            "full reuse cancellation changed rejection details",
15657        )?;
15658
15659        let fault_control = IndexWorkControl::new(IndexCancellation::new(), None);
15660        let fault_policy =
15661            RootScanPolicy::discover(&root, &ScanOptions::default(), &fault_control)?;
15662        let mut fault_stage = stage_full_repository_graph(
15663            &store,
15664            &root,
15665            retained_generation,
15666            &nodes,
15667            &fault_policy,
15668            &empty_symbol_build_stage(),
15669            &fault_control,
15670        )?;
15671        fault_stage.identity_rejections.resize(
15672            usize::try_from(GraphLimits::MAX_ROWS)
15673                .unwrap_or(usize::MAX)
15674                .saturating_add(1),
15675            fault_stage.identity_rejections[0].clone(),
15676        );
15677        {
15678            let mut publication = store.begin_index_publication("full-reuse-fault")?;
15679            publication.begin_scan_replacement()?;
15680            publication.upsert_scan_node_batch(&nodes)?;
15681            publication.finish_scan_replacement()?;
15682            require(
15683                fault_stage.apply(&mut publication, &fault_control).is_err(),
15684                "full reuse late rejection-detail fault was ignored",
15685            )?;
15686        }
15687        require_eq(
15688            &store
15689                .index_publication()?
15690                .ok_or("full reuse fault lost publication")?
15691                .generation,
15692            &retained_generation,
15693            "full reuse late fault changed current generation",
15694        )?;
15695        require_eq(
15696            &store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?,
15697            &persisted,
15698            "full reuse late fault changed rejection details",
15699        )?;
15700        let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
15701        let retry_policy =
15702            RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
15703        let retry_stage = stage_full_repository_graph(
15704            &store,
15705            &root,
15706            retained_generation,
15707            &nodes,
15708            &retry_policy,
15709            &empty_symbol_build_stage(),
15710            &retry_control,
15711        )?;
15712        publish_full_staged_graph(
15713            &mut store,
15714            &nodes,
15715            &retry_stage,
15716            &retry_control,
15717            "full-reuse-retry",
15718        )?;
15719        require_eq(
15720            &store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?,
15721            &persisted,
15722            "full reuse retry changed rejection details",
15723        )?;
15724        let retained_generation = store
15725            .index_publication()?
15726            .ok_or("full reuse retry publication is missing")?
15727            .generation;
15728
15729        let valid_graph = extract_symbol_graph(
15730            path,
15731            Some("rust"),
15732            "pub fn caller() { helper(); }\nfn helper() {}\n",
15733        );
15734        let valid_control = IndexWorkControl::new(IndexCancellation::new(), None);
15735        let valid_policy =
15736            RootScanPolicy::discover(&root, &ScanOptions::default(), &valid_control)?;
15737        let valid_stage = stage_full_repository_graph(
15738            &store,
15739            &root,
15740            retained_generation,
15741            &nodes,
15742            &valid_policy,
15743            &symbol_build_stage_for_graphs(vec![valid_graph]),
15744            &valid_control,
15745        )?;
15746        require(
15747            valid_stage.identity_rejections.is_empty(),
15748            "full reuse changed graph retained stale rejection detail",
15749        )?;
15750        publish_full_staged_graph(
15751            &mut store,
15752            &nodes,
15753            &valid_stage,
15754            &valid_control,
15755            "full-reuse-repair",
15756        )?;
15757        require(
15758            store
15759                .repository_graph_identity_rejections(project, &persisted_paths, 16, None)?
15760                .is_empty(),
15761            "full reuse repaired graph retained stale rejection detail",
15762        )?;
15763        Ok(())
15764    }
15765
15766    #[test]
15767    fn full_stage_reuse_preserves_capped_fallback_omission_count_without_details()
15768    -> Result<(), Box<dyn Error>> {
15769        let temp = tempfile::tempdir()?;
15770        let root = temp.path().join("full-reuse-fallback-identity-admission");
15771        fs::create_dir_all(root.join("src"))?;
15772        fs::write(
15773            root.join("src/reused.rs"),
15774            "pub fn caller() { helper(); }\nfn helper() {}\n",
15775        )?;
15776        let database = root.join(".projectatlas/projectatlas.db");
15777        fs::create_dir_all(root.join(".projectatlas"))?;
15778        let mut store = AtlasStore::open_for_project(&database, &root)?;
15779        let project = store
15780            .project_instance_id()?
15781            .ok_or("fallback reuse project identity is missing")?;
15782        let path = "src/reused.rs";
15783        let nodes = vec![test_file_node(path, "rust")];
15784        let mut graph = identity_sibling_graph(path, GraphIdentityField::RelationTarget);
15785        graph.parser = ParserKind::Fallback;
15786        for symbol in &mut graph.symbols {
15787            symbol.parser = ParserKind::Fallback;
15788        }
15789        for relation in &mut graph.relations {
15790            relation.parser = ParserKind::Fallback;
15791        }
15792        let control = IndexWorkControl::new(IndexCancellation::new(), None);
15793        let policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
15794        let mut symbols = symbol_build_stage_for_graphs(vec![graph]);
15795        symbols.identity_admission = super::admit_symbol_build_stage(&mut symbols, &control)?;
15796        let persisted_graph = symbols
15797            .changes
15798            .iter()
15799            .find_map(|change| match change {
15800                SymbolProjectionChange::Parsed(parsed) => Some(parsed.graph.clone()),
15801                SymbolProjectionChange::Clear { .. } => None,
15802            })
15803            .ok_or("fallback sanitized graph is missing")?;
15804        let mut stage = stage_full_repository_graph(
15805            &store,
15806            &root,
15807            IndexGeneration::ZERO,
15808            &nodes,
15809            &policy,
15810            &symbols,
15811            &control,
15812        )?;
15813        require_eq(
15814            &stage.identity_rejections.len(),
15815            &1,
15816            "fallback initial typed rejection detail count",
15817        )?;
15818        let coverage = stage
15819            .coverage
15820            .iter_mut()
15821            .find(|coverage| {
15822                matches!(
15823                    coverage.scope(),
15824                    CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
15825                ) && coverage.relation().is_none()
15826            })
15827            .ok_or("fallback initial coverage row is missing")?;
15828        let scope = coverage.scope().clone();
15829        let state = coverage.state();
15830        let covered = coverage.covered();
15831        let generation = coverage.generation();
15832        let reason = coverage.reason().cloned();
15833        let reached_limit = coverage.reached_limit();
15834        *coverage = CoverageRecord::new(
15835            scope,
15836            None,
15837            state,
15838            covered,
15839            2,
15840            generation,
15841            reason,
15842            reached_limit,
15843        )?;
15844        // Simulate a capped publication: the persisted coverage count remains
15845        // authoritative even when no typed detail row survived the ceiling.
15846        stage.identity_rejections.clear();
15847        publish_full_staged_graph(
15848            &mut store,
15849            &nodes,
15850            &stage,
15851            &control,
15852            "fallback-reuse-initial",
15853        )?;
15854        store.replace_symbol_graph(&persisted_graph)?;
15855        let base_generation = store
15856            .index_publication()?
15857            .ok_or("fallback initial publication is missing")?
15858            .generation;
15859        let persisted_paths = vec![RepositoryNodePath::new(Path::new(path))?];
15860        require(
15861            store
15862                .repository_graph_identity_rejections(project, &persisted_paths, 16, None)?
15863                .is_empty(),
15864            "fallback capped publication retained an unexpected detail row",
15865        )?;
15866
15867        let reuse_control = IndexWorkControl::new(IndexCancellation::new(), None);
15868        let reuse_policy =
15869            RootScanPolicy::discover(&root, &ScanOptions::default(), &reuse_control)?;
15870        let reused = stage_full_repository_graph(
15871            &store,
15872            &root,
15873            base_generation,
15874            &nodes,
15875            &reuse_policy,
15876            &empty_symbol_build_stage(),
15877            &reuse_control,
15878        )?;
15879        require(
15880            reused.identity_rejections.is_empty(),
15881            "fallback reuse fabricated a detail row after cap",
15882        )?;
15883        let reused_coverage = reused
15884            .coverage
15885            .iter()
15886            .find(|coverage| {
15887                matches!(
15888                    coverage.scope(),
15889                    CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
15890                ) && coverage.relation().is_none()
15891            })
15892            .ok_or("fallback reused coverage row is missing")?;
15893        require_eq(
15894            &reused_coverage.omitted(),
15895            &2,
15896            "fallback reused persisted omission count",
15897        )?;
15898        require_eq(
15899            &reused_coverage.state(),
15900            &CoverageState::Partial,
15901            "fallback reused coverage state",
15902        )?;
15903        Ok(())
15904    }
15905
15906    #[test]
15907    fn incremental_reuse_hydrates_unchanged_affected_dependent_rejections()
15908    -> Result<(), Box<dyn Error>> {
15909        let temp = tempfile::tempdir()?;
15910        let root = temp
15911            .path()
15912            .join("incremental-reuse-dependent-identity-admission");
15913        fs::create_dir_all(root.join("src"))?;
15914        fs::write(root.join("src/provider.rs"), "pub fn changed() {}\n")?;
15915        fs::write(
15916            root.join("src/consumer.rs"),
15917            "pub fn caller() { changed(); }\n",
15918        )?;
15919        let database = root.join(".projectatlas/projectatlas.db");
15920        fs::create_dir_all(root.join(".projectatlas"))?;
15921        let mut store = AtlasStore::open_for_project(&database, &root)?;
15922        let project = store
15923            .project_instance_id()?
15924            .ok_or("incremental dependent project identity is missing")?;
15925        let provider_path = "src/provider.rs";
15926        let consumer_path = "src/consumer.rs";
15927        let nodes = vec![
15928            test_file_node(provider_path, "rust"),
15929            test_file_node(consumer_path, "rust"),
15930        ];
15931        let provider = extract_symbol_graph(provider_path, Some("rust"), "pub fn changed() {}\n");
15932        let mut consumer = extract_symbol_graph(
15933            consumer_path,
15934            Some("rust"),
15935            "pub fn caller() { changed(); }\n",
15936        );
15937        consumer.relations.push(SymbolRelation {
15938            path: consumer_path.to_string(),
15939            source_name: "caller".to_string(),
15940            target_name: "bad\0target".to_string(),
15941            kind: RelationKind::Calls,
15942            line: 3,
15943            context: "incremental unchanged dependent fixture".to_string(),
15944            parser: ParserKind::TreeSitter,
15945        });
15946        let mut initial_symbols = symbol_build_stage_for_graphs(vec![provider, consumer]);
15947        let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
15948        initial_symbols.identity_admission =
15949            super::admit_symbol_build_stage(&mut initial_symbols, &initial_control)?;
15950        let persisted_graphs = initial_symbols
15951            .changes
15952            .iter()
15953            .filter_map(|change| match change {
15954                SymbolProjectionChange::Parsed(parsed) => Some(parsed.graph.clone()),
15955                SymbolProjectionChange::Clear { .. } => None,
15956            })
15957            .collect::<Vec<_>>();
15958        let policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
15959        let initial_stage = stage_full_repository_graph(
15960            &store,
15961            &root,
15962            IndexGeneration::ZERO,
15963            &nodes,
15964            &policy,
15965            &initial_symbols,
15966            &initial_control,
15967        )?;
15968        require(
15969            initial_stage
15970                .identity_rejections
15971                .iter()
15972                .any(|rejection| rejection.path.as_str() == consumer_path),
15973            "initial dependent rejection was not admitted",
15974        )?;
15975        publish_full_staged_graph(
15976            &mut store,
15977            &nodes,
15978            &initial_stage,
15979            &initial_control,
15980            "incremental-dependent-initial",
15981        )?;
15982        for graph in &persisted_graphs {
15983            store.replace_symbol_graph(graph)?;
15984        }
15985        let base_generation = store
15986            .index_publication()?
15987            .ok_or("incremental dependent initial publication is missing")?
15988            .generation;
15989        let consumer_key = RepositoryNodePath::new(Path::new(consumer_path))?;
15990        let initial_rows = store.repository_graph_identity_rejections(
15991            project,
15992            std::slice::from_ref(&consumer_key),
15993            16,
15994            None,
15995        )?;
15996        require_eq(
15997            &initial_rows.len(),
15998            &1,
15999            "initial unchanged dependent rejection rows",
16000        )?;
16001
16002        let replacement =
16003            extract_symbol_graph(provider_path, Some("rust"), "pub fn replacement() {}\n");
16004        let replacement_symbols = symbol_build_stage_for_graphs(vec![replacement]);
16005        let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
16006        let incremental_policy =
16007            RootScanPolicy::discover(&root, &ScanOptions::default(), &incremental_control)?;
16008        let incremental_stage = stage_incremental_repository_graph(
16009            &store,
16010            &root,
16011            base_generation,
16012            &nodes,
16013            std::slice::from_ref(&provider_path.to_string()),
16014            &incremental_policy,
16015            &replacement_symbols,
16016            &incremental_control,
16017        )?;
16018        require(
16019            incremental_stage
16020                .identity_rejections
16021                .iter()
16022                .any(|rejection| rejection.path.as_str() == consumer_path),
16023            "incremental affected dependent did not hydrate rejection details",
16024        )?;
16025        let staged_coverage = incremental_stage
16026            .coverage
16027            .iter()
16028            .find(|coverage| {
16029                matches!(
16030                    coverage.scope(),
16031                    CoverageScope::Path { path } if path.as_str() == consumer_path
16032                ) && coverage.relation().is_none()
16033            })
16034            .ok_or("incremental affected dependent coverage is missing")?;
16035        require_eq(
16036            &staged_coverage.omitted(),
16037            &1,
16038            "incremental affected dependent omission count",
16039        )?;
16040        {
16041            let mut publication = store.begin_index_publication("incremental-dependent-reuse")?;
16042            incremental_stage.apply(&mut publication, &incremental_control)?;
16043            publication.complete()?;
16044        }
16045        require_eq(
16046            &store.repository_graph_identity_rejections(
16047                project,
16048                std::slice::from_ref(&consumer_key),
16049                16,
16050                None,
16051            )?,
16052            &initial_rows,
16053            "incremental affected dependent persisted rejection rows",
16054        )?;
16055        Ok(())
16056    }
16057
16058    #[test]
16059    fn incremental_reuse_reconciles_markdown_and_semantic_rejections_once()
16060    -> Result<(), Box<dyn Error>> {
16061        let temp = tempfile::tempdir()?;
16062        let root = fs::canonicalize(temp.path())?;
16063        let semantic_components = (0..20)
16064            .map(|_| "s".repeat(200))
16065            .collect::<Vec<_>>()
16066            .join("/");
16067        let semantic_path = format!("src/semantic/{semantic_components}/graph.rs");
16068        fs::create_dir_all(root.join("src"))?;
16069        fs::create_dir_all(root.join("docs"))?;
16070        fs::create_dir_all(
16071            root.join(&semantic_path)
16072                .parent()
16073                .ok_or("semantic fixture parent is missing")?,
16074        )?;
16075        let guide_source = "[invalid](<../src/worker.rs#bad\u{1}>)\n[worker](../src/worker.rs)\n";
16076        fs::write(root.join("src/worker.rs"), "pub fn worker() {}\n")?;
16077        fs::write(root.join(&semantic_path), "use crate::worker;\n")?;
16078        fs::write(root.join("docs/guide.md"), guide_source)?;
16079        let database = root.join(".projectatlas/projectatlas.db");
16080        fs::create_dir_all(root.join(".projectatlas"))?;
16081        let mut store = AtlasStore::open_for_project(&database, &root)?;
16082        let project = store
16083            .project_instance_id()?
16084            .ok_or("combined reuse project identity is missing")?;
16085        let mut nodes = vec![
16086            test_file_node("src/worker.rs", "rust"),
16087            test_file_node(&semantic_path, "rust"),
16088            test_file_node("docs/guide.md", "markdown"),
16089        ];
16090        let worker_graph =
16091            extract_symbol_graph("src/worker.rs", Some("rust"), "pub fn worker() {}\n");
16092        let semantic_graph = semantic_resolution_key_graph(&semantic_path, true);
16093        let guide_facts = projectatlas_symbols::extract_markdown_facts(guide_source);
16094        let guide_graph = guide_facts.symbol_graph("docs/guide.md", Some("markdown"));
16095        let guide_bytes = guide_source.as_bytes();
16096        nodes[2].size_bytes = Some(u64::try_from(guide_bytes.len())?);
16097        nodes[2].content_hash = Some(blake3::hash(guide_bytes).to_hex().to_string());
16098        let mut initial_symbols = symbol_build_stage_for_graphs(vec![worker_graph, semantic_graph]);
16099        initial_symbols.report.candidates = initial_symbols.report.candidates.saturating_add(1);
16100        initial_symbols.report.parsed = initial_symbols.report.parsed.saturating_add(1);
16101        initial_symbols.report.summaries = initial_symbols.report.summaries.saturating_add(1);
16102        initial_symbols
16103            .changes
16104            .push(SymbolProjectionChange::Parsed(SymbolParseSuccess {
16105                path: "docs/guide.md".to_string(),
16106                graph: guide_graph,
16107                markdown_facts: Some(Box::new(guide_facts)),
16108                source_parser: ParserKind::Structural,
16109                summary: "combined reuse Markdown fixture".to_string(),
16110                summary_is_structural: true,
16111                purpose_suggestion: None,
16112            }));
16113        let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
16114        initial_symbols.identity_admission =
16115            super::admit_symbol_build_stage(&mut initial_symbols, &initial_control)?;
16116        let scan_policy =
16117            RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
16118        let initial_stage = stage_full_repository_graph(
16119            &store,
16120            &root,
16121            IndexGeneration::ZERO,
16122            &nodes,
16123            &scan_policy,
16124            &initial_symbols,
16125            &initial_control,
16126        )?;
16127        require(
16128            initial_stage
16129                .identity_rejections
16130                .iter()
16131                .any(|row| row.path.as_str() == "docs/guide.md"),
16132            "combined reuse fixture did not retain Markdown rejection",
16133        )?;
16134        require(
16135            initial_stage.identity_rejections.iter().any(|row| {
16136                row.path.as_str() == semantic_path && row.field == GraphIdentityField::ResolutionKey
16137            }),
16138            "combined reuse fixture did not retain semantic rejection",
16139        )?;
16140        publish_full_staged_graph(
16141            &mut store,
16142            &nodes,
16143            &initial_stage,
16144            &initial_control,
16145            "combined-reuse-initial",
16146        )?;
16147        for change in &initial_symbols.changes {
16148            if let SymbolProjectionChange::Parsed(parsed) = change {
16149                store.replace_symbol_graph(&parsed.graph)?;
16150            }
16151        }
16152        let base_generation = store
16153            .index_publication()?
16154            .ok_or("combined reuse initial publication is missing")?
16155            .generation;
16156        let paths = nodes
16157            .iter()
16158            .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
16159            .collect::<Result<Vec<_>, _>>()?;
16160        let initial_rows = store.repository_graph_identity_rejections(
16161            project,
16162            &paths,
16163            GraphLimits::MAX_ROWS,
16164            None,
16165        )?;
16166        let replacement =
16167            extract_symbol_graph("src/worker.rs", Some("rust"), "pub fn replacement() {}\n");
16168        let replacement_symbols = symbol_build_stage_for_graphs(vec![replacement]);
16169        let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
16170        let incremental_stage = stage_incremental_repository_graph(
16171            &store,
16172            &root,
16173            base_generation,
16174            &nodes,
16175            &["src/worker.rs".to_string()],
16176            &scan_policy,
16177            &replacement_symbols,
16178            &incremental_control,
16179        )?;
16180        let guide_coverage = incremental_stage
16181            .coverage
16182            .iter()
16183            .find(|coverage| {
16184                matches!(
16185                    coverage.scope(),
16186                    CoverageScope::Path { path } if path.as_str() == "docs/guide.md"
16187                ) && coverage.relation().is_none()
16188            })
16189            .ok_or("combined reuse Markdown coverage is missing")?;
16190        let semantic_coverage = incremental_stage
16191            .coverage
16192            .iter()
16193            .find(|coverage| {
16194                matches!(
16195                    coverage.scope(),
16196                    CoverageScope::Path { path } if path.as_str() == semantic_path
16197                ) && coverage.relation().is_none()
16198            })
16199            .ok_or("combined reuse semantic coverage is missing")?;
16200        let initial_guide_omitted = initial_rows
16201            .iter()
16202            .filter(|row| row.path.as_str() == "docs/guide.md")
16203            .count();
16204        let initial_semantic_omitted = initial_rows
16205            .iter()
16206            .filter(|row| row.path.as_str() == semantic_path)
16207            .count();
16208        require_eq(
16209            &guide_coverage.omitted(),
16210            &u64::try_from(initial_guide_omitted)?,
16211            "reused Markdown omission count",
16212        )?;
16213        require_eq(
16214            &semantic_coverage.omitted(),
16215            &u64::try_from(initial_semantic_omitted)?,
16216            "reused semantic omission count",
16217        )?;
16218        require_eq(
16219            &incremental_stage
16220                .identity_rejections
16221                .iter()
16222                .filter(|row| row.path.as_str() == "docs/guide.md")
16223                .count(),
16224            &initial_guide_omitted,
16225            "reused Markdown detail count",
16226        )?;
16227        require_eq(
16228            &incremental_stage
16229                .identity_rejections
16230                .iter()
16231                .filter(|row| row.path.as_str() == semantic_path)
16232                .count(),
16233            &initial_semantic_omitted,
16234            "reused semantic detail count",
16235        )?;
16236        let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
16237        canceled.cancel();
16238        {
16239            let mut publication = store.begin_index_publication("combined-reuse-cancel")?;
16240            require(
16241                incremental_stage
16242                    .apply(&mut publication, &canceled)
16243                    .is_err(),
16244                "combined reuse cancellation reached publication",
16245            )?;
16246        }
16247        require_eq(
16248            &store
16249                .index_publication()?
16250                .map(|publication| publication.generation),
16251            &Some(base_generation),
16252            "combined reuse cancellation changed generation",
16253        )?;
16254        {
16255            let mut publication = store.begin_index_publication("combined-reuse-incremental")?;
16256            incremental_stage.apply(&mut publication, &incremental_control)?;
16257            publication.complete()?;
16258        }
16259        let persisted_rows = store.repository_graph_identity_rejections(
16260            project,
16261            &paths,
16262            GraphLimits::MAX_ROWS,
16263            None,
16264        )?;
16265        require_eq(
16266            &persisted_rows,
16267            &initial_rows,
16268            "reused Markdown and semantic persisted rows",
16269        )?;
16270        let retry_generation = store
16271            .index_publication()?
16272            .ok_or("combined reuse incremental publication is missing")?
16273            .generation;
16274        let retry_stage = stage_incremental_repository_graph(
16275            &store,
16276            &root,
16277            retry_generation,
16278            &nodes,
16279            &["src/worker.rs".to_string()],
16280            &scan_policy,
16281            &replacement_symbols,
16282            &incremental_control,
16283        )?;
16284        require_eq(
16285            &retry_stage
16286                .identity_rejections
16287                .iter()
16288                .filter(|row| row.path.as_str() == "docs/guide.md")
16289                .count(),
16290            &initial_guide_omitted,
16291            "reused Markdown deterministic retry detail count",
16292        )?;
16293        require_eq(
16294            &retry_stage
16295                .identity_rejections
16296                .iter()
16297                .filter(|row| row.path.as_str() == semantic_path)
16298                .count(),
16299            &initial_semantic_omitted,
16300            "reused semantic deterministic retry detail count",
16301        )?;
16302        Ok(())
16303    }
16304
16305    #[test]
16306    fn incremental_stage_repairs_and_removes_only_affected_rejection_paths()
16307    -> Result<(), Box<dyn Error>> {
16308        let temp = tempfile::tempdir()?;
16309        let root = temp.path().join("incremental-identity-admission");
16310        for directory in ["src", "tests", "docs"] {
16311            fs::create_dir_all(root.join(directory))?;
16312        }
16313        for path in ["src/one.rs", "tests/two.rs", "docs/three.rs"] {
16314            fs::write(
16315                root.join(path),
16316                "pub fn caller() { helper(); }\nfn helper() {}\n",
16317            )?;
16318        }
16319        let database = root.join(".projectatlas/projectatlas.db");
16320        fs::create_dir_all(root.join(".projectatlas"))?;
16321        let mut store = AtlasStore::open_for_project(&database, &root)?;
16322        let project = store
16323            .project_instance_id()?
16324            .ok_or("incremental admission project identity is missing")?;
16325        let initial_nodes = [
16326            test_file_node("src/one.rs", "rust"),
16327            test_file_node("tests/two.rs", "rust"),
16328            test_file_node("docs/three.rs", "rust"),
16329        ];
16330        let initial_graphs = vec![
16331            identity_sibling_graph("src/one.rs", GraphIdentityField::RelationTarget),
16332            identity_sibling_graph("tests/two.rs", GraphIdentityField::RelationTarget),
16333            identity_sibling_graph("docs/three.rs", GraphIdentityField::RelationTarget),
16334        ];
16335        let initial_symbols = symbol_build_stage_for_graphs(initial_graphs);
16336        let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
16337        let initial_policy =
16338            RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
16339        let initial_stage = stage_full_repository_graph(
16340            &store,
16341            &root,
16342            IndexGeneration::ZERO,
16343            &initial_nodes,
16344            &initial_policy,
16345            &initial_symbols,
16346            &initial_control,
16347        )?;
16348        publish_full_staged_graph(
16349            &mut store,
16350            &initial_nodes,
16351            &initial_stage,
16352            &initial_control,
16353            "incremental-identity-initial",
16354        )?;
16355        let base_generation = store
16356            .index_publication()?
16357            .ok_or("incremental initial publication is missing")?
16358            .generation;
16359        let src_path = RepositoryNodePath::new(Path::new("src/one.rs"))?;
16360        let removed_path = RepositoryNodePath::new(Path::new("tests/two.rs"))?;
16361        let retained_path = RepositoryNodePath::new(Path::new("docs/three.rs"))?;
16362        let initial_rows = store.repository_graph_identity_rejections(
16363            project,
16364            &[
16365                src_path.clone(),
16366                removed_path.clone(),
16367                retained_path.clone(),
16368            ],
16369            16,
16370            None,
16371        )?;
16372        require_eq(
16373            &initial_rows.len(),
16374            &3,
16375            "initial incremental rejection rows",
16376        )?;
16377
16378        let repaired_graph = extract_symbol_graph(
16379            "src/one.rs",
16380            Some("rust"),
16381            "pub fn caller() { helper(); }\nfn helper() {}\n",
16382        );
16383        let repaired_symbols = symbol_build_stage_for_graphs(vec![repaired_graph]);
16384        let expected_nodes = vec![initial_nodes[0].clone(), initial_nodes[2].clone()];
16385        let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
16386        let incremental_policy =
16387            RootScanPolicy::discover(&root, &ScanOptions::default(), &incremental_control)?;
16388        let incremental_stage = stage_incremental_repository_graph(
16389            &store,
16390            &root,
16391            base_generation,
16392            &expected_nodes,
16393            &["src/one.rs".to_string(), "tests/two.rs".to_string()],
16394            &incremental_policy,
16395            &repaired_symbols,
16396            &incremental_control,
16397        )?;
16398        {
16399            let mut publication = store.begin_index_publication("incremental-identity-repair")?;
16400            incremental_stage.apply(&mut publication, &incremental_control)?;
16401            publication.complete()?;
16402        }
16403        let repaired_rows = store.repository_graph_identity_rejections(
16404            project,
16405            &[
16406                src_path.clone(),
16407                removed_path.clone(),
16408                retained_path.clone(),
16409            ],
16410            16,
16411            None,
16412        )?;
16413        require(
16414            repaired_rows
16415                .iter()
16416                .all(|row| row.path != src_path && row.path != removed_path),
16417            "incremental repair/removal retained affected rejection detail",
16418        )?;
16419        require(
16420            repaired_rows.iter().any(|row| row.path == retained_path),
16421            "incremental repair removed unrelated rejection detail",
16422        )?;
16423        require_eq(
16424            &repaired_rows.len(),
16425            &1,
16426            "incremental unaffected rejection rows",
16427        )?;
16428        Ok(())
16429    }
16430
16431    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
16432        if condition {
16433            Ok(())
16434        } else {
16435            Err(io::Error::other(message).into())
16436        }
16437    }
16438
16439    fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
16440    where
16441        T: Debug + PartialEq,
16442    {
16443        if actual == expected {
16444            Ok(())
16445        } else {
16446            Err(io::Error::other(format!(
16447                "{label} mismatch: actual={actual:?}, expected={expected:?}"
16448            ))
16449            .into())
16450        }
16451    }
16452
16453    fn publish_full_staged_graph(
16454        store: &mut AtlasStore,
16455        nodes: &[Node],
16456        staged: &StagedRepositoryGraph,
16457        control: &IndexWorkControl,
16458        label: &str,
16459    ) -> Result<(), Box<dyn Error>> {
16460        let mut publication = store.begin_index_publication(label)?;
16461        publication.begin_scan_replacement()?;
16462        publication.upsert_scan_node_batch(nodes)?;
16463        publication.finish_scan_replacement()?;
16464        staged.apply(&mut publication, control)?;
16465        publication.complete()?;
16466        Ok(())
16467    }
16468
16469    fn symbol_build_stage_for_markdown(
16470        graph: SymbolGraph,
16471        markdown_facts: projectatlas_symbols::MarkdownFacts,
16472    ) -> SymbolBuildStage {
16473        let mut stage = empty_symbol_build_stage();
16474        stage.report.candidates = 1;
16475        stage.report.parsed = 1;
16476        stage.report.summaries = 1;
16477        stage.changes = vec![SymbolProjectionChange::Parsed(SymbolParseSuccess {
16478            path: graph.path.clone(),
16479            source_parser: ParserKind::Structural,
16480            graph,
16481            markdown_facts: Some(Box::new(markdown_facts)),
16482            summary: "markdown identity admission fixture".to_string(),
16483            summary_is_structural: true,
16484            purpose_suggestion: None,
16485        })];
16486        stage
16487    }
16488
16489    fn symbol_build_stage_for_graphs(graphs: Vec<SymbolGraph>) -> SymbolBuildStage {
16490        let mut stage = empty_symbol_build_stage();
16491        stage.report.candidates = graphs.len();
16492        stage.report.parsed = graphs.len();
16493        stage.report.symbols = graphs.iter().map(|graph| graph.symbols.len()).sum();
16494        stage.report.relations = graphs.iter().map(|graph| graph.relations.len()).sum();
16495        stage.changes = graphs
16496            .into_iter()
16497            .map(|graph| {
16498                let path = graph.path.clone();
16499                let source_parser = graph.parser;
16500                SymbolProjectionChange::Parsed(SymbolParseSuccess {
16501                    path,
16502                    graph,
16503                    markdown_facts: None,
16504                    source_parser,
16505                    summary: "identity admission fixture".to_string(),
16506                    summary_is_structural: false,
16507                    purpose_suggestion: None,
16508                })
16509            })
16510            .collect();
16511        stage
16512    }
16513
16514    fn identity_sibling_graph(path: &str, invalid_field: GraphIdentityField) -> SymbolGraph {
16515        let mut graph = extract_symbol_graph(
16516            path,
16517            Some("rust"),
16518            "pub fn caller() { helper(); }\nfn helper() {}\n",
16519        );
16520        graph.relations.push(SymbolRelation {
16521            path: path.to_string(),
16522            source_name: if invalid_field == GraphIdentityField::RelationSource {
16523                "bad\0source".to_string()
16524            } else {
16525                "caller".to_string()
16526            },
16527            target_name: if invalid_field == GraphIdentityField::RelationTarget {
16528                "bad\0target".to_string()
16529            } else {
16530                "helper".to_string()
16531            },
16532            kind: RelationKind::Calls,
16533            line: 3,
16534            context: "identity admission fixture".to_string(),
16535            parser: ParserKind::TreeSitter,
16536        });
16537        graph
16538    }
16539
16540    fn semantic_resolution_key_graph(path: &str, include_invalid: bool) -> SymbolGraph {
16541        let helper = if path.contains("sibling") {
16542            "sibling_helper"
16543        } else {
16544            "page_helper"
16545        };
16546        let parent = "LeakedIdentity".repeat(16);
16547        let invalid_methods = if include_invalid {
16548            format!(
16549                "pub struct {parent};\nimpl {parent} {{\n    pub fn first(&self) {{}}\n    pub fn second(&self) {{}}\n}}\n"
16550            )
16551        } else {
16552            String::new()
16553        };
16554        extract_symbol_graph(
16555            path,
16556            Some("rust"),
16557            &format!(
16558                "use crate::worker;\n{invalid_methods}pub fn caller() {{ {helper}(); worker(); }}\npub fn {helper}() {{}}\n"
16559            ),
16560        )
16561    }
16562
16563    fn empty_symbol_build_stage() -> SymbolBuildStage {
16564        SymbolBuildStage {
16565            report: SymbolBuildReport {
16566                candidates: 0,
16567                parsed: 0,
16568                unchanged: 0,
16569                too_large: 0,
16570                binary_or_non_utf8: 0,
16571                timed_out: 0,
16572                max_workers: 1,
16573                timeout_seconds: None,
16574                symbols: 0,
16575                relations: 0,
16576                summaries: 0,
16577                purpose_suggestions: 0,
16578            },
16579            changes: Vec::new(),
16580            retained_bytes: 0,
16581            identity_admission: GraphIdentityAdmission::default(),
16582        }
16583    }
16584
16585    fn function_graph(path: &str, symbol_count: usize) -> SymbolGraph {
16586        SymbolGraph {
16587            path: path.to_string(),
16588            language: Some("rust".to_string()),
16589            parser: ParserKind::TreeSitter,
16590            symbols: (0..symbol_count)
16591                .map(|index| CodeSymbol {
16592                    path: path.to_string(),
16593                    language: Some("rust".to_string()),
16594                    name: format!("symbol_{index}"),
16595                    kind: SymbolKind::Function,
16596                    signature: format!("fn symbol_{index}()"),
16597                    exported: index == 0,
16598                    documentation: None,
16599                    line_start: index + 1,
16600                    line_end: index + 1,
16601                    source_selector: None,
16602                    parent: None,
16603                    parser: ParserKind::TreeSitter,
16604                    detail: Some("function_item".to_string()),
16605                })
16606                .collect(),
16607            relations: vec![SymbolRelation {
16608                path: path.to_string(),
16609                source_name: "symbol_0".to_string(),
16610                target_name: "dependency".to_string(),
16611                kind: RelationKind::Calls,
16612                line: 1,
16613                context: "dependency()".to_string(),
16614                parser: ParserKind::TreeSitter,
16615            }],
16616        }
16617    }
16618
16619    fn package_graph(path: &str, name: &str) -> SymbolGraph {
16620        SymbolGraph {
16621            path: path.to_string(),
16622            language: Some("cargo-manifest".to_string()),
16623            parser: ParserKind::Manifest,
16624            symbols: vec![CodeSymbol {
16625                path: path.to_string(),
16626                language: Some("cargo-manifest".to_string()),
16627                name: name.to_string(),
16628                kind: SymbolKind::Package,
16629                signature: format!("name = \"{name}\""),
16630                exported: true,
16631                documentation: None,
16632                line_start: 1,
16633                line_end: 1,
16634                source_selector: None,
16635                parent: None,
16636                parser: ParserKind::Manifest,
16637                detail: Some("cargo-package".to_string()),
16638            }],
16639            relations: Vec::new(),
16640        }
16641    }
16642}