Skip to main content

projectatlas/runtime/
graph_projection.rs

1//! Normalize parser-owned symbol facts into one generation-bound repository graph.
2
3mod binding_resolution;
4mod cargo_roots;
5
6use cargo_roots::CargoRootCohortIndex;
7
8#[cfg(test)]
9use projectatlas_core::graph::QUALIFIED_SYMBOL_SCOPE_PREFIX;
10
11use super::{
12    CliError, INDEX_FRESHNESS_SAMPLE_LIMIT, IndexReadStatus, IndexRefreshReason,
13    IndexRefreshRequired, IndexRefreshScope, IndexWorkControl, IndexWorkFailure, IndexWorkResource,
14    IndexWorkStage, MAX_SYMBOL_FILE_BYTES, Node, NodeKind, SourceReadFailure, SymbolBuildStage,
15    SymbolProjectionChange, lossless_project_root_display, normalize_native_path_display,
16    read_source_bytes_controlled, source_changed_during_derivation,
17};
18use projectatlas_core::IndexGeneration;
19use projectatlas_core::graph::{
20    CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
21    CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
22    ExtendedRelationKind, ExternalSelector, GraphContractError, GraphEntity, GraphIdentityField,
23    GraphIdentityRejection, GraphIdentityRejectionReason, GraphIdentityText, GraphLimitKind,
24    GraphLimits, GraphRelationKind, LogicalRelation, LogicalRelationKey, PackageSelector,
25    ProjectInstanceId, RelationDependencyKey, RelationOccurrence, RelationResolution,
26    RepositoryFilePath, RepositoryNodePath, ResolutionKeyDomain, SourceSpan, SymbolSelector,
27};
28use projectatlas_core::language::{SemanticProviderOwner, SymbolParserOwner, language_capability};
29use projectatlas_core::symbols::{
30    CodeSymbol, MODULE_RELATION_SOURCE, ParserKind, RelationKind, SymbolGraph, SymbolKind,
31    SymbolRelation,
32};
33use projectatlas_db::{
34    AtlasStore, IndexPublicationGuard, RepositoryAffectedSourceFootprint,
35    RepositoryResolutionCandidate, RepositoryResolutionReadPage,
36};
37use projectatlas_fs::RootScanPolicy;
38#[cfg(test)]
39use projectatlas_fs::ScanOptions;
40use projectatlas_symbols::{
41    ConfiguredModuleResolution, DOCUMENT_COVERAGE_SYMBOL, MAX_RESOLUTION_KEYS_PER_FACT,
42    MarkdownFactCompleteness, MarkdownFactLimit, MarkdownFacts, ResolutionKeyProjection,
43    ResolutionProjectionContext, ResolutionProjectionError, ResolutionProjectionFact,
44    SourceCallBinding, SourceCallFact, SourceSemanticFacts, derive_resolution_keys_with_context,
45    extract_markdown_facts_controlled, extract_symbol_graph_with_semantics_controlled,
46    parse_import_references,
47};
48use std::borrow::{Borrow, Cow};
49use std::cell::RefCell;
50use std::cmp::Reverse;
51use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, btree_map::Entry};
52use std::fs::{self, File, OpenOptions};
53use std::num::NonZeroU32;
54use std::path::{Path, PathBuf};
55use std::sync::Arc;
56use tempfile::{Builder as TempDirBuilder, TempDir};
57
58/// Maximum canonical keys or distinct source paths admitted by one incremental closure.
59const MAX_INCREMENTAL_RESOLUTION_ITEMS: u32 = GraphLimits::MAX_ROWS;
60/// Maximum simultaneous parser/entity/key bytes before rows spill to typed `SQLite` staging.
61const MAX_IN_MEMORY_GRAPH_WORK_BYTES: u64 = 512 * 1_024 * 1_024;
62/// Maximum aggregate normalized graph rows admitted by one incremental closure.
63const MAX_INCREMENTAL_GRAPH_ROWS: u64 = GraphLimits::MAX_ROWS as u64;
64/// Maximum conservative graph bytes admitted before requesting a complete refresh.
65const MAX_INCREMENTAL_GRAPH_BYTES: u64 = MAX_IN_MEMORY_GRAPH_WORK_BYTES;
66/// Maximum persisted key bindings retained by one complete in-memory graph projection.
67const MAX_GRAPH_KEY_BINDINGS: u64 = 8_000_000;
68/// Conservative fixed bytes counted for each staged graph or binding row.
69const STAGED_GRAPH_ROW_BYTES: u64 = 128;
70/// Conservative bytes for one generated document relation and its retained indexes.
71///
72/// This covers the relation, occurrence, dependency, unresolved-reason map entry,
73/// final reason tuple, and duplicate-validation digest; candidate text is added
74/// separately from the already-counted Markdown fact bytes.
75const DOCUMENT_PROJECTION_ROW_BYTES: u64 = STAGED_GRAPH_ROW_BYTES * 8;
76/// Maximum graph-map rows processed between cooperative cancellation checks.
77const GRAPH_WORK_CHECK_INTERVAL: usize = 256;
78/// Borrowed entity references inserted per disposable staging call.
79const GRAPH_STAGE_ENTITY_BATCH_SIZE: usize = 1_024;
80/// Maximum normalized graph rows retained between disposable staging writes.
81const GRAPH_STAGE_ROW_BATCH_SIZE: usize = 8_192;
82/// Direct child prefix owned by disposable graph staging.
83const GRAPH_STAGE_DIRECTORY_PREFIX: &str = "graph-stage-";
84/// Stable cross-process lease protecting active staging and restart cleanup.
85const GRAPH_STAGE_LEASE_FILE_NAME: &str = "repository-graph-stage.lock";
86/// Typed disposable database inside every owned staging directory.
87const GRAPH_STAGE_DATABASE_FILE_NAME: &str = "projectatlas.db";
88/// Internal invariant failure for a staging owner already entering teardown.
89const GRAPH_STAGE_OWNER_UNAVAILABLE: &str = "repository graph staging owner is unavailable";
90/// Maximum persisted symbol-graph paths reconstructed between work checks.
91const PERSISTED_GRAPH_PATHS_PER_CHUNK: usize = 256;
92/// Stable fallback for a parser relation that omitted a usable display target.
93const UNKNOWN_REFERENCE: &str = "unknown-reference";
94/// Stable package manager for currently extracted Cargo manifest ownership.
95const CARGO_PACKAGE_MANAGER: &str = "cargo";
96/// Stable external namespace for the Rust standard-library distribution.
97const RUST_TOOLCHAIN_SYSTEM: &str = "rust-toolchain";
98/// Stable external namespace for explicitly qualified Node.js built-ins.
99const NODE_SYSTEM: &str = "node";
100/// Honest coverage diagnostic for fallback or structural relation extraction.
101const PARTIAL_COVERAGE_REASON: &str = "parser does not prove complete relationship coverage";
102/// External namespace for content-free configuration identities.
103const CONFIGURATION_SYSTEM: &str = "configuration";
104/// External namespace for static environment-variable identities.
105const ENVIRONMENT_SYSTEM: &str = "environment-variable";
106/// External namespace for content-free deployment platform identities.
107const DEPLOYMENT_SYSTEM: &str = "deployment-platform";
108/// Canonical provider owner for exact repository document targets.
109const DOCUMENT_PATH_PROVIDER: &str = "projectatlas-document";
110/// Canonical resolver language for repository-relative file and heading identities.
111const DOCUMENT_PATH_LANGUAGE: &str = "repository-path";
112/// Canonical resolver language for case-fold collision invalidation only.
113const DOCUMENT_CASEFOLD_LANGUAGE: &str = "repository-path-casefold";
114/// Honest content-free coverage diagnostic for bounded Markdown facts.
115const DOCUMENT_PARTIAL_COVERAGE_REASON: &str =
116    "markdown fact extraction reached a declared limit or unsupported structure";
117/// Maximum typed identity rejection details retained for one publication.
118const MAX_GRAPH_IDENTITY_REJECTIONS: usize = GraphLimits::MAX_ROWS as usize;
119/// Stable namespace for invalid symbol/package parser facts.
120const SYMBOL_FACT_INDEX_NAMESPACE: u64 = 1_u64 << 56;
121/// Stable namespace for invalid source-relation parser facts.
122const RELATION_FACT_INDEX_NAMESPACE: u64 = 2_u64 << 56;
123/// Stable namespace for invalid derived-relation parser facts.
124const DERIVED_RELATION_FACT_INDEX_NAMESPACE: u64 = 3_u64 << 56;
125/// Stable namespace for invalid Markdown selector parser facts.
126const MARKDOWN_FACT_INDEX_NAMESPACE: u64 = 4_u64 << 56;
127/// Stable namespace for invalid explicit export-slot identities.
128const EXPORT_FACT_INDEX_NAMESPACE: u64 = 5_u64 << 56;
129
130/// Build a deterministic internal identity without retaining parser text.
131fn parser_fact_index(namespace: u64, index: usize) -> u64 {
132    match u64::try_from(index) {
133        Ok(index) => namespace.saturating_add(index),
134        Err(_) => u64::MAX,
135    }
136}
137
138/// Relation observation paired with each symbol in one parser graph.
139struct PairedImportRelations {
140    /// Relation index for each paired import symbol.
141    by_symbol: Vec<Option<usize>>,
142    /// Test-only proof that pairing scans each bounded input row once.
143    #[cfg(test)]
144    work_items: usize,
145}
146
147/// Pair import symbols and relations once by source line and occurrence.
148///
149/// Tree-sitter emits an import declaration as both a symbol and an import
150/// relation. Pairing by source line and occurrence keeps those observations
151/// tied to one parser fact while retaining distinct same-line imports.
152fn paired_import_relations(
153    graph: &SymbolGraph,
154    control: &IndexWorkControl,
155) -> Result<PairedImportRelations, CliError> {
156    let mut relations_by_line = HashMap::<usize, (Vec<usize>, usize)>::new();
157    #[cfg(test)]
158    let mut work_items = 0_usize;
159    for (relation_index, relation) in graph.relations.iter().enumerate() {
160        check_graph_work(control, relation_index)?;
161        #[cfg(test)]
162        {
163            work_items = work_items.saturating_add(1);
164        }
165        if relation.kind == RelationKind::Imports {
166            relations_by_line
167                .entry(relation.line)
168                .or_default()
169                .0
170                .push(relation_index);
171        }
172    }
173    let mut by_symbol = vec![None; graph.symbols.len()];
174    for (symbol_index, symbol) in graph.symbols.iter().enumerate() {
175        check_graph_work(control, symbol_index)?;
176        #[cfg(test)]
177        {
178            work_items = work_items.saturating_add(1);
179        }
180        if symbol.kind != SymbolKind::Import {
181            continue;
182        }
183        let Some((relation_indices, next_ordinal)) = relations_by_line.get_mut(&symbol.line_start)
184        else {
185            continue;
186        };
187        by_symbol[symbol_index] = relation_indices.get(*next_ordinal).copied();
188        *next_ordinal = (*next_ordinal).saturating_add(1);
189    }
190    Ok(PairedImportRelations {
191        by_symbol,
192        #[cfg(test)]
193        work_items,
194    })
195}
196
197/// Reuse the relation ordinal for its paired import symbol observation.
198fn symbol_parser_fact_index(symbol_index: usize, paired_relation_index: Option<usize>) -> u64 {
199    paired_relation_index.map_or_else(
200        || parser_fact_index(SYMBOL_FACT_INDEX_NAMESPACE, symbol_index),
201        |relation_index| parser_fact_index(RELATION_FACT_INDEX_NAMESPACE, relation_index),
202    )
203}
204
205/// Bounded source span attached to one rejected parser identity.
206#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
207struct IdentitySpan {
208    /// One-based first line containing the parser fact.
209    start_line: usize,
210    /// Zero-based first column when the parser provides one.
211    start_column: usize,
212    /// One-based last line containing the parser fact.
213    end_line: usize,
214    /// Zero-based exclusive end column when the parser provides one.
215    end_column: usize,
216}
217
218/// Use one source span for paired import symbol and relation observations.
219fn symbol_identity_span(
220    graph: &SymbolGraph,
221    symbol_index: usize,
222    paired_relation_index: Option<usize>,
223) -> IdentitySpan {
224    let Some(symbol) = graph.symbols.get(symbol_index) else {
225        return IdentitySpan {
226            start_line: 1,
227            start_column: 0,
228            end_line: 1,
229            end_column: 0,
230        };
231    };
232    let start_line = symbol.line_start.max(1);
233    let end_line = symbol.line_end.max(symbol.line_start).max(1);
234    if let Some(relation_index) = paired_relation_index
235        && let Some(relation) = graph.relations.get(relation_index)
236    {
237        let line = relation.line.max(1);
238        return IdentitySpan {
239            start_line: line,
240            start_column: 0,
241            end_line: line,
242            end_column: 0,
243        };
244    }
245    IdentitySpan {
246        start_line,
247        start_column: 0,
248        end_line,
249        end_column: 0,
250    }
251}
252
253/// Internal identity for one observed parser fact, independent of retained
254/// detail rows. The detail vector is deliberately capped, but omission
255/// counts must still deduplicate repeated projections after that cap.
256#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
257struct IdentityFactKey {
258    /// Exact bounded source span of the parser fact.
259    span: IdentitySpan,
260    /// Stable local discriminator for the parser strategy.
261    parser: u8,
262    /// Admission owner for distinguishing parser identity from its derived
263    /// resolution-key outcome when both share one parser fact ordinal.
264    owner: u8,
265    /// Internal parser-fact ordinal.
266    fact_index: u64,
267}
268
269/// Exact membership key for one retained typed identity rejection detail.
270///
271/// The ordered key is kept beside the deterministic detail vector so replay
272/// checks do not scan every previously retained row.
273#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
274struct IdentityRejectionKey {
275    /// Repository-relative source path containing the rejected fact.
276    path: RepositoryNodePath,
277    /// Exact bounded source span of the rejected fact.
278    span: IdentitySpan,
279    /// Parser strategy that produced the rejected fact.
280    parser: u8,
281    /// Identity field that failed admission.
282    field: GraphIdentityField,
283    /// Stable rejection category.
284    reason: GraphIdentityRejectionReason,
285    /// Internal parser-fact ordinal.
286    fact_index: u64,
287}
288
289impl From<&GraphIdentityRejection> for IdentityRejectionKey {
290    fn from(rejection: &GraphIdentityRejection) -> Self {
291        Self {
292            path: rejection.path.clone(),
293            span: IdentitySpan {
294                start_line: rejection.span.start_line() as usize,
295                start_column: rejection.span.start_column() as usize,
296                end_line: rejection.span.end_line() as usize,
297                end_column: rejection.span.end_column() as usize,
298            },
299            parser: parser_fact_kind(rejection.parser),
300            field: rejection.field,
301            reason: rejection.reason,
302            fact_index: rejection.fact_index,
303        }
304    }
305}
306
307/// Map parser strategy to a compact local fact-key discriminator.
308fn parser_fact_kind(parser: ParserKind) -> u8 {
309    match parser {
310        ParserKind::TreeSitter => 0,
311        ParserKind::Manifest => 1,
312        ParserKind::Structural => 2,
313        ParserKind::Fallback => 3,
314    }
315}
316
317/// Map one rejection field to the admission owner that re-derives it.
318fn identity_fact_owner(fields: &[(GraphIdentityField, GraphIdentityRejectionReason)]) -> u8 {
319    u8::from(
320        fields
321            .iter()
322            .any(|(field, _reason)| *field == GraphIdentityField::ResolutionKey),
323    )
324}
325
326/// Return whether a persisted fact is replaced by the current derivation.
327fn is_rederived_identity_fact(fact: &IdentityFactKey) -> bool {
328    fact.owner == 1
329        || matches!(
330            fact.fact_index & !((1_u64 << 56) - 1),
331            DERIVED_RELATION_FACT_INDEX_NAMESPACE | MARKDOWN_FACT_INDEX_NAMESPACE
332        )
333}
334
335/// Conservative bytes for one observed fact and its path/count map entries.
336fn identity_fact_retained_bytes(
337    path: &str,
338    new_observed_path: bool,
339    new_count_path: bool,
340) -> Result<u64, CliError> {
341    let path_bytes = u64::try_from(path.len()).map_err(|error| {
342        CliError::InvalidInput(format!(
343            "identity rejection path length overflowed: {error}"
344        ))
345    })?;
346    let path_entries = u64::from(u8::from(new_observed_path)) + u64::from(u8::from(new_count_path));
347    let path_bytes = path_bytes
348        .checked_mul(path_entries)
349        .and_then(|bytes| bytes.checked_add(STAGED_GRAPH_ROW_BYTES.checked_mul(path_entries)?))
350        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))?;
351    path_bytes
352        .checked_add(STAGED_GRAPH_ROW_BYTES)
353        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
354}
355
356/// Conservative bytes for a count-only path retained after detail rows cap.
357fn identity_count_path_retained_bytes(path: &str) -> Result<u64, CliError> {
358    let path_bytes = u64::try_from(path.len()).map_err(|error| {
359        CliError::InvalidInput(format!(
360            "identity rejection path length overflowed: {error}"
361        ))
362    })?;
363    path_bytes
364        .checked_add(STAGED_GRAPH_ROW_BYTES)
365        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
366}
367
368/// Count one persisted identity fact set and its owning path entry.
369fn identity_fact_set_retained_bytes(
370    path: &str,
371    facts: &BTreeSet<IdentityFactKey>,
372) -> Result<u64, CliError> {
373    let path_bytes = u64::try_from(path.len()).map_err(|error| {
374        CliError::InvalidInput(format!(
375            "identity rejection path length overflowed: {error}"
376        ))
377    })?;
378    let fact_bytes = STAGED_GRAPH_ROW_BYTES
379        .checked_mul(u64::try_from(facts.len()).map_err(|error| {
380            CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
381        })?)
382        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))?;
383    path_bytes
384        .checked_add(STAGED_GRAPH_ROW_BYTES)
385        .and_then(|bytes| bytes.checked_add(fact_bytes))
386        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
387}
388
389/// Count one observed-fact map path and its retained facts.
390fn identity_observed_path_retained_bytes(path: &str, fact_count: usize) -> Result<u64, CliError> {
391    let path_bytes = u64::try_from(path.len()).map_err(|error| {
392        CliError::InvalidInput(format!(
393            "identity rejection path length overflowed: {error}"
394        ))
395    })?;
396    let fact_bytes = STAGED_GRAPH_ROW_BYTES
397        .checked_mul(u64::try_from(fact_count).map_err(|error| {
398            CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
399        })?)
400        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))?;
401    path_bytes
402        .checked_add(STAGED_GRAPH_ROW_BYTES)
403        .and_then(|bytes| bytes.checked_add(fact_bytes))
404        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
405}
406
407/// Count the bounded map/set entries retained for observed identity facts.
408fn identity_maps_retained_bytes(
409    observed_facts: &BTreeMap<String, BTreeSet<IdentityFactKey>>,
410    rejected_facts_by_path: &BTreeMap<String, u64>,
411) -> Result<u64, CliError> {
412    let mut retained = 0_u64;
413    for (path, facts) in observed_facts {
414        retained = retained
415            .checked_add(identity_observed_path_retained_bytes(path, facts.len())?)
416            .ok_or_else(|| {
417                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
418            })?;
419    }
420    for path in rejected_facts_by_path.keys() {
421        retained = retained
422            .checked_add(identity_count_path_retained_bytes(path)?)
423            .ok_or_else(|| {
424                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
425            })?;
426    }
427    Ok(retained)
428}
429
430/// Count one retained rejection-membership key and its owned path text.
431fn identity_rejection_key_retained_bytes(path: &str) -> Result<u64, CliError> {
432    let path_bytes = u64::try_from(path.len()).map_err(|error| {
433        CliError::InvalidInput(format!(
434            "identity rejection path length overflowed: {error}"
435        ))
436    })?;
437    path_bytes
438        .checked_add(STAGED_GRAPH_ROW_BYTES)
439        .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
440}
441
442/// Count the bounded keyed membership state for retained rejection details.
443fn identity_rejection_keys_retained_bytes(
444    keys: &BTreeSet<IdentityRejectionKey>,
445) -> Result<u64, CliError> {
446    keys.iter().try_fold(0_u64, |retained, key| {
447        retained
448            .checked_add(identity_rejection_key_retained_bytes(key.path.as_str())?)
449            .ok_or_else(|| {
450                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
451            })
452    })
453}
454
455/// Count the path markers retained for publication-time detail evictions.
456fn identity_rejection_drop_paths_retained_bytes(paths: &BTreeSet<String>) -> Result<u64, CliError> {
457    paths.iter().try_fold(0_u64, |retained, path| {
458        retained
459            .checked_add(identity_count_path_retained_bytes(path)?)
460            .ok_or_else(|| {
461                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
462            })
463    })
464}
465
466/// Bound map/path ownership and ordinal vector spare capacity before allocation.
467fn identity_relation_indices_bytes(path: &str, count: usize) -> u64 {
468    STAGED_GRAPH_ROW_BYTES
469        .saturating_add(path.len() as u64)
470        .saturating_add((count as u64).saturating_mul((4 * std::mem::size_of::<usize>()) as u64))
471}
472
473/// Return the exact incremental identity-state total owned by one report.
474fn identity_admission_retained_bytes(report: &GraphIdentityAdmission) -> u64 {
475    report.observed_fact_bytes
476}
477
478/// Return the typed graph-work limit reached before retaining identity state.
479fn identity_fact_budget_failure_for_limit(limit: u64, retained_bytes: u64) -> CliError {
480    IndexWorkFailure::resource_limit(
481        IndexWorkStage::SymbolParsing,
482        IndexWorkResource::OutputBytes,
483        limit,
484        retained_bytes,
485    )
486    .into()
487}
488
489/// Return the production graph-work limit reached before retaining identity state.
490fn identity_fact_budget_failure(retained_bytes: u64) -> CliError {
491    identity_fact_budget_failure_for_limit(MAX_IN_MEMORY_GRAPH_WORK_BYTES, retained_bytes)
492}
493
494/// Check complete retained identity state against an injected or production limit.
495fn checked_identity_admission_budget(
496    report: &GraphIdentityAdmission,
497    control: &IndexWorkControl,
498    limit: u64,
499) -> Result<u64, CliError> {
500    control.check(IndexWorkStage::SymbolParsing)?;
501    let retained_bytes = identity_admission_retained_bytes(report);
502    if retained_bytes > limit {
503        return Err(identity_fact_budget_failure_for_limit(
504            limit,
505            retained_bytes,
506        ));
507    }
508    Ok(retained_bytes)
509}
510
511/// Bounded admission report shared by entity, relation, and key projection.
512#[derive(Clone, Debug, Default)]
513pub(super) struct GraphIdentityAdmission {
514    /// Operation remainder while successful keys coexist with new rejection facts.
515    retained_limit: Option<u64>,
516    /// Whether the parser-output graphs have passed the shared source boundary.
517    source_admitted: bool,
518    /// Retained typed details, capped independently from the count.
519    rejections: Vec<GraphIdentityRejection>,
520    /// Exact membership for the retained typed detail vector.
521    rejection_keys: BTreeSet<IdentityRejectionKey>,
522    /// Number of parser facts rejected per source path.
523    rejected_facts_by_path: BTreeMap<String, u64>,
524    /// Every observed rejected parser fact, retained independently of detail
525    /// rows so the global detail ceiling cannot make replays overcount.
526    observed_facts: BTreeMap<String, BTreeSet<IdentityFactKey>>,
527    /// Exact bytes retained by every identity map, detail key, and
528    /// reconciliation entry, charged to the graph-work budget incrementally.
529    observed_fact_bytes: u64,
530    /// Persisted typed fact identities retained separately from current
531    /// observations so removed outcomes can be subtracted exactly.
532    reused_rejection_facts: BTreeMap<String, BTreeSet<IdentityFactKey>>,
533    /// Original parser relation ordinal for each admitted relation after
534    /// source-identity filtering. This remains private because it is only
535    /// needed to keep derived rejection facts tied to parser observations.
536    relation_fact_indices: BTreeMap<String, Vec<usize>>,
537    /// Resolution keys derived once at admission and carried into projection.
538    resolution_projections: BTreeMap<String, ResolutionKeyProjection>,
539    /// Omission counts retained from the current generation for safely reused
540    /// graphs and reconciled with the current pass after it regenerates any
541    /// key, Markdown, or derived details it owns.
542    reused_rejection_counts: BTreeMap<String, u64>,
543    /// Parser-baseline omission retained for reused structural/fallback
544    /// graphs. This stays separate from identity omissions because parser
545    /// coverage counts only relations when no identity fact was rejected.
546    reused_parser_rejection_counts: BTreeMap<String, u64>,
547    /// Number of persisted typed facts retained for each reused path. This is
548    /// the baseline for exact post-rederivation count reconciliation.
549    reused_rejection_detail_counts: BTreeMap<String, u64>,
550    /// Reused paths whose persisted detail ceiling leaves old identities
551    /// unknowable. These paths require a complete source reparse before a
552    /// replacement publication can be exact.
553    reused_rejection_details_incomplete: BTreeSet<String>,
554    /// Paths where a distinct typed rejection detail was evicted by the
555    /// publication-wide detail ceiling.
556    rejection_details_dropped_by_path: BTreeSet<String>,
557    /// Test-only proof of one derivation per source path and generation.
558    #[cfg(test)]
559    resolution_derivations: BTreeMap<(String, IndexGeneration), usize>,
560    /// Test-only count of source rows inspected while pairing import facts.
561    #[cfg(test)]
562    paired_import_pairing_work: usize,
563}
564
565impl GraphIdentityAdmission {
566    /// Owned source identity details included in symbol-stage publication accounting.
567    pub(super) const fn retained_bytes(&self) -> u64 {
568        self.observed_fact_bytes
569    }
570
571    /// Record one parser fact and every failed identity field without retaining raw input.
572    fn record(
573        &mut self,
574        path: &str,
575        span: IdentitySpan,
576        parser: ParserKind,
577        fact_index: u64,
578        fields: &[(GraphIdentityField, GraphIdentityRejectionReason)],
579        control: &IndexWorkControl,
580    ) -> Result<(), CliError> {
581        control.check(IndexWorkStage::SymbolParsing)?;
582        let path = RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract)?;
583        let fact_span = span;
584        let span = SourceSpan::new(
585            u32::try_from(span.start_line).map_err(|error| {
586                CliError::InvalidInput(format!("identity rejection start line overflowed: {error}"))
587            })?,
588            u32::try_from(span.start_column).map_err(|error| {
589                CliError::InvalidInput(format!(
590                    "identity rejection start column overflowed: {error}"
591                ))
592            })?,
593            u32::try_from(span.end_line).map_err(|error| {
594                CliError::InvalidInput(format!("identity rejection end line overflowed: {error}"))
595            })?,
596            u32::try_from(span.end_column).map_err(|error| {
597                CliError::InvalidInput(format!("identity rejection end column overflowed: {error}"))
598            })?,
599        )
600        .map_err(invalid_graph_contract)?;
601        let fact_key = IdentityFactKey {
602            span: fact_span,
603            parser: parser_fact_kind(parser),
604            owner: identity_fact_owner(fields),
605            fact_index,
606        };
607        let path_key = path.as_str().to_owned();
608        let observed_path_is_new = !self.observed_facts.contains_key(&path_key);
609        let count_path_is_new = !self.rejected_facts_by_path.contains_key(&path_key);
610        let fact_is_new = self
611            .observed_facts
612            .get(&path_key)
613            .is_none_or(|facts| !facts.contains(&fact_key));
614        let mut new_rejection_keys = Vec::new();
615        let mut dropped_rejection_detail = false;
616        for (field, reason) in fields {
617            let key = IdentityRejectionKey {
618                path: path.clone(),
619                span: fact_span,
620                parser: parser_fact_kind(parser),
621                field: *field,
622                reason: *reason,
623                fact_index,
624            };
625            if !self.rejection_keys.contains(&key)
626                && !new_rejection_keys.iter().any(|existing| existing == &key)
627            {
628                if self.rejections.len() + new_rejection_keys.len() >= MAX_GRAPH_IDENTITY_REJECTIONS
629                {
630                    dropped_rejection_detail = true;
631                } else {
632                    new_rejection_keys.push(key);
633                }
634            }
635        }
636        let dropped_path_is_new = dropped_rejection_detail
637            && !self
638                .rejection_details_dropped_by_path
639                .contains(path.as_str());
640        let mut additional_bytes = if fact_is_new {
641            identity_fact_retained_bytes(path.as_str(), observed_path_is_new, count_path_is_new)?
642        } else {
643            0
644        };
645        for key in &new_rejection_keys {
646            additional_bytes = additional_bytes
647                .checked_add(identity_rejection_key_retained_bytes(key.path.as_str())?)
648                .ok_or_else(|| {
649                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
650                })?;
651        }
652        if dropped_path_is_new {
653            additional_bytes = additional_bytes
654                .checked_add(identity_count_path_retained_bytes(path.as_str())?)
655                .ok_or_else(|| {
656                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
657                })?;
658        }
659        self.reserve_identity_bytes(additional_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
660        if dropped_rejection_detail {
661            self.rejection_details_dropped_by_path
662                .insert(path.as_str().to_owned());
663        }
664        if fact_is_new {
665            self.observed_facts
666                .entry(path_key.clone())
667                .or_default()
668                .insert(fact_key);
669            let count = self.rejected_facts_by_path.entry(path_key).or_default();
670            *count = count.checked_add(1).ok_or_else(|| {
671                CliError::InvalidInput("identity rejection count overflowed".to_string())
672            })?;
673        }
674        for key in new_rejection_keys {
675            let field = key.field;
676            let reason = key.reason;
677            self.rejection_keys.insert(key);
678            self.rejections.push(GraphIdentityRejection {
679                path: path.clone(),
680                span,
681                parser,
682                field,
683                reason,
684                fact_index,
685            });
686        }
687        Ok(())
688    }
689
690    /// Reserve one newly owned identity-state entry before retaining it.
691    fn reserve_identity_bytes(
692        &mut self,
693        additional_bytes: u64,
694        control: &IndexWorkControl,
695        limit: u64,
696    ) -> Result<(), CliError> {
697        control.check(IndexWorkStage::SymbolParsing)?;
698        let limit = self
699            .retained_limit
700            .map_or(limit, |remaining| limit.min(remaining));
701        let retained_bytes = self
702            .observed_fact_bytes
703            .checked_add(additional_bytes)
704            .ok_or_else(|| {
705                identity_fact_budget_failure_for_limit(limit, self.observed_fact_bytes)
706            })?;
707        if retained_bytes > limit {
708            return Err(identity_fact_budget_failure_for_limit(
709                limit,
710                retained_bytes,
711            ));
712        }
713        self.observed_fact_bytes = retained_bytes;
714        Ok(())
715    }
716
717    /// Adjust the cached identity-state total when a merge replaces entries.
718    fn adjust_identity_bytes(&mut self, before: u64, after: u64) -> Result<(), CliError> {
719        if let Some(limit) = self.retained_limit {
720            enforce_resolution_registry_budget_with_limit(
721                self.observed_fact_bytes
722                    .saturating_add(after)
723                    .saturating_sub(before),
724                limit,
725            )?;
726        }
727        if after >= before {
728            self.observed_fact_bytes = self
729                .observed_fact_bytes
730                .checked_add(after - before)
731                .ok_or_else(|| {
732                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
733                })?;
734        } else {
735            self.observed_fact_bytes = self
736                .observed_fact_bytes
737                .checked_sub(before - after)
738                .ok_or_else(|| {
739                    CliError::InvalidInput("identity rejection bytes underflowed".to_string())
740                })?;
741        }
742        Ok(())
743    }
744
745    /// Merge one incoming observed-fact/count path without recounting the
746    /// existing report.
747    fn merge_observed_identity_path(
748        &mut self,
749        path: &str,
750        incoming_facts: Option<&BTreeSet<IdentityFactKey>>,
751        incoming_total: u64,
752        control: &IndexWorkControl,
753    ) -> Result<(), CliError> {
754        control.check(IndexWorkStage::SymbolParsing)?;
755        let existing_total = self.rejected_facts_by_path.get(path).copied().unwrap_or(0);
756        let existing_observed = self.observed_facts.get(path).map_or(0, BTreeSet::len);
757        let existing_unobserved = existing_total
758            .checked_sub(u64::try_from(existing_observed).map_err(|error| {
759                CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
760            })?)
761            .ok_or_else(|| {
762                CliError::InvalidInput(
763                    "observed identity facts exceeded rejection count".to_string(),
764                )
765            })?;
766        let incoming_observed = incoming_facts.map_or(0, BTreeSet::len);
767        let incoming_unobserved = incoming_total
768            .checked_sub(u64::try_from(incoming_observed).map_err(|error| {
769                CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
770            })?)
771            .ok_or_else(|| {
772                CliError::InvalidInput(
773                    "observed identity facts exceeded rejection count".to_string(),
774                )
775            })?;
776        let new_observed = incoming_facts.map_or(0, |facts| {
777            facts
778                .iter()
779                .filter(|fact| {
780                    self.observed_facts
781                        .get(path)
782                        .is_none_or(|existing| !existing.contains(*fact))
783                })
784                .count()
785        });
786        let observed = existing_observed
787            .checked_add(new_observed)
788            .ok_or_else(|| CliError::InvalidInput("identity fact count overflowed".to_string()))?;
789        let total = u64::try_from(observed)
790            .map_err(|error| {
791                CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
792            })?
793            .checked_add(existing_unobserved)
794            .and_then(|count| count.checked_add(incoming_unobserved))
795            .ok_or_else(|| {
796                CliError::InvalidInput("identity rejection count overflowed".to_string())
797            })?;
798
799        let mut before_bytes = 0_u64;
800        if self.observed_facts.contains_key(path) {
801            before_bytes = before_bytes
802                .checked_add(identity_observed_path_retained_bytes(
803                    path,
804                    existing_observed,
805                )?)
806                .ok_or_else(|| {
807                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
808                })?;
809        }
810        if self.rejected_facts_by_path.contains_key(path) {
811            before_bytes = before_bytes
812                .checked_add(identity_count_path_retained_bytes(path)?)
813                .ok_or_else(|| {
814                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
815                })?;
816        }
817        let mut after_bytes = 0_u64;
818        if observed > 0 {
819            after_bytes = after_bytes
820                .checked_add(identity_observed_path_retained_bytes(path, observed)?)
821                .ok_or_else(|| {
822                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
823                })?;
824        }
825        if total > 0 {
826            after_bytes = after_bytes
827                .checked_add(identity_count_path_retained_bytes(path)?)
828                .ok_or_else(|| {
829                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
830                })?;
831        }
832        self.adjust_identity_bytes(before_bytes, after_bytes)?;
833
834        if observed == 0 {
835            self.observed_facts.remove(path);
836        } else if let Some(facts) = incoming_facts
837            && !facts.is_empty()
838        {
839            self.observed_facts
840                .entry(path.to_string())
841                .or_default()
842                .extend(facts.iter().cloned());
843        }
844        if total == 0 {
845            self.rejected_facts_by_path.remove(path);
846            self.observed_facts.remove(path);
847        } else {
848            self.rejected_facts_by_path.insert(path.to_string(), total);
849        }
850        Ok(())
851    }
852
853    /// Reserve a per-path reconciliation entry before retaining its path key.
854    fn reserve_reused_path_bytes(
855        &mut self,
856        path: &str,
857        already_retained: bool,
858        control: &IndexWorkControl,
859        limit: u64,
860    ) -> Result<(), CliError> {
861        control.check(IndexWorkStage::SymbolParsing)?;
862        if !already_retained {
863            self.reserve_identity_bytes(identity_count_path_retained_bytes(path)?, control, limit)?;
864        }
865        Ok(())
866    }
867
868    /// Add rejected-fact count that exceeded the bounded typed-detail rows.
869    fn record_rejected_fact_count(
870        &mut self,
871        path: &str,
872        count: usize,
873        control: &IndexWorkControl,
874    ) -> Result<(), CliError> {
875        if count == 0 {
876            control.check(IndexWorkStage::SymbolParsing)?;
877            return Ok(());
878        }
879        control.check(IndexWorkStage::SymbolParsing)?;
880        let path = RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract)?;
881        let count = u64::try_from(count).map_err(|error| {
882            CliError::InvalidInput(format!("identity rejection count overflowed: {error}"))
883        })?;
884        let path = path.as_str().to_owned();
885        if !self.rejected_facts_by_path.contains_key(&path) {
886            self.reserve_reused_path_bytes(&path, false, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
887        }
888        let entry = self.rejected_facts_by_path.entry(path).or_default();
889        *entry = entry.checked_add(count).ok_or_else(|| {
890            CliError::InvalidInput("identity rejection count overflowed".to_string())
891        })?;
892        Ok(())
893    }
894
895    /// Merge one report while retaining the global detail bound.
896    fn merge(&mut self, other: Self, control: &IndexWorkControl) -> Result<(), CliError> {
897        control.check(IndexWorkStage::SymbolParsing)?;
898        let peak_retained_bytes = self
899            .observed_fact_bytes
900            .checked_add(other.observed_fact_bytes)
901            .ok_or_else(|| identity_fact_budget_failure(self.observed_fact_bytes))?;
902        if let Some(limit) = self.retained_limit {
903            // Incoming maps remain live while shared paths are copied into the
904            // target. Admit one additional complete incoming envelope before any
905            // clone; unique-path transfers stay below this conservative peak.
906            enforce_resolution_registry_budget_with_limit(
907                peak_retained_bytes.saturating_add(other.observed_fact_bytes),
908                limit,
909            )?;
910        }
911        if peak_retained_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
912            return Err(identity_fact_budget_failure(peak_retained_bytes));
913        }
914        #[cfg(test)]
915        {
916            self.paired_import_pairing_work = self
917                .paired_import_pairing_work
918                .checked_add(other.paired_import_pairing_work)
919                .ok_or_else(|| {
920                    CliError::InvalidInput("paired import work count overflowed".to_string())
921                })?;
922        }
923        self.source_admitted |= other.source_admitted;
924        if other
925            .resolution_projections
926            .keys()
927            .any(|path| self.resolution_projections.contains_key(path))
928        {
929            return Err(CliError::InvalidInput(
930                "duplicate resolution projection admission".to_string(),
931            ));
932        }
933        let other_observed_facts = other.observed_facts;
934        let other_rejected_facts_by_path = other.rejected_facts_by_path;
935        let other_reused_rejection_facts = other.reused_rejection_facts;
936        let other_rejection_details_dropped_by_path = other.rejection_details_dropped_by_path;
937        for (path, incoming_total) in &other_rejected_facts_by_path {
938            self.merge_observed_identity_path(
939                path,
940                other_observed_facts.get(path),
941                *incoming_total,
942                control,
943            )?;
944        }
945        for (path, facts) in &other_observed_facts {
946            if !other_rejected_facts_by_path.contains_key(path) {
947                self.merge_observed_identity_path(path, Some(facts), 0, control)?;
948            }
949        }
950        for (path, facts) in other_reused_rejection_facts {
951            control.check(IndexWorkStage::SymbolParsing)?;
952            let existing_facts = self.reused_rejection_facts.get(&path);
953            let existing_count = existing_facts.map_or(0, BTreeSet::len);
954            let new_count = facts
955                .iter()
956                .filter(|fact| existing_facts.is_none_or(|existing| !existing.contains(*fact)))
957                .count();
958            let after_count = existing_count.checked_add(new_count).ok_or_else(|| {
959                CliError::InvalidInput("identity fact count overflowed".to_string())
960            })?;
961            let before_bytes = existing_facts.map_or(Ok(0), |facts| {
962                identity_fact_set_retained_bytes(&path, facts)
963            })?;
964            let after_bytes = if after_count == 0 {
965                0
966            } else {
967                identity_observed_path_retained_bytes(&path, after_count)?
968            };
969            self.adjust_identity_bytes(before_bytes, after_bytes)?;
970            if after_count == 0 {
971                self.reused_rejection_facts.remove(&path);
972            } else {
973                self.reused_rejection_facts
974                    .entry(path)
975                    .or_default()
976                    .extend(facts);
977            }
978        }
979        for (path, indices) in other.relation_fact_indices {
980            control.check(IndexWorkStage::SymbolParsing)?;
981            if let Some(existing) = self.relation_fact_indices.get(&path) {
982                if existing != &indices {
983                    return Err(CliError::InvalidInput(
984                        "conflicting relation fact admission".to_string(),
985                    ));
986                }
987            } else {
988                self.reserve_identity_bytes(
989                    identity_relation_indices_bytes(&path, indices.len()),
990                    control,
991                    MAX_IN_MEMORY_GRAPH_WORK_BYTES,
992                )?;
993                self.relation_fact_indices.insert(path, indices);
994            }
995        }
996        control.check(IndexWorkStage::SymbolParsing)?;
997        let rejection_key_bytes = extend_bounded_identity_rejections_with_drop_paths_limit(
998            &mut self.rejections,
999            &mut self.rejection_keys,
1000            other.rejections,
1001            &mut self.rejection_details_dropped_by_path,
1002            self.retained_limit
1003                .unwrap_or(MAX_IN_MEMORY_GRAPH_WORK_BYTES)
1004                .min(MAX_IN_MEMORY_GRAPH_WORK_BYTES)
1005                .saturating_sub(self.observed_fact_bytes),
1006        )?;
1007        self.observed_fact_bytes = self
1008            .observed_fact_bytes
1009            .checked_add(rejection_key_bytes)
1010            .ok_or_else(|| {
1011                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1012            })?;
1013        for path in other_rejection_details_dropped_by_path {
1014            control.check(IndexWorkStage::SymbolParsing)?;
1015            if self.rejection_details_dropped_by_path.insert(path.clone()) {
1016                self.reserve_identity_bytes(
1017                    identity_count_path_retained_bytes(&path)?,
1018                    control,
1019                    MAX_IN_MEMORY_GRAPH_WORK_BYTES,
1020                )?;
1021            }
1022        }
1023        self.resolution_projections
1024            .extend(other.resolution_projections);
1025        for (path, count) in other.reused_rejection_counts {
1026            control.check(IndexWorkStage::SymbolParsing)?;
1027            let new_path = !self.reused_rejection_counts.contains_key(&path);
1028            let path_bytes = if new_path {
1029                identity_count_path_retained_bytes(&path)?
1030            } else {
1031                0
1032            };
1033            if self
1034                .reused_rejection_counts
1035                .insert(path.clone(), count)
1036                .is_some()
1037            {
1038                return Err(CliError::InvalidInput(
1039                    "duplicate reused identity rejection count".to_string(),
1040                ));
1041            }
1042            if new_path {
1043                self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1044            }
1045        }
1046        for (path, count) in other.reused_parser_rejection_counts {
1047            control.check(IndexWorkStage::SymbolParsing)?;
1048            let new_path = !self.reused_parser_rejection_counts.contains_key(&path);
1049            let path_bytes = if new_path {
1050                identity_count_path_retained_bytes(&path)?
1051            } else {
1052                0
1053            };
1054            if self
1055                .reused_parser_rejection_counts
1056                .insert(path.clone(), count)
1057                .is_some()
1058            {
1059                return Err(CliError::InvalidInput(
1060                    "duplicate reused parser rejection count".to_string(),
1061                ));
1062            }
1063            if new_path {
1064                self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1065            }
1066        }
1067        for (path, count) in other.reused_rejection_detail_counts {
1068            control.check(IndexWorkStage::SymbolParsing)?;
1069            let new_path = !self.reused_rejection_detail_counts.contains_key(&path);
1070            let path_bytes = if new_path {
1071                identity_count_path_retained_bytes(&path)?
1072            } else {
1073                0
1074            };
1075            if self
1076                .reused_rejection_detail_counts
1077                .insert(path.clone(), count)
1078                .is_some()
1079            {
1080                return Err(CliError::InvalidInput(
1081                    "duplicate reused identity rejection detail count".to_string(),
1082                ));
1083            }
1084            if new_path {
1085                self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1086            }
1087        }
1088        control.check(IndexWorkStage::SymbolParsing)?;
1089        for path in other.reused_rejection_details_incomplete {
1090            let path_bytes = identity_count_path_retained_bytes(&path)?;
1091            if self.reused_rejection_details_incomplete.insert(path) {
1092                self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1093            }
1094        }
1095        if self.observed_fact_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
1096            return Err(identity_fact_budget_failure(self.observed_fact_bytes));
1097        }
1098        #[cfg(test)]
1099        for (key, count) in other.resolution_derivations {
1100            let entry = self.resolution_derivations.entry(key).or_default();
1101            *entry = entry.saturating_add(count);
1102        }
1103        Ok(())
1104    }
1105
1106    /// Return the number of rejected parser facts for one path.
1107    fn rejected_facts_for(&self, path: &str) -> u64 {
1108        self.rejected_facts_by_path.get(path).copied().unwrap_or(0)
1109    }
1110
1111    /// Return the source-admission details owned by one publication path set.
1112    /// Admit a filtered report clone while its source report remains alive.
1113    fn for_paths_with_limit(&self, paths: &BTreeSet<String>, limit: u64) -> Result<Self, CliError> {
1114        // The complete source report is an already-maintained conservative bound
1115        // for any selection; no speculative clone precedes this admission.
1116        enforce_resolution_registry_budget_with_limit(self.observed_fact_bytes, limit)?;
1117        let rejected_facts_by_path = self
1118            .rejected_facts_by_path
1119            .iter()
1120            .filter(|(path, _count)| paths.contains(path.as_str()))
1121            .map(|(path, count)| (path.clone(), *count))
1122            .collect::<BTreeMap<_, _>>();
1123        let observed_facts = self
1124            .observed_facts
1125            .iter()
1126            .filter(|(path, _facts)| paths.contains(*path))
1127            .map(|(path, facts)| (path.clone(), facts.clone()))
1128            .collect::<BTreeMap<_, _>>();
1129        let rejections = self
1130            .rejections
1131            .iter()
1132            .filter(|rejection| paths.contains(rejection.path.as_str()))
1133            .cloned()
1134            .collect::<Vec<_>>();
1135        let rejection_keys = self
1136            .rejection_keys
1137            .iter()
1138            .filter(|key| paths.contains(key.path.as_str()))
1139            .cloned()
1140            .collect::<BTreeSet<_>>();
1141        let rejection_details_dropped_by_path = self
1142            .rejection_details_dropped_by_path
1143            .iter()
1144            .filter(|path| paths.contains(path.as_str()))
1145            .cloned()
1146            .collect::<BTreeSet<_>>();
1147        let marker_bytes =
1148            identity_rejection_drop_paths_retained_bytes(&rejection_details_dropped_by_path)?;
1149        let observed_fact_bytes =
1150            identity_maps_retained_bytes(&observed_facts, &rejected_facts_by_path)?
1151                .checked_add(identity_rejection_keys_retained_bytes(&rejection_keys)?)
1152                .and_then(|bytes| bytes.checked_add(marker_bytes))
1153                .and_then(|bytes| {
1154                    self.relation_fact_indices
1155                        .iter()
1156                        .filter(|(path, _)| paths.contains(path.as_str()))
1157                        .try_fold(bytes, |bytes, (path, indices)| {
1158                            bytes.checked_add(identity_relation_indices_bytes(path, indices.len()))
1159                        })
1160                })
1161                .ok_or_else(|| {
1162                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1163                })?;
1164        Ok(Self {
1165            retained_limit: None,
1166            rejection_keys,
1167            rejections,
1168            source_admitted: self.source_admitted,
1169            observed_fact_bytes,
1170            reused_rejection_facts: BTreeMap::new(),
1171            rejected_facts_by_path,
1172            observed_facts,
1173            relation_fact_indices: self
1174                .relation_fact_indices
1175                .iter()
1176                .filter(|(path, _indices)| paths.contains(path.as_str()))
1177                .map(|(path, indices)| (path.clone(), indices.clone()))
1178                .collect(),
1179            resolution_projections: BTreeMap::new(),
1180            reused_rejection_counts: BTreeMap::new(),
1181            reused_parser_rejection_counts: BTreeMap::new(),
1182            reused_rejection_detail_counts: BTreeMap::new(),
1183            reused_rejection_details_incomplete: BTreeSet::new(),
1184            rejection_details_dropped_by_path,
1185            #[cfg(test)]
1186            resolution_derivations: BTreeMap::new(),
1187            #[cfg(test)]
1188            paired_import_pairing_work: 0,
1189        })
1190    }
1191
1192    /// Return whether this report contains any rejected source facts.
1193    fn has_rejections(&self) -> bool {
1194        !self.rejected_facts_by_path.is_empty()
1195    }
1196
1197    /// Return reused paths that cannot be reconciled without reparsing source.
1198    fn incomplete_reused_rejection_paths(&self) -> &BTreeSet<String> {
1199        &self.reused_rejection_details_incomplete
1200    }
1201
1202    /// Return whether a path lost a distinct typed rejection detail at the
1203    /// publication-wide ceiling.
1204    fn rejection_details_dropped_for(&self, path: &str) -> bool {
1205        self.rejection_details_dropped_by_path.contains(path)
1206    }
1207
1208    /// Reconcile a reused path's persisted total with current re-derived facts.
1209    fn rejected_facts_for_graph(&self, path: &str, derived: &Self) -> Result<u64, CliError> {
1210        let Some(persisted) = self.reused_rejection_counts.get(path).copied() else {
1211            return self
1212                .rejected_facts_for(path)
1213                .checked_add(derived.rejected_facts_for(path))
1214                .ok_or_else(|| {
1215                    CliError::InvalidInput("identity rejection count overflowed".to_string())
1216                });
1217        };
1218        let persisted_parser = self
1219            .reused_parser_rejection_counts
1220            .get(path)
1221            .copied()
1222            .unwrap_or(0);
1223        let persisted_identity = persisted.checked_sub(persisted_parser).ok_or_else(|| {
1224            CliError::InvalidInput("persisted parser rejection count exceeded total".to_string())
1225        })?;
1226        let mut current_facts = BTreeSet::<&IdentityFactKey>::new();
1227        if let Some(facts) = self.observed_facts.get(path) {
1228            current_facts.extend(facts);
1229        }
1230        if let Some(facts) = derived.observed_facts.get(path) {
1231            current_facts.extend(facts);
1232        }
1233        let current_known = u64::try_from(current_facts.len()).map_err(|error| {
1234            CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
1235        })?;
1236        let current_total = self
1237            .rejected_facts_for(path)
1238            .checked_add(derived.rejected_facts_for(path))
1239            .ok_or_else(|| {
1240                CliError::InvalidInput("identity rejection count overflowed".to_string())
1241            })?;
1242        let current_unknown = current_total.checked_sub(current_known).ok_or_else(|| {
1243            CliError::InvalidInput("observed identity facts exceeded rejection count".to_string())
1244        })?;
1245        let persisted_facts = self.reused_rejection_facts.get(path);
1246        let persisted_known = persisted_facts.map_or(0, BTreeSet::len);
1247        let persisted_known = u64::try_from(persisted_known).map_err(|error| {
1248            CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
1249        })?;
1250        let persisted_unknown =
1251            persisted_identity
1252                .checked_sub(persisted_known)
1253                .ok_or_else(|| {
1254                    CliError::InvalidInput(
1255                        "persisted identity facts exceeded rejection count".to_string(),
1256                    )
1257                })?;
1258        let mut all_facts = current_facts;
1259        if let Some(facts) = persisted_facts {
1260            all_facts.extend(
1261                facts
1262                    .iter()
1263                    .filter(|fact| !is_rederived_identity_fact(fact)),
1264            );
1265        }
1266        u64::try_from(all_facts.len())
1267            .map_err(|error| {
1268                CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
1269            })?
1270            .checked_add(persisted_unknown)
1271            .and_then(|count| count.checked_add(current_unknown))
1272            .ok_or_else(|| {
1273                CliError::InvalidInput("identity rejection count overflowed".to_string())
1274            })
1275    }
1276
1277    /// Return the parser-baseline omission retained for one reused path.
1278    fn parser_rejection_for_graph(&self, path: &str) -> u64 {
1279        self.reused_parser_rejection_counts
1280            .get(path)
1281            .copied()
1282            .unwrap_or(0)
1283    }
1284
1285    /// Return whether parser-output graphs have passed the shared source boundary.
1286    pub(super) fn source_admitted(&self) -> bool {
1287        self.source_admitted
1288    }
1289
1290    /// Return paths that need a graph publication because admission changed them.
1291    fn paths(&self) -> impl Iterator<Item = &str> {
1292        self.rejected_facts_by_path.keys().map(String::as_str)
1293    }
1294
1295    /// Return the already-admitted resolution projection for one graph path.
1296    fn resolution_projection(&self, path: &str) -> Option<&ResolutionKeyProjection> {
1297        self.resolution_projections.get(path)
1298    }
1299
1300    /// Translate an admitted relation index back to its parser ordinal.
1301    fn relation_parser_index(&self, path: &str, admitted_index: usize) -> usize {
1302        self.relation_fact_indices
1303            .get(path)
1304            .and_then(|indices| indices.get(admitted_index))
1305            .copied()
1306            .unwrap_or(admitted_index)
1307    }
1308
1309    /// Count one resolution-key derivation for the test-visible ownership proof.
1310    #[cfg(test)]
1311    fn record_resolution_derivation(&mut self, path: &str, generation: IndexGeneration) {
1312        let count = self
1313            .resolution_derivations
1314            .entry((path.to_string(), generation))
1315            .or_default();
1316        *count = count.saturating_add(1);
1317    }
1318}
1319
1320/// Build keyed membership for a retained typed detail vector.
1321fn identity_rejection_key_set(
1322    rejections: &[GraphIdentityRejection],
1323) -> BTreeSet<IdentityRejectionKey> {
1324    rejections.iter().map(IdentityRejectionKey::from).collect()
1325}
1326
1327/// Merge typed details under the one-publication storage ceiling.
1328#[cfg(test)]
1329fn extend_bounded_identity_rejections(
1330    target: &mut Vec<GraphIdentityRejection>,
1331    target_keys: &mut BTreeSet<IdentityRejectionKey>,
1332    incoming: impl IntoIterator<Item = GraphIdentityRejection>,
1333) -> Result<u64, CliError> {
1334    let mut dropped_paths = BTreeSet::new();
1335    extend_bounded_identity_rejections_with_drop_paths(
1336        target,
1337        target_keys,
1338        incoming,
1339        &mut dropped_paths,
1340    )
1341}
1342
1343/// Retain the existing standalone detail helper; report owners use an explicit remainder.
1344fn extend_bounded_identity_rejections_with_drop_paths(
1345    target: &mut Vec<GraphIdentityRejection>,
1346    target_keys: &mut BTreeSet<IdentityRejectionKey>,
1347    incoming: impl IntoIterator<Item = GraphIdentityRejection>,
1348    dropped_paths: &mut BTreeSet<String>,
1349) -> Result<u64, CliError> {
1350    extend_bounded_identity_rejections_with_drop_paths_limit(
1351        target,
1352        target_keys,
1353        incoming,
1354        dropped_paths,
1355        u64::MAX,
1356    )
1357}
1358
1359/// Merge typed details and retain the exact paths that lost a distinct row.
1360fn extend_bounded_identity_rejections_with_drop_paths_limit(
1361    target: &mut Vec<GraphIdentityRejection>,
1362    target_keys: &mut BTreeSet<IdentityRejectionKey>,
1363    incoming: impl IntoIterator<Item = GraphIdentityRejection>,
1364    dropped_paths: &mut BTreeSet<String>,
1365    limit: u64,
1366) -> Result<u64, CliError> {
1367    let mut retained_bytes = 0_u64;
1368    for rejection in incoming {
1369        let key = IdentityRejectionKey::from(&rejection);
1370        if target_keys.contains(&key) {
1371            continue;
1372        }
1373        if target.len() >= MAX_GRAPH_IDENTITY_REJECTIONS {
1374            if !dropped_paths.contains(rejection.path.as_str()) {
1375                let additional = identity_count_path_retained_bytes(rejection.path.as_str())?;
1376                enforce_resolution_registry_budget_with_limit(
1377                    retained_bytes.saturating_add(additional),
1378                    limit,
1379                )?;
1380                dropped_paths.insert(rejection.path.as_str().to_owned());
1381                retained_bytes = retained_bytes
1382                    .checked_add(identity_count_path_retained_bytes(rejection.path.as_str())?)
1383                    .ok_or_else(|| {
1384                        CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1385                    })?;
1386            }
1387            continue;
1388        }
1389        enforce_resolution_registry_budget_with_limit(
1390            retained_bytes.saturating_add(identity_rejection_key_retained_bytes(
1391                rejection.path.as_str(),
1392            )?),
1393            limit,
1394        )?;
1395        target_keys.insert(key);
1396        retained_bytes = retained_bytes
1397            .checked_add(identity_rejection_key_retained_bytes(
1398                rejection.path.as_str(),
1399            )?)
1400            .ok_or_else(|| {
1401                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1402            })?;
1403        target.push(rejection);
1404    }
1405    Ok(retained_bytes)
1406}
1407
1408/// Mark graph-scoped coverage when publication evicted a typed identity row.
1409fn mark_identity_rejection_coverage_limits(
1410    coverage: &mut [CoverageRecord],
1411    dropped_paths: &BTreeSet<String>,
1412) -> Result<(), CliError> {
1413    for row in coverage {
1414        let CoverageScope::Path { path } = row.scope() else {
1415            continue;
1416        };
1417        if row.relation().is_some()
1418            || !dropped_paths.contains(path.as_str())
1419            || row.reached_limit() == Some(GraphLimitKind::Rows)
1420        {
1421            continue;
1422        }
1423        *row = CoverageRecord::new(
1424            row.scope().clone(),
1425            row.relation(),
1426            row.state(),
1427            row.covered(),
1428            row.omitted(),
1429            row.generation(),
1430            row.reason().cloned(),
1431            Some(GraphLimitKind::Rows),
1432        )
1433        .map_err(invalid_graph_contract)?;
1434    }
1435    Ok(())
1436}
1437
1438/// One graph mutation staged outside the database writer transaction.
1439pub(super) enum RepositoryGraphMutation {
1440    /// Replace the complete normalized graph.
1441    Full,
1442    /// Replace the exact admitted source closure while retaining unrelated rows.
1443    AffectedPaths(Vec<String>),
1444}
1445
1446/// Complete normalized graph and canonical-key rows waiting for publication.
1447pub(super) struct StagedRepositoryGraph {
1448    /// Actual required source parses performed while completing canonical evidence.
1449    source_recovery: SourceRecoveryWork,
1450    /// Project identity owning every staged graph fact.
1451    project: ProjectInstanceId,
1452    /// Full or affected-path replacement selected by the staging caller.
1453    mutation: RepositoryGraphMutation,
1454    /// Stable typed entities ready for normalized persistence.
1455    entities: Vec<GraphEntity>,
1456    /// Deduplicated logical relationships ready for persistence.
1457    relations: Vec<LogicalRelation>,
1458    /// Exact source occurrences retained separately from logical relations.
1459    occurrences: Vec<RelationOccurrence>,
1460    /// Parser and relation coverage facts for staged source paths.
1461    coverage: Vec<CoverageRecord>,
1462    /// Canonical resolution keys exported by staged entities.
1463    entity_exports: Vec<EntityResolutionKey>,
1464    /// Canonical dependency keys retained by staged relations.
1465    relation_dependencies: Vec<RelationDependencyKey>,
1466    /// Closed reasons retained only for unresolved canonical document relations.
1467    document_unresolved_reasons: Vec<(LogicalRelationKey, DocumentTargetUnresolvedReason)>,
1468    /// Bounded typed parser-identity rejections committed with this generation.
1469    identity_rejections: Vec<GraphIdentityRejection>,
1470    /// Test-only resolution derivation counts used to prove incremental reuse.
1471    #[cfg(test)]
1472    resolution_derivations: BTreeMap<(String, IndexGeneration), usize>,
1473    /// Test-visible peak of the entity projection's map-plus-entity estimate.
1474    #[cfg(test)]
1475    peak_retained_bytes: u64,
1476    /// Test-visible order proving projections moved before entity allocation.
1477    #[cfg(test)]
1478    projection_removals_before_entities: Vec<String>,
1479    /// Effective scan policy used to recheck non-indexed document targets before commit.
1480    scan_policy: RootScanPolicy,
1481    /// Non-indexed target states observed while resolving document candidates.
1482    document_target_states: Vec<(String, DocumentTargetUnresolvedReason)>,
1483    /// Optional disposable database replacing the in-memory row vectors.
1484    database: Option<StagedGraphDatabase>,
1485    /// Conservative bytes retained until the parent publication completes.
1486    retained_bytes: u64,
1487}
1488
1489/// File-backed graph rows staged outside the main database writer transaction.
1490struct StagedGraphDatabase {
1491    /// Open typed store copied into the main publication.
1492    store: Option<AtlasStore>,
1493    /// Owning directory removed after the store closes.
1494    directory: Option<TempDir>,
1495    /// Cross-process lease preventing restart cleanup while the stage is active.
1496    _lease: File,
1497}
1498
1499impl StagedGraphDatabase {
1500    /// Return the live typed staging store.
1501    fn store(&self) -> Result<&AtlasStore, CliError> {
1502        self.store
1503            .as_ref()
1504            .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
1505    }
1506
1507    /// Return the live typed staging store mutably during preparation.
1508    fn store_mut(&mut self) -> Result<&mut AtlasStore, CliError> {
1509        self.store
1510            .as_mut()
1511            .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
1512    }
1513
1514    /// Return the live disposable staging directory.
1515    fn directory(&self) -> Result<&TempDir, CliError> {
1516        self.directory
1517            .as_ref()
1518            .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
1519    }
1520}
1521
1522impl Drop for StagedGraphDatabase {
1523    fn drop(&mut self) {
1524        let prepared = self.store.take().is_some();
1525        let Some(directory) = self.directory.take() else {
1526            return;
1527        };
1528        let database_path = directory.path().join(GRAPH_STAGE_DATABASE_FILE_NAME);
1529        let direct_database = fs::symlink_metadata(&database_path).is_ok_and(|metadata| {
1530            metadata.file_type().is_file() && !metadata.file_type().is_symlink()
1531        });
1532        if prepared
1533            && direct_database
1534            && remove_owned_graph_stage_payload(directory.path(), &database_path, None).is_ok()
1535        {
1536            drop(directory);
1537        } else {
1538            let _retained_path: PathBuf = directory.keep();
1539        }
1540    }
1541}
1542
1543impl StagedRepositoryGraph {
1544    /// Include evidence recovery in existing parser counts instead of reporting reuse.
1545    pub(super) fn account_source_recovery(
1546        &self,
1547        report: &mut super::SymbolBuildReport,
1548    ) -> Result<(), CliError> {
1549        report.unchanged = report
1550            .unchanged
1551            .checked_sub(self.source_recovery.previously_unchanged)
1552            .ok_or_else(|| {
1553                CliError::InvalidInput(
1554                    "source recovery exceeded the unchanged parser count".to_string(),
1555                )
1556            })?;
1557        report.parsed = report
1558            .parsed
1559            .checked_add(self.source_recovery.previously_unchanged)
1560            .and_then(|count| count.checked_add(self.source_recovery.additional_candidates))
1561            .ok_or_else(|| {
1562                CliError::InvalidInput("source recovery parser count overflowed".to_string())
1563            })?;
1564        report.candidates = report
1565            .candidates
1566            .checked_add(self.source_recovery.additional_candidates)
1567            .ok_or_else(|| {
1568                CliError::InvalidInput("source recovery candidate count overflowed".to_string())
1569            })?;
1570        if self.source_recovery.previously_unchanged > 0
1571            || self.source_recovery.additional_candidates > 0
1572        {
1573            report.max_workers = report.max_workers.max(1);
1574        }
1575        Ok(())
1576    }
1577    /// Return conservative retained bytes counted toward the parent staging budget.
1578    pub(super) const fn retained_bytes(&self) -> u64 {
1579        self.retained_bytes
1580    }
1581
1582    /// Refuse publication if a consulted non-indexed target changed after resolution.
1583    pub(super) fn revalidate_document_targets(&self, root: &Path) -> Result<(), CliError> {
1584        if self.document_target_states.is_empty() {
1585            return Ok(());
1586        }
1587        let current = DocumentResolutionIndex::new(root, &[], &self.scan_policy)?;
1588        for (path, expected) in &self.document_target_states {
1589            if current.absent_reason(path)? != *expected {
1590                return Err(source_changed_during_derivation(root, path));
1591            }
1592        }
1593        Ok(())
1594    }
1595
1596    /// Apply the complete staged graph through the parent publication transaction.
1597    pub(super) fn apply(
1598        &self,
1599        publication: &mut IndexPublicationGuard<'_>,
1600        control: &IndexWorkControl,
1601    ) -> Result<(), CliError> {
1602        control.check(IndexWorkStage::Publication)?;
1603        if let Some(database) = &self.database {
1604            if !database.directory()?.path().is_dir() {
1605                return Err(CliError::InvalidInput(
1606                    "repository graph staging directory is unavailable".to_string(),
1607                ));
1608            }
1609            if !matches!(self.mutation, RepositoryGraphMutation::Full) {
1610                return Err(CliError::InvalidInput(
1611                    "database-backed graph staging only supports full replacement".to_string(),
1612                ));
1613            }
1614            publication.replace_repository_graph_from_staging(
1615                self.project,
1616                database.store()?,
1617                Some(control),
1618            )?;
1619            publication
1620                .replace_graph_identity_rejections(self.project, &self.identity_rejections)?;
1621            if !self.document_unresolved_reasons.is_empty() {
1622                publication.set_document_unresolved_reasons_controlled(
1623                    &self.document_unresolved_reasons,
1624                    control,
1625                )?;
1626            }
1627            control.check(IndexWorkStage::Publication)?;
1628            return Ok(());
1629        }
1630        match &self.mutation {
1631            RepositoryGraphMutation::Full => {
1632                publication.replace_repository_graph_with_resolution_keys(
1633                    self.project,
1634                    &self.entities,
1635                    &self.relations,
1636                    &self.occurrences,
1637                    &self.coverage,
1638                    &self.entity_exports,
1639                    &self.relation_dependencies,
1640                )?;
1641                publication
1642                    .replace_graph_identity_rejections(self.project, &self.identity_rejections)?;
1643            }
1644            RepositoryGraphMutation::AffectedPaths(paths) => {
1645                publication.replace_repository_graph_for_paths_with_resolution_keys(
1646                    self.project,
1647                    paths,
1648                    &self.entities,
1649                    &self.relations,
1650                    &self.occurrences,
1651                    &self.coverage,
1652                    &self.entity_exports,
1653                    &self.relation_dependencies,
1654                )?;
1655                publication
1656                    .replace_graph_identity_rejections(self.project, &self.identity_rejections)?;
1657            }
1658        }
1659        if !self.document_unresolved_reasons.is_empty() {
1660            publication.set_document_unresolved_reasons_controlled(
1661                &self.document_unresolved_reasons,
1662                control,
1663            )?;
1664        }
1665        control.check(IndexWorkStage::Publication)?;
1666        Ok(())
1667    }
1668}
1669
1670/// Reserve existing path inventories before constructing their sets or node clones.
1671/// Ten node/string envelopes cover direct/current/manifest/package/affected path
1672/// sets plus temporary graph-loader inventories. Deleted direct paths are separate
1673/// because they are absent from the expected node inventory. This is an inherited
1674/// staging estimate, not a new source/path-size or user-facing limit.
1675fn graph_path_inventory_bytes(nodes: &[Node], direct_paths: &[String]) -> u64 {
1676    super::staged_node_bytes(nodes)
1677        .saturating_add(super::staged_string_bytes(direct_paths))
1678        .saturating_mul(10)
1679}
1680
1681/// Stage a full graph with the ordinary publication allowance in unit fixtures.
1682#[cfg(test)]
1683fn stage_full_repository_graph(
1684    store: &AtlasStore,
1685    root: &Path,
1686    base_generation: IndexGeneration,
1687    nodes: &[Node],
1688    scan_policy: &RootScanPolicy,
1689    symbols: &SymbolBuildStage,
1690    control: &IndexWorkControl,
1691) -> Result<StagedRepositoryGraph, CliError> {
1692    stage_full_repository_graph_with_limit(
1693        store,
1694        root,
1695        base_generation,
1696        nodes,
1697        scan_policy,
1698        symbols,
1699        control,
1700        super::MAX_PUBLICATION_STAGING_BYTES,
1701    )
1702}
1703
1704/// Stage a complete repository graph from current parser output plus safe reused graphs.
1705pub(super) fn stage_full_repository_graph_with_limit(
1706    store: &AtlasStore,
1707    root: &Path,
1708    base_generation: IndexGeneration,
1709    nodes: &[Node],
1710    scan_policy: &RootScanPolicy,
1711    symbols: &SymbolBuildStage,
1712    control: &IndexWorkControl,
1713    staging_limit: u64,
1714) -> Result<StagedRepositoryGraph, CliError> {
1715    let inventory_bytes = graph_path_inventory_bytes(nodes, &[]);
1716    enforce_resolution_registry_budget_with_limit(
1717        symbols.retained_bytes.saturating_add(inventory_bytes),
1718        staging_limit,
1719    )?;
1720    let staging_limit = staging_limit.saturating_sub(inventory_bytes);
1721    cleanup_abandoned_repository_graph_staging(store, root, control)?;
1722    let project = selected_project(store)?;
1723    let generation = next_generation(base_generation)?;
1724    let paths = nodes
1725        .iter()
1726        .filter(|node| node.kind == NodeKind::File)
1727        .map(|node| node.path.clone())
1728        .collect::<BTreeSet<_>>();
1729    let staging_limit = staging_limit.saturating_sub(symbols.retained_bytes);
1730    let mut sources = SourceBindingCache::new(root, nodes, None);
1731    sources.set_retained_limit(staging_limit)?;
1732    let loaded_graphs = complete_symbol_graphs(store, &paths, symbols, &mut sources, control)?;
1733    let (graphs, mut identity_admission) = admit_symbol_graphs_with_limit(
1734        loaded_graphs,
1735        control,
1736        staging_limit.saturating_sub(sources.retained_bytes),
1737    )?;
1738    let changed_paths = symbols
1739        .changes
1740        .iter()
1741        .map(|change| match change {
1742            SymbolProjectionChange::Parsed(parsed) => parsed.path.as_str(),
1743            SymbolProjectionChange::Clear { path, .. } => path.as_str(),
1744        })
1745        .collect::<BTreeSet<_>>();
1746    let reused_paths = paths
1747        .iter()
1748        .filter(|path| !changed_paths.contains(path.as_str()))
1749        .cloned()
1750        .collect::<BTreeSet<_>>();
1751    identity_admission.retained_limit = Some(staging_limit.saturating_sub(sources.retained_bytes));
1752    hydrate_reused_identity_admission(
1753        store,
1754        project,
1755        &reused_paths,
1756        &graphs,
1757        &mut identity_admission,
1758        control,
1759    )?;
1760    if !identity_admission
1761        .incomplete_reused_rejection_paths()
1762        .is_empty()
1763    {
1764        return Err(dependency_closure_limit(
1765            root,
1766            identity_admission
1767                .incomplete_reused_rejection_paths()
1768                .iter()
1769                .cloned(),
1770            identity_admission.incomplete_reused_rejection_paths().len(),
1771        ));
1772    }
1773    identity_admission.merge(
1774        symbols.identity_admission.for_paths_with_limit(
1775            &paths,
1776            staging_limit
1777                .saturating_sub(sources.retained_bytes)
1778                .saturating_sub(identity_admission.observed_fact_bytes),
1779        )?,
1780        control,
1781    )?;
1782    let mut document_facts = complete_markdown_facts(
1783        root,
1784        nodes,
1785        &graphs,
1786        symbols,
1787        control,
1788        staging_limit
1789            .saturating_sub(sources.retained_bytes)
1790            .saturating_sub(identity_admission.observed_fact_bytes),
1791    )?;
1792    identity_admission.retained_limit = Some(
1793        staging_limit
1794            .saturating_sub(sources.retained_bytes)
1795            .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
1796    );
1797    admit_markdown_facts(
1798        &mut document_facts,
1799        &graphs,
1800        &mut identity_admission,
1801        control,
1802    )?;
1803    control.check(IndexWorkStage::SymbolParsing)?;
1804    let configured_modules =
1805        super::module_resolution::load_configured_module_resolution(root, nodes, control)?;
1806    let staging_limit = staging_limit.saturating_sub(configured_modules.retained_bytes());
1807    sources.set_retained_limit(
1808        staging_limit
1809            .saturating_sub(identity_admission.observed_fact_bytes)
1810            .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
1811    )?;
1812    let packages = PackageIndex::from_sources(
1813        project,
1814        &graphs.iter().collect::<Vec<_>>(),
1815        &mut sources,
1816        control,
1817    )?;
1818    admit_resolution_key_failures_with_limit(
1819        project,
1820        generation,
1821        &graphs,
1822        &packages,
1823        &configured_modules,
1824        &mut identity_admission,
1825        control,
1826        staging_limit
1827            .saturating_sub(sources.retained_bytes)
1828            .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
1829    )?;
1830    ensure_admitted_resolution_projections(&graphs, &identity_admission.resolution_projections)?;
1831    let entity_limit = staging_limit
1832        .saturating_sub(sources.retained_bytes)
1833        .saturating_sub(identity_admission.observed_fact_bytes)
1834        .saturating_sub(document_fact_map_retained_bytes(&document_facts));
1835    let mut entity_projection = build_entity_projection_with_config_limit(
1836        project,
1837        generation,
1838        nodes,
1839        &graphs,
1840        &packages,
1841        &configured_modules,
1842        Some(&mut identity_admission.resolution_projections),
1843        true,
1844        control,
1845        entity_limit,
1846    )?;
1847    debug_assert!(identity_admission.resolution_projections.is_empty());
1848    let candidate_limit = staging_limit
1849        .saturating_sub(entity_projection.retained_bytes)
1850        .saturating_sub(sources.retained_bytes)
1851        .saturating_sub(identity_admission.observed_fact_bytes)
1852        .saturating_sub(document_fact_map_retained_bytes(&document_facts));
1853    let mut candidates =
1854        resolution_registry_from_exports_with_limit(&entity_projection, control, candidate_limit)?;
1855    let (bindings, binding_bytes) = binding_resolution::resolve_source_bindings(
1856        store,
1857        project,
1858        generation,
1859        &graphs.iter().collect::<Vec<_>>(),
1860        &packages,
1861        &configured_modules,
1862        &mut sources,
1863        &entity_projection,
1864        &mut candidates,
1865        None,
1866        &BTreeSet::new(),
1867        control,
1868        staging_limit
1869            .saturating_sub(identity_admission.observed_fact_bytes)
1870            .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
1871    )?;
1872    entity_projection.call_bindings = bindings;
1873    entity_projection.retained_bytes = entity_projection
1874        .retained_bytes
1875        .saturating_add(binding_bytes);
1876    enforce_resolution_staging_budget(&entity_projection, &candidates)?;
1877    let document_projection_bytes = document_projection_retained_bytes(&document_facts, control)?;
1878    let graph_work_bytes = symbols
1879        .retained_bytes
1880        .saturating_add(identity_admission.observed_fact_bytes)
1881        .saturating_add(entity_projection.retained_bytes)
1882        .saturating_add(candidates.retained_bytes)
1883        .saturating_add(document_fact_map_retained_bytes(&document_facts))
1884        .saturating_add(sources.retained_bytes)
1885        .saturating_add(document_projection_bytes);
1886    let mut staged = if graph_work_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
1887        finish_projection_in_database_with_documents(
1888            root,
1889            nodes,
1890            project,
1891            generation,
1892            &graphs,
1893            &document_facts,
1894            &identity_admission,
1895            entity_projection,
1896            &candidates,
1897            scan_policy,
1898            control,
1899        )
1900    } else {
1901        finish_projection_with_documents(
1902            project,
1903            generation,
1904            RepositoryGraphMutation::Full,
1905            &graphs,
1906            root,
1907            nodes,
1908            &document_facts,
1909            &identity_admission,
1910            entity_projection,
1911            &candidates,
1912            scan_policy,
1913            control,
1914        )
1915    }?;
1916    staged.source_recovery = sources.work;
1917    Ok(staged)
1918}
1919
1920/// Distinct canonical witnesses owned by one bounded incremental phase.
1921struct ResolutionKeyWorkspace<'a> {
1922    /// Root and affected source sample used by the existing full-refresh guidance.
1923    root: &'a Path,
1924    /// Current owning source paths, borrowed without another path inventory.
1925    paths: &'a BTreeSet<String>,
1926    /// Existing distinct-key ceiling, checked before insertion.
1927    row_limit: usize,
1928    /// Ordered witnesses; duplicates retain no additional key copy.
1929    keys: BTreeSet<CanonicalResolutionKey>,
1930    /// Tree/key/text ownership already admitted in this workspace.
1931    retained_bytes: u64,
1932    /// Operation remainder after all coexisting phase owners.
1933    limit: u64,
1934}
1935
1936impl<'a> ResolutionKeyWorkspace<'a> {
1937    /// Start a workspace under the current operation remainder.
1938    fn new(limit: u64, root: &'a Path, paths: &'a BTreeSet<String>) -> Self {
1939        Self {
1940            root,
1941            paths,
1942            row_limit: MAX_INCREMENTAL_RESOLUTION_ITEMS as usize,
1943            keys: BTreeSet::new(),
1944            retained_bytes: 0,
1945            limit,
1946        }
1947    }
1948
1949    /// Admit a borrowed witness before cloning its canonical identity.
1950    fn insert(&mut self, key: &CanonicalResolutionKey) -> Result<(), CliError> {
1951        if self.keys.contains(key) {
1952            return Ok(());
1953        }
1954        if self.keys.len() >= self.row_limit {
1955            return Err(dependency_closure_limit(
1956                self.root,
1957                self.paths.iter().cloned(),
1958                self.keys.len().saturating_add(1),
1959            ));
1960        }
1961        let next = self
1962            .retained_bytes
1963            .saturating_add(resolution_workspace_key_bytes(key));
1964        enforce_resolution_registry_budget_with_limit(next, self.limit)?;
1965        self.keys.insert(key.clone());
1966        self.retained_bytes = next;
1967        Ok(())
1968    }
1969
1970    /// Move an already decoded witness without making a second canonical string.
1971    fn insert_owned(&mut self, key: CanonicalResolutionKey) -> Result<(), CliError> {
1972        if self.keys.contains(&key) {
1973            return Ok(());
1974        }
1975        if self.keys.len() >= self.row_limit {
1976            return Err(dependency_closure_limit(
1977                self.root,
1978                self.paths.iter().cloned(),
1979                self.keys.len().saturating_add(1),
1980            ));
1981        }
1982        let next = self
1983            .retained_bytes
1984            .saturating_add(resolution_workspace_key_bytes(&key));
1985        enforce_resolution_registry_budget_with_limit(next, self.limit)?;
1986        self.keys.insert(key);
1987        self.retained_bytes = next;
1988        Ok(())
1989    }
1990
1991    /// Admit the complete query copy while its ordered witness set remains live.
1992    fn snapshot(&self) -> Result<(Vec<CanonicalResolutionKey>, u64), CliError> {
1993        let bytes = self.keys.iter().fold(
1994            (self.keys.len() as u64)
1995                .saturating_mul(std::mem::size_of::<CanonicalResolutionKey>() as u64),
1996            |bytes, key| bytes.saturating_add(key.canonical_identity().len() as u64),
1997        );
1998        enforce_resolution_registry_budget_with_limit(
1999            self.retained_bytes.saturating_add(bytes),
2000            self.limit,
2001        )?;
2002        let mut snapshot = Vec::with_capacity(self.keys.len());
2003        snapshot.extend(self.keys.iter().cloned());
2004        Ok((
2005            snapshot,
2006            self.limit
2007                .saturating_sub(self.retained_bytes)
2008                .saturating_sub(bytes),
2009        ))
2010    }
2011}
2012
2013/// Bound each retained canonical witness, its inline key, and tree metadata.
2014fn resolution_workspace_key_bytes(key: &CanonicalResolutionKey) -> u64 {
2015    STAGED_GRAPH_ROW_BYTES
2016        .saturating_add(std::mem::size_of::<CanonicalResolutionKey>() as u64)
2017        .saturating_add(key.retained_heap_bytes())
2018}
2019
2020/// Stage one bounded dependency-aware graph closure for an incremental publication.
2021#[cfg(test)]
2022fn stage_incremental_repository_graph(
2023    store: &AtlasStore,
2024    root: &Path,
2025    base_generation: IndexGeneration,
2026    expected_nodes: &[Node],
2027    direct_paths: &[String],
2028    scan_policy: &RootScanPolicy,
2029    symbols: &SymbolBuildStage,
2030    control: &IndexWorkControl,
2031) -> Result<StagedRepositoryGraph, CliError> {
2032    stage_incremental_repository_graph_with_limit(
2033        store,
2034        root,
2035        base_generation,
2036        expected_nodes,
2037        direct_paths,
2038        scan_policy,
2039        symbols,
2040        control,
2041        super::MAX_PUBLICATION_STAGING_BYTES,
2042    )
2043}
2044
2045#[cfg(test)]
2046fn stage_incremental_repository_graph_with_test_limit(
2047    store: &AtlasStore,
2048    root: &Path,
2049    base_generation: IndexGeneration,
2050    expected_nodes: &[Node],
2051    direct_paths: &[String],
2052    scan_policy: &RootScanPolicy,
2053    symbols: &SymbolBuildStage,
2054    control: &IndexWorkControl,
2055    staging_limit: u64,
2056) -> Result<StagedRepositoryGraph, CliError> {
2057    stage_incremental_repository_graph_with_limit(
2058        store,
2059        root,
2060        base_generation,
2061        expected_nodes,
2062        direct_paths,
2063        scan_policy,
2064        symbols,
2065        control,
2066        staging_limit,
2067    )
2068}
2069
2070#[allow(clippy::too_many_arguments)]
2071/// Stage an incremental graph with the supplied projection staging limit.
2072pub(super) fn stage_incremental_repository_graph_with_limit(
2073    store: &AtlasStore,
2074    root: &Path,
2075    base_generation: IndexGeneration,
2076    expected_nodes: &[Node],
2077    direct_paths: &[String],
2078    scan_policy: &RootScanPolicy,
2079    symbols: &SymbolBuildStage,
2080    control: &IndexWorkControl,
2081    staging_limit: u64,
2082) -> Result<StagedRepositoryGraph, CliError> {
2083    let inventory_bytes = graph_path_inventory_bytes(expected_nodes, direct_paths);
2084    enforce_resolution_registry_budget_with_limit(
2085        symbols.retained_bytes.saturating_add(inventory_bytes),
2086        staging_limit,
2087    )?;
2088    let staging_limit = staging_limit
2089        .saturating_sub(symbols.retained_bytes)
2090        .saturating_sub(inventory_bytes);
2091    let project = selected_project(store)?;
2092    let generation = next_generation(base_generation)?;
2093    let configured_modules =
2094        super::module_resolution::load_configured_module_resolution(root, expected_nodes, control)?;
2095    let staging_limit = staging_limit.saturating_sub(configured_modules.retained_bytes());
2096    let direct_paths = direct_paths.iter().cloned().collect::<BTreeSet<_>>();
2097    let mut sources = SourceBindingCache::new(root, expected_nodes, Some(&direct_paths));
2098    sources.set_retained_limit(staging_limit)?;
2099    enforce_incremental_count(
2100        root,
2101        "direct source paths",
2102        direct_paths.len(),
2103        &direct_paths,
2104    )?;
2105    let _direct_footprint =
2106        admitted_persisted_footprint(store, project, root, &direct_paths, control)?;
2107
2108    let current_file_paths = expected_nodes
2109        .iter()
2110        .filter(|node| node.kind == NodeKind::File)
2111        .map(|node| node.path.clone())
2112        .collect::<BTreeSet<_>>();
2113    let direct_graph_paths = direct_paths
2114        .intersection(&current_file_paths)
2115        .cloned()
2116        .collect::<BTreeSet<_>>();
2117    let manifest_paths = expected_nodes
2118        .iter()
2119        .filter(|node| node.kind == NodeKind::File && is_cargo_manifest_path(&node.path))
2120        .map(|node| node.path.clone())
2121        .collect::<BTreeSet<_>>();
2122    let package_only_paths = manifest_paths
2123        .difference(&direct_graph_paths)
2124        .cloned()
2125        .collect::<BTreeSet<_>>();
2126    let loaded_direct_graphs =
2127        complete_symbol_graphs(store, &direct_graph_paths, symbols, &mut sources, control)?;
2128    let (direct_graphs, mut direct_identity_admission) = admit_symbol_graphs_with_limit(
2129        loaded_direct_graphs,
2130        control,
2131        staging_limit.saturating_sub(sources.retained_bytes),
2132    )?;
2133    direct_identity_admission.merge(
2134        symbols.identity_admission.for_paths_with_limit(
2135            &direct_graph_paths,
2136            staging_limit
2137                .saturating_sub(sources.retained_bytes)
2138                .saturating_sub(direct_identity_admission.observed_fact_bytes),
2139        )?,
2140        control,
2141    )?;
2142    let package_graphs =
2143        complete_symbol_graphs(store, &package_only_paths, symbols, &mut sources, control)?;
2144    let (package_graphs, package_context_admission) = admit_symbol_graphs_with_limit(
2145        package_graphs,
2146        control,
2147        staging_limit
2148            .saturating_sub(sources.retained_bytes)
2149            .saturating_sub(direct_identity_admission.observed_fact_bytes),
2150    )?;
2151    drop(package_context_admission);
2152    sources.set_retained_limit(
2153        staging_limit.saturating_sub(direct_identity_admission.observed_fact_bytes),
2154    )?;
2155    let package_index_graphs = direct_graphs
2156        .iter()
2157        .chain(package_graphs.iter())
2158        .collect::<Vec<_>>();
2159    let direct_packages =
2160        PackageIndex::from_sources(project, &package_index_graphs, &mut sources, control)?;
2161    admit_resolution_key_failures_with_limit(
2162        project,
2163        generation,
2164        &direct_graphs,
2165        &direct_packages,
2166        &configured_modules,
2167        &mut direct_identity_admission,
2168        control,
2169        staging_limit.saturating_sub(sources.retained_bytes),
2170    )?;
2171    // Direct keys remain live while affected sources and package cohorts grow.
2172    let direct_key_bytes =
2173        resolution_projection_map_retained_bytes(&direct_identity_admission.resolution_projections);
2174    sources.set_retained_limit(
2175        staging_limit
2176            .saturating_sub(direct_key_bytes)
2177            .saturating_sub(direct_identity_admission.observed_fact_bytes),
2178    )?;
2179
2180    let old_exports = store
2181        .repository_export_keys_for_paths_with_byte_limit(
2182            project,
2183            &direct_paths.iter().cloned().collect::<Vec<_>>(),
2184            MAX_INCREMENTAL_RESOLUTION_ITEMS,
2185            staging_limit
2186                .saturating_sub(sources.retained_bytes)
2187                .saturating_sub(direct_key_bytes)
2188                .saturating_sub(direct_identity_admission.observed_fact_bytes),
2189        )
2190        .map_err(resolution_read_error)?;
2191    if old_exports.page.truncated {
2192        return Err(dependency_closure_limit(
2193            root,
2194            direct_paths.iter().cloned(),
2195            usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
2196        ));
2197    }
2198    let mut changed_keys = ResolutionKeyWorkspace::new(
2199        staging_limit
2200            .saturating_sub(sources.retained_bytes)
2201            .saturating_sub(direct_key_bytes)
2202            .saturating_sub(direct_identity_admission.observed_fact_bytes),
2203        root,
2204        &direct_paths,
2205    );
2206    let changed_limit = changed_keys.limit;
2207    changed_keys.limit = changed_limit.saturating_sub(old_exports.retained_bytes);
2208    for key in old_exports.page.rows {
2209        changed_keys.insert_owned(key)?;
2210    }
2211    changed_keys.limit = changed_limit;
2212    for path in &direct_paths {
2213        changed_keys.insert_owned(document_file_resolution_key(project, path)?)?;
2214        changed_keys.insert_owned(document_casefold_resolution_key(project, path)?)?;
2215    }
2216    for graph in &direct_graphs {
2217        let graph = graph.graph();
2218        control.check(IndexWorkStage::SymbolParsing)?;
2219        let projection = direct_identity_admission
2220            .resolution_projection(&graph.path)
2221            .ok_or_else(|| {
2222                CliError::InvalidInput(format!(
2223                    "resolution keys were not admitted for graph {}",
2224                    graph.path
2225                ))
2226            })?;
2227        for key in projection.source_keys() {
2228            changed_keys.insert(key)?;
2229        }
2230        for export in projection.export_keys() {
2231            for key in export.keys() {
2232                changed_keys.insert(key)?;
2233            }
2234        }
2235        for symbol in projection.symbol_keys() {
2236            for key in symbol.keys() {
2237                changed_keys.insert(key)?;
2238            }
2239        }
2240        for symbol in &graph.symbols {
2241            if symbol.kind == SymbolKind::Heading {
2242                changed_keys.insert_owned(document_heading_resolution_key(
2243                    project,
2244                    &graph.path,
2245                    &symbol.signature,
2246                )?)?;
2247            }
2248        }
2249    }
2250    enforce_incremental_count(
2251        root,
2252        "old and new export keys",
2253        changed_keys.keys.len(),
2254        &direct_paths,
2255    )?;
2256
2257    let inbound = {
2258        let (request, remaining) = changed_keys.snapshot()?;
2259        store
2260            .repository_affected_source_paths_with_byte_limit(
2261                project,
2262                &request,
2263                MAX_INCREMENTAL_RESOLUTION_ITEMS,
2264                remaining,
2265            )
2266            .map_err(resolution_read_error)?
2267    };
2268    drop(changed_keys);
2269    if inbound.page.truncated {
2270        return Err(dependency_closure_limit(
2271            root,
2272            inbound
2273                .page
2274                .rows
2275                .iter()
2276                .map(|path| path.as_str().to_string()),
2277            usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
2278        ));
2279    }
2280    let mut affected_paths = direct_paths;
2281    // Returned paths move into the inventory envelope admitted at stage entry.
2282    affected_paths.extend(inbound.page.rows.into_iter().map(String::from));
2283    affected_paths.extend(direct_identity_admission.paths().map(ToString::to_string));
2284    enforce_incremental_count(
2285        root,
2286        "affected source paths",
2287        affected_paths.len(),
2288        &affected_paths,
2289    )?;
2290    control.check(IndexWorkStage::SymbolParsing)?;
2291    let persisted_footprint =
2292        admitted_persisted_footprint(store, project, root, &affected_paths, control)?;
2293
2294    let affected_graph_paths = affected_paths
2295        .intersection(&current_file_paths)
2296        .cloned()
2297        .collect::<BTreeSet<_>>();
2298    let newly_affected_graph_paths = affected_graph_paths
2299        .difference(&direct_graph_paths)
2300        .cloned()
2301        .collect::<BTreeSet<_>>();
2302    let loaded_affected_graphs = complete_symbol_graphs(
2303        store,
2304        &newly_affected_graph_paths,
2305        symbols,
2306        &mut sources,
2307        control,
2308    )?;
2309    let (newly_affected_graphs, mut affected_identity_admission) = admit_symbol_graphs_with_limit(
2310        loaded_affected_graphs,
2311        control,
2312        staging_limit
2313            .saturating_sub(sources.retained_bytes)
2314            .saturating_sub(direct_key_bytes)
2315            .saturating_sub(direct_identity_admission.observed_fact_bytes),
2316    )?;
2317    hydrate_reused_identity_admission(
2318        store,
2319        project,
2320        &newly_affected_graph_paths,
2321        &newly_affected_graphs,
2322        &mut affected_identity_admission,
2323        control,
2324    )?;
2325    if !affected_identity_admission
2326        .incomplete_reused_rejection_paths()
2327        .is_empty()
2328    {
2329        return Err(dependency_closure_limit(
2330            root,
2331            affected_identity_admission
2332                .incomplete_reused_rejection_paths()
2333                .iter()
2334                .cloned(),
2335            affected_identity_admission
2336                .incomplete_reused_rejection_paths()
2337                .len(),
2338        ));
2339    }
2340    let admitted_graphs = direct_graphs
2341        .iter()
2342        .chain(newly_affected_graphs.iter())
2343        .chain(package_graphs.iter())
2344        .collect::<Vec<_>>();
2345    let packages = PackageIndex::from_sources(project, &admitted_graphs, &mut sources, control)?;
2346    admit_resolution_key_failures_with_limit(
2347        project,
2348        generation,
2349        &newly_affected_graphs,
2350        &packages,
2351        &configured_modules,
2352        &mut affected_identity_admission,
2353        control,
2354        staging_limit
2355            .saturating_sub(sources.retained_bytes)
2356            .saturating_sub(direct_key_bytes)
2357            .saturating_sub(direct_identity_admission.observed_fact_bytes),
2358    )?;
2359    direct_identity_admission.retained_limit = Some(
2360        staging_limit
2361            .saturating_sub(sources.retained_bytes)
2362            .saturating_sub(direct_key_bytes)
2363            .saturating_sub(resolution_projection_map_retained_bytes(
2364                &affected_identity_admission.resolution_projections,
2365            )),
2366    );
2367    direct_identity_admission.merge(affected_identity_admission, control)?;
2368    enforce_incremental_projection_budget(
2369        root,
2370        &affected_paths,
2371        0,
2372        direct_identity_admission.observed_fact_bytes,
2373    )?;
2374    let affected_graphs = direct_graphs
2375        .iter()
2376        .filter(|graph| affected_graph_paths.contains(&graph.graph().path))
2377        .map(SourceGraph::graph)
2378        .chain(newly_affected_graphs.iter().map(SourceGraph::graph))
2379        .collect::<Vec<_>>();
2380    ensure_admitted_resolution_projections(
2381        &affected_graphs,
2382        &direct_identity_admission.resolution_projections,
2383    )?;
2384    let mut document_facts = complete_markdown_facts(
2385        root,
2386        expected_nodes,
2387        &affected_graphs,
2388        symbols,
2389        control,
2390        staging_limit
2391            .saturating_sub(sources.retained_bytes)
2392            .saturating_sub(direct_identity_admission.observed_fact_bytes)
2393            .saturating_sub(resolution_projection_map_retained_bytes(
2394                &direct_identity_admission.resolution_projections,
2395            )),
2396    )?;
2397    direct_identity_admission.retained_limit = Some(
2398        staging_limit
2399            .saturating_sub(sources.retained_bytes)
2400            .saturating_sub(resolution_projection_map_retained_bytes(
2401                &direct_identity_admission.resolution_projections,
2402            ))
2403            .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
2404    );
2405    admit_markdown_facts(
2406        &mut document_facts,
2407        &affected_graphs,
2408        &mut direct_identity_admission,
2409        control,
2410    )?;
2411    let affected_nodes = expected_nodes
2412        .iter()
2413        .filter(|node| affected_paths.contains(&node.path))
2414        .cloned()
2415        .collect::<Vec<_>>();
2416    let mut entity_projection = build_entity_projection_with_config_limit(
2417        project,
2418        generation,
2419        &affected_nodes,
2420        &affected_graphs,
2421        &packages,
2422        &configured_modules,
2423        Some(&mut direct_identity_admission.resolution_projections),
2424        false,
2425        control,
2426        staging_limit
2427            .saturating_sub(sources.retained_bytes)
2428            .saturating_sub(direct_identity_admission.observed_fact_bytes)
2429            .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
2430    )?;
2431    debug_assert!(direct_identity_admission.resolution_projections.is_empty());
2432
2433    let dependency_limit = staging_limit
2434        .saturating_sub(entity_projection.retained_bytes)
2435        .saturating_sub(sources.retained_bytes)
2436        .saturating_sub(direct_identity_admission.observed_fact_bytes)
2437        .saturating_sub(document_fact_map_retained_bytes(&document_facts));
2438    let mut dependency_keys = ResolutionKeyWorkspace::new(dependency_limit, root, &affected_paths);
2439    for key in entity_projection
2440        .keys_by_graph
2441        .values()
2442        .flat_map(ResolutionKeyProjection::relation_keys)
2443        .flat_map(projectatlas_symbols::RelationResolutionKeys::keys)
2444    {
2445        dependency_keys.insert(key)?;
2446    }
2447    append_document_dependency_keys(project, &document_facts, &mut dependency_keys)?;
2448    enforce_incremental_count(
2449        root,
2450        "affected dependency keys",
2451        dependency_keys.keys.len(),
2452        &affected_paths,
2453    )?;
2454    let persisted = {
2455        let (request, remaining) = dependency_keys.snapshot()?;
2456        store
2457            .repository_resolution_candidates_for_keys_with_byte_limit(
2458                project,
2459                &request,
2460                MAX_INCREMENTAL_RESOLUTION_ITEMS,
2461                remaining,
2462            )
2463            .map_err(resolution_read_error)?
2464    };
2465    if persisted.page.truncated {
2466        return Err(dependency_closure_limit(
2467            root,
2468            affected_paths.iter().cloned(),
2469            usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
2470        ));
2471    }
2472    let mut candidates = resolution_registry_from_persisted(
2473        project,
2474        generation,
2475        persisted,
2476        &affected_paths,
2477        control,
2478        staging_limit
2479            .saturating_sub(dependency_keys.retained_bytes)
2480            .saturating_sub(entity_projection.retained_bytes)
2481            .saturating_sub(sources.retained_bytes)
2482            .saturating_sub(direct_identity_admission.observed_fact_bytes)
2483            .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
2484    )?;
2485    append_resolution_exports(&mut candidates, &entity_projection, control)?;
2486    let binding_graphs = direct_graphs
2487        .iter()
2488        .filter(|graph| affected_graph_paths.contains(&graph.graph().path))
2489        .chain(newly_affected_graphs.iter())
2490        .collect::<Vec<_>>();
2491    let (bindings, binding_bytes) = binding_resolution::resolve_source_bindings(
2492        store,
2493        project,
2494        generation,
2495        &binding_graphs,
2496        &packages,
2497        &configured_modules,
2498        &mut sources,
2499        &entity_projection,
2500        &mut candidates,
2501        Some(dependency_keys.keys),
2502        &affected_paths,
2503        control,
2504        staging_limit
2505            .saturating_sub(direct_identity_admission.observed_fact_bytes)
2506            .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
2507    )?;
2508    entity_projection.call_bindings = bindings;
2509    entity_projection.retained_bytes = entity_projection
2510        .retained_bytes
2511        .saturating_add(binding_bytes);
2512    let document_projection_bytes = document_projection_retained_bytes(&document_facts, control)?;
2513    enforce_incremental_projection_budget(
2514        root,
2515        &affected_paths,
2516        0,
2517        entity_projection
2518            .retained_bytes
2519            .saturating_add(candidates.retained_bytes)
2520            .saturating_add(document_fact_map_retained_bytes(&document_facts))
2521            .saturating_add(document_projection_bytes)
2522            .saturating_add(sources.retained_bytes)
2523            .saturating_add(direct_identity_admission.observed_fact_bytes),
2524    )?;
2525    let mut staged = finish_projection_with_documents(
2526        project,
2527        generation,
2528        RepositoryGraphMutation::AffectedPaths(affected_paths.iter().cloned().collect()),
2529        &affected_graphs,
2530        root,
2531        expected_nodes,
2532        &document_facts,
2533        &direct_identity_admission,
2534        entity_projection,
2535        &candidates,
2536        scan_policy,
2537        control,
2538    )?;
2539    enforce_incremental_projection_limits(root, &affected_paths, persisted_footprint, &staged)?;
2540    staged.source_recovery = sources.work;
2541    Ok(staged)
2542}
2543
2544/// Entity/key facts prepared before relation resolution.
2545struct EntityProjection {
2546    /// Entities keyed by their compact digest for collision-safe lookup.
2547    entity_by_digest: BTreeMap<String, GraphEntity>,
2548    /// File and symbol owners keyed by parser graph path.
2549    owners_by_graph: BTreeMap<String, GraphOwners>,
2550    /// Canonical resolution-key projections keyed by parser graph path.
2551    keys_by_graph: BTreeMap<String, ResolutionKeyProjection>,
2552    /// Canonical resolution keys exported by staged entities.
2553    entity_exports: Vec<EntityResolutionKey>,
2554    /// Independently resolved forwarding outcomes, separate from invalidation keys.
2555    call_bindings: binding_resolution::CallBindings,
2556    /// Conservative bytes retained by entities and export keys.
2557    retained_bytes: u64,
2558    /// Test-visible peak of the map-plus-entity staging estimate.
2559    #[cfg(test)]
2560    peak_retained_bytes: u64,
2561    /// Test-visible order proving each projection moved before entity allocation.
2562    #[cfg(test)]
2563    projection_removals_before_entities: Vec<String>,
2564}
2565
2566/// File and symbol entities associated with one parser graph.
2567struct GraphOwners {
2568    /// Digest of the file entity owning the parser graph.
2569    file_digest: String,
2570    /// Optional stable entity digest corresponding to each parser symbol row.
2571    symbol_digests: Vec<Option<String>>,
2572}
2573
2574/// Recognize include syntax without treating unknown or legacy import text as alias-free.
2575fn php_file_include_context(context: &str) -> bool {
2576    let context = context.trim_start_matches(|character: char| character.is_ascii_whitespace());
2577    ["include", "include_once", "require", "require_once"]
2578        .iter()
2579        .any(|keyword| {
2580            context
2581                .get(..keyword.len())
2582                .is_some_and(|prefix| prefix.eq_ignore_ascii_case(keyword))
2583                && context.get(keyword.len()..).is_some_and(|suffix| {
2584                    suffix.starts_with(|character: char| character.is_ascii_whitespace())
2585                        || suffix.starts_with(['(', '\'', '"', '#'])
2586                        || suffix.starts_with("/*")
2587                        || suffix.starts_with("//")
2588                })
2589        })
2590}
2591
2592/// Borrowed per-graph symbol lookup used by every relation in that file.
2593struct GraphSymbolIndex<'graph> {
2594    /// Explicit source policy, including an empty set that cannot authorize guesses.
2595    source_calls: Option<BTreeMap<usize, &'graph SourceCallFact>>,
2596    /// Source-proven Rust import ownership, including explicit non-toolchain rows.
2597    rust_toolchain_imports:
2598        Option<&'graph BTreeMap<usize, Option<projectatlas_symbols::ImportReference>>>,
2599    /// Parser symbol indices grouped by exact source name.
2600    indices_by_name: BTreeMap<&'graph str, Vec<usize>>,
2601    /// PHP call target indices grouped by their case-insensitive source name.
2602    php_call_indices_by_name: BTreeMap<String, Vec<usize>>,
2603    /// Import positions retained separately across repeated namespace declarations.
2604    php_import_positions: BTreeMap<String, BTreeMap<usize, Option<usize>>>,
2605    /// Named namespace declaration boundaries; tied line-only blocks stay conservative.
2606    php_namespace_start_lines: BTreeMap<String, BTreeSet<usize>>,
2607    /// Omitted import declarations cannot prove a namespace-local alias boundary.
2608    php_imports_have_unknown_scope: bool,
2609}
2610
2611impl<'graph> GraphSymbolIndex<'graph> {
2612    /// Build one bounded name index instead of rescanning all symbols per relation.
2613    fn new(graph: &'graph SymbolGraph, control: &IndexWorkControl) -> Result<Self, CliError> {
2614        let mut indices_by_name = BTreeMap::new();
2615        let mut php_call_indices_by_name = BTreeMap::new();
2616        let mut php_import_positions = BTreeMap::<String, BTreeMap<usize, Option<usize>>>::new();
2617        let mut php_namespace_start_lines = BTreeMap::<String, BTreeSet<usize>>::new();
2618        let mut php_imports_have_unknown_scope = false;
2619        let is_php = graph
2620            .language
2621            .as_deref()
2622            .is_some_and(|language| language.eq_ignore_ascii_case("php"));
2623        // Grouped PHP uses emit one symbol but several relations. Only equal
2624        // per-line counts prove that occurrence pairing preserves source order.
2625        let mut import_counts = HashMap::<usize, (usize, usize)>::new();
2626        let paired_imports = if is_php {
2627            for (index, symbol) in graph.symbols.iter().enumerate() {
2628                check_graph_work(control, index)?;
2629                if symbol.kind == SymbolKind::Import {
2630                    import_counts.entry(symbol.line_start).or_default().0 += 1;
2631                }
2632            }
2633            for (index, relation) in graph.relations.iter().enumerate() {
2634                check_graph_work(control, index)?;
2635                if relation.kind == RelationKind::Imports {
2636                    import_counts.entry(relation.line).or_default().1 += 1;
2637                }
2638            }
2639            Some(paired_import_relations(graph, control)?)
2640        } else {
2641            None
2642        };
2643        for (index, symbol) in graph.symbols.iter().enumerate() {
2644            check_graph_work(control, index)?;
2645            // PHP emits import symbols only for namespace use, not includes or traits.
2646            if is_php && symbol.kind == SymbolKind::Import {
2647                let ordinal = import_counts
2648                    .get(&symbol.line_start)
2649                    .filter(|(symbols, relations)| symbols == relations)
2650                    .and(paired_imports.as_ref())
2651                    .and_then(|paired| paired.by_symbol.get(index).copied().flatten());
2652                php_import_positions
2653                    .entry(symbol.parent.as_deref().unwrap_or("").to_ascii_lowercase())
2654                    .or_default()
2655                    .entry(symbol.line_start)
2656                    .or_insert(ordinal);
2657            }
2658            if is_php && symbol.kind == SymbolKind::Module {
2659                php_namespace_start_lines
2660                    .entry(symbol.name.to_ascii_lowercase())
2661                    .or_default()
2662                    .insert(symbol.line_start);
2663            }
2664            indices_by_name
2665                .entry(symbol.name.as_str())
2666                .or_insert_with(Vec::new)
2667                .push(index);
2668            if is_php && matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method) {
2669                php_call_indices_by_name
2670                    .entry(symbol.name.to_ascii_lowercase())
2671                    .or_insert_with(Vec::new)
2672                    .push(index);
2673            }
2674        }
2675        // Partial extraction may retain an import relation after its symbol was omitted.
2676        if is_php && graph.parser != ParserKind::TreeSitter {
2677            for (index, relation) in graph.relations.iter().enumerate() {
2678                check_graph_work(control, index)?;
2679                if relation.kind == RelationKind::Imports
2680                    && relation.source_name == MODULE_RELATION_SOURCE
2681                    && !php_file_include_context(&relation.context)
2682                {
2683                    php_imports_have_unknown_scope = true;
2684                    break;
2685                }
2686            }
2687        }
2688        Ok(Self {
2689            source_calls: None,
2690            rust_toolchain_imports: None,
2691            indices_by_name,
2692            php_call_indices_by_name,
2693            php_import_positions,
2694            php_namespace_start_lines,
2695            php_imports_have_unknown_scope,
2696        })
2697    }
2698
2699    /// Index admitted call policies once per graph, with exact ordinal uniqueness.
2700    fn with_source_calls(
2701        mut self,
2702        calls: Option<&'graph [SourceCallFact]>,
2703        control: &IndexWorkControl,
2704    ) -> Result<Self, CliError> {
2705        if let Some(calls) = calls {
2706            let mut indexed = BTreeMap::new();
2707            for call in calls {
2708                control.check(IndexWorkStage::SymbolParsing)?;
2709                if indexed.insert(call.relation, call).is_some() {
2710                    return Err(CliError::InvalidInput(
2711                        "duplicate source call ordinal".to_string(),
2712                    ));
2713                }
2714            }
2715            self.source_calls = Some(indexed);
2716        }
2717        Ok(self)
2718    }
2719
2720    /// Borrow the already validated Rust import policy without copying its index.
2721    fn with_rust_toolchain_imports(
2722        mut self,
2723        imports: Option<&'graph BTreeMap<usize, Option<projectatlas_symbols::ImportReference>>>,
2724    ) -> Self {
2725        self.rust_toolchain_imports = imports;
2726        self
2727    }
2728
2729    /// Return exact parser symbol rows for one source name.
2730    fn get(&self, name: &str) -> &[usize] {
2731        self.indices_by_name.get(name).map_or(&[], Vec::as_slice)
2732    }
2733
2734    /// Return PHP parser symbol rows for one case-insensitive call name.
2735    fn get_php_call(&self, name: &str) -> &[usize] {
2736        self.php_call_indices_by_name
2737            .get(&name.to_ascii_lowercase())
2738            .map_or(&[], Vec::as_slice)
2739    }
2740}
2741
2742/// One conservative additive relation derived from already bounded parser facts.
2743struct DerivedRelationFact {
2744    /// Additive graph relation kind.
2745    kind: ExtendedRelationKind,
2746    /// Parser-compatible source, target, span, context, and trust facts.
2747    relation: SymbolRelation,
2748    /// Resolution strategy for the derived target.
2749    target: DerivedRelationTarget,
2750}
2751
2752/// Closed target classes accepted by additive relation projection.
2753enum DerivedRelationTarget {
2754    /// Resolve through the same typed provider keys as one parser relation.
2755    Parser {
2756        /// Canonical provider-owned dependency keys.
2757        keys: Vec<CanonicalResolutionKey>,
2758    },
2759    /// Resolve one statically visible repository-relative file path.
2760    RepositoryPath,
2761    /// Bind one content-free external namespace and identity.
2762    External {
2763        /// Closed external namespace.
2764        system: &'static str,
2765    },
2766}
2767
2768/// Package names assigned by longest manifest-directory ownership.
2769struct PackageIndex {
2770    /// Admitted owned package-vector/string envelope, excluding shared cohorts.
2771    retained_bytes: u64,
2772    /// Cargo packages ordered from the most-specific repository root.
2773    packages: Vec<PackageOwner>,
2774}
2775
2776/// One Cargo package and its owning repository prefix.
2777struct PackageOwner {
2778    /// Repository prefix owned by this package.
2779    root: String,
2780    /// Canonical package name from the manifest graph.
2781    name: String,
2782    /// Repository-relative manifest path proving ownership.
2783    manifest: String,
2784    /// Derived source-proved sibling roots; absent in graph-only compatibility fixtures.
2785    cohorts: Option<Arc<CargoRootCohortIndex>>,
2786}
2787
2788impl PackageIndex {
2789    /// Build deterministic longest-prefix package ownership from manifest graphs.
2790    #[cfg(test)]
2791    fn from_graphs(graphs: &[impl Borrow<SymbolGraph>]) -> Result<Self, CliError> {
2792        Self::from_graph_iter(graphs.iter().map(Borrow::borrow), &mut |_| Ok(()))
2793    }
2794
2795    /// Build package names without discarding source proof at production call sites.
2796    fn from_graph_iter<'a>(
2797        graphs: impl IntoIterator<Item = &'a SymbolGraph>,
2798        reserve: &mut impl FnMut(u64) -> Result<(), CliError>,
2799    ) -> Result<Self, CliError> {
2800        let mut packages = Vec::new();
2801        let mut retained_bytes = 0_u64;
2802        for graph in graphs {
2803            for symbol in &graph.symbols {
2804                if symbol.kind != SymbolKind::Package {
2805                    continue;
2806                }
2807                RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?;
2808                let bytes = (4 * std::mem::size_of::<PackageOwner>()) as u64
2809                    + (graph.path.len() as u64).saturating_mul(2)
2810                    + symbol.name.len() as u64;
2811                reserve(bytes)?;
2812                retained_bytes = retained_bytes.saturating_add(bytes);
2813                let root = graph
2814                    .path
2815                    .rsplit_once('/')
2816                    .map_or(String::new(), |(parent, _manifest)| parent.to_string());
2817                packages.push(PackageOwner {
2818                    root,
2819                    name: symbol.name.clone(),
2820                    manifest: graph.path.clone(),
2821                    cohorts: None,
2822                });
2823            }
2824        }
2825        packages.sort_by(|left, right| {
2826            right
2827                .root
2828                .len()
2829                .cmp(&left.root.len())
2830                .then_with(|| left.root.cmp(&right.root))
2831                .then_with(|| left.name.cmp(&right.name))
2832                .then_with(|| left.manifest.cmp(&right.manifest))
2833        });
2834        packages.dedup_by(|left, right| {
2835            left.root == right.root && left.name == right.name && left.manifest == right.manifest
2836        });
2837        Ok(Self {
2838            retained_bytes,
2839            packages,
2840        })
2841    }
2842
2843    /// Return the most-specific Cargo package owning one repository path.
2844    fn package_name(&self, path: &str) -> Option<&str> {
2845        self.package_owner(path)
2846            .map(|package| package.name.as_str())
2847    }
2848
2849    /// Borrow the manifest identity and directory proving the most-specific owner.
2850    fn package_owner(&self, path: &str) -> Option<&PackageOwner> {
2851        self.packages
2852            .iter()
2853            .find(|package| repository_path_belongs_to(path, &package.root))
2854    }
2855}
2856
2857/// Return whether one path belongs to a normalized repository prefix.
2858fn repository_path_belongs_to(path: &str, root: &str) -> bool {
2859    root.is_empty()
2860        || path == root
2861        || path
2862            .strip_prefix(root)
2863            .is_some_and(|suffix| suffix.starts_with('/'))
2864}
2865
2866/// Return whether one normalized repository path is a Cargo package manifest.
2867fn is_cargo_manifest_path(path: &str) -> bool {
2868    path == "Cargo.toml" || path.ends_with("/Cargo.toml")
2869}
2870
2871/// Use the parser-owned canonical parent calculation for graph publication.
2872#[cfg(test)]
2873fn qualified_symbol_parents(
2874    graph: &SymbolGraph,
2875) -> Result<Vec<Option<GraphIdentityText>>, CliError> {
2876    projectatlas_symbols::declaration_identity::qualified_symbol_parents(graph)
2877        .map_err(invalid_graph_contract)
2878}
2879
2880/// Admit parser-owned text through the same source identity boundary.
2881#[cfg(test)]
2882fn source_symbol_identity(value: String) -> Result<GraphIdentityText, CliError> {
2883    projectatlas_symbols::declaration_identity::source_symbol_identity(value)
2884        .map_err(invalid_graph_contract)
2885}
2886
2887/// Exercise the shared qualified identity owner from projection contract tests.
2888#[cfg(test)]
2889fn qualified_symbol_identity(
2890    parent: &GraphIdentityText,
2891    name: &GraphIdentityText,
2892) -> Result<GraphIdentityText, CliError> {
2893    projectatlas_symbols::declaration_identity::qualified_symbol_identity(parent, name)
2894        .map_err(invalid_graph_contract)
2895}
2896
2897/// Project file, symbol, package, and canonical export facts from parser graphs.
2898#[cfg(test)]
2899fn build_entity_projection(
2900    project: ProjectInstanceId,
2901    generation: IndexGeneration,
2902    nodes: &[Node],
2903    graphs: &[impl Borrow<SymbolGraph>],
2904    packages: &PackageIndex,
2905    include_project: bool,
2906    control: &IndexWorkControl,
2907) -> Result<EntityProjection, CliError> {
2908    build_entity_projection_with_config(
2909        project,
2910        generation,
2911        nodes,
2912        graphs,
2913        packages,
2914        &ConfiguredModuleResolution::default(),
2915        None,
2916        include_project,
2917        control,
2918    )
2919}
2920
2921/// Project entity/key facts with one shared configured-module snapshot.
2922#[cfg(test)]
2923#[allow(clippy::too_many_arguments)]
2924fn build_entity_projection_with_config(
2925    project: ProjectInstanceId,
2926    generation: IndexGeneration,
2927    nodes: &[Node],
2928    graphs: &[impl Borrow<SymbolGraph>],
2929    packages: &PackageIndex,
2930    configured_modules: &ConfiguredModuleResolution,
2931    admitted_projections: Option<&mut BTreeMap<String, ResolutionKeyProjection>>,
2932    include_project: bool,
2933    control: &IndexWorkControl,
2934) -> Result<EntityProjection, CliError> {
2935    build_entity_projection_with_config_limit(
2936        project,
2937        generation,
2938        nodes,
2939        graphs,
2940        packages,
2941        configured_modules,
2942        admitted_projections,
2943        include_project,
2944        control,
2945        super::MAX_PUBLICATION_STAGING_BYTES,
2946    )
2947}
2948
2949/// Admit one export's owned key/owner copies and vector growth before cloning.
2950fn reserve_entity_export(
2951    owner: &projectatlas_core::graph::GraphEntityKey,
2952    key: &CanonicalResolutionKey,
2953    bytes: &mut u64,
2954    coexisting: u64,
2955    limit: u64,
2956) -> Result<(), CliError> {
2957    let next = bytes
2958        .saturating_add((4 * std::mem::size_of::<EntityResolutionKey>()) as u64)
2959        .saturating_add(owner.canonical_identity().len() as u64)
2960        .saturating_add(key.canonical_identity().len() as u64);
2961    enforce_resolution_registry_budget_with_limit(next.saturating_add(coexisting), limit)?;
2962    *bytes = next;
2963    Ok(())
2964}
2965
2966/// Project entity/key facts with a caller-selected staging budget for proof seams.
2967#[allow(clippy::too_many_arguments)]
2968fn build_entity_projection_with_config_limit(
2969    project: ProjectInstanceId,
2970    generation: IndexGeneration,
2971    nodes: &[Node],
2972    graphs: &[impl Borrow<SymbolGraph>],
2973    packages: &PackageIndex,
2974    configured_modules: &ConfiguredModuleResolution,
2975    mut admitted_projections: Option<&mut BTreeMap<String, ResolutionKeyProjection>>,
2976    include_project: bool,
2977    control: &IndexWorkControl,
2978    staging_limit: u64,
2979) -> Result<EntityProjection, CliError> {
2980    let mut admitted_map_bytes = admitted_projections
2981        .as_deref()
2982        .map(resolution_projection_map_retained_bytes)
2983        .unwrap_or_default();
2984    enforce_resolution_registry_budget_with_limit(admitted_map_bytes, staging_limit)?;
2985    let mut entity_by_digest = BTreeMap::new();
2986    let mut entity_exports = Vec::new();
2987    let mut entity_bytes = 0_u64;
2988    if include_project {
2989        let entity = GraphEntity::new(project, EntitySelector::Project, generation)
2990            .map_err(invalid_graph_contract)?;
2991        entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&entity));
2992        enforce_resolution_registry_budget_with_limit(
2993            entity_bytes.saturating_add(admitted_map_bytes),
2994            staging_limit,
2995        )?;
2996        insert_entity(&mut entity_by_digest, entity)?;
2997    }
2998    for node in nodes {
2999        control.check(IndexWorkStage::SymbolParsing)?;
3000        let selector = match node.kind {
3001            NodeKind::Folder => EntitySelector::Folder {
3002                path: RepositoryNodePath::new(Path::new(&node.path))
3003                    .map_err(invalid_graph_contract)?,
3004            },
3005            NodeKind::File => EntitySelector::File {
3006                path: RepositoryFilePath::new(Path::new(&node.path))
3007                    .map_err(invalid_graph_contract)?,
3008            },
3009        };
3010        let entity =
3011            GraphEntity::new(project, selector, generation).map_err(invalid_graph_contract)?;
3012        entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&entity));
3013        if node.kind == NodeKind::File {
3014            for key in [
3015                document_file_resolution_key(project, &node.path)?,
3016                document_casefold_resolution_key(project, &node.path)?,
3017            ] {
3018                reserve_entity_export(
3019                    entity.key(),
3020                    &key,
3021                    &mut entity_bytes,
3022                    admitted_map_bytes,
3023                    staging_limit,
3024                )?;
3025                entity_exports.push(
3026                    EntityResolutionKey::new(entity.key().clone(), key)
3027                        .map_err(invalid_graph_contract)?,
3028                );
3029                entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
3030            }
3031        }
3032        enforce_resolution_registry_budget_with_limit(
3033            entity_bytes.saturating_add(admitted_map_bytes),
3034            staging_limit,
3035        )?;
3036        insert_entity(&mut entity_by_digest, entity)?;
3037    }
3038
3039    let mut owners_by_graph = BTreeMap::new();
3040    let mut keys_by_graph = BTreeMap::new();
3041    let mut retained_bytes = 0_u64;
3042
3043    enforce_resolution_registry_budget_with_limit(
3044        admitted_map_bytes.saturating_add(entity_bytes),
3045        staging_limit,
3046    )?;
3047    #[cfg(test)]
3048    let mut peak_retained_bytes = admitted_map_bytes.saturating_add(entity_bytes);
3049    #[cfg(test)]
3050    let mut projection_removals_before_entities = Vec::new();
3051    for graph in graphs {
3052        let graph = graph.borrow();
3053        control.check(IndexWorkStage::SymbolParsing)?;
3054        let resolution = if let Some(projections) = admitted_projections.as_deref_mut() {
3055            let resolution = projections.remove(&graph.path).ok_or_else(|| {
3056                CliError::InvalidInput(format!(
3057                    "resolution keys were not admitted for graph {}",
3058                    graph.path
3059                ))
3060            })?;
3061            admitted_map_bytes = admitted_map_bytes.saturating_sub(
3062                resolution_projection_map_entry_retained_bytes(&graph.path, &resolution),
3063            );
3064            #[cfg(test)]
3065            projection_removals_before_entities.push(graph.path.clone());
3066            resolution
3067        } else {
3068            resolution_projection_with_config(
3069                project,
3070                packages,
3071                graph,
3072                configured_modules,
3073                staging_limit
3074                    .saturating_sub(admitted_map_bytes)
3075                    .saturating_sub(entity_bytes),
3076            )?
3077        };
3078        let resolution_bytes = resolution_retained_bytes(&resolution);
3079        entity_bytes = entity_bytes.saturating_add(resolution_bytes);
3080        #[cfg(test)]
3081        {
3082            peak_retained_bytes =
3083                peak_retained_bytes.max(admitted_map_bytes.saturating_add(entity_bytes));
3084        }
3085        enforce_resolution_registry_budget_with_limit(
3086            admitted_map_bytes.saturating_add(entity_bytes),
3087            staging_limit,
3088        )?;
3089        let file = GraphEntity::new(
3090            project,
3091            EntitySelector::File {
3092                path: RepositoryFilePath::new(Path::new(&graph.path))
3093                    .map_err(invalid_graph_contract)?,
3094            },
3095            generation,
3096        )
3097        .map_err(invalid_graph_contract)?;
3098        let file_digest = file.key().digest().to_string();
3099        entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&file));
3100        insert_entity(&mut entity_by_digest, file)?;
3101        let mut symbol_digests = Vec::with_capacity(graph.symbols.len());
3102        entity_bytes = entity_bytes.saturating_add(
3103            STAGED_GRAPH_ROW_BYTES
3104                .saturating_mul(u64::try_from(navigable_symbol_count(graph)).unwrap_or(u64::MAX)),
3105        );
3106        let qualified_parents =
3107            projectatlas_symbols::declaration_identity::qualified_symbol_parents_checked(
3108                graph,
3109                &mut |bytes| {
3110                    let next = entity_bytes.saturating_add(bytes);
3111                    enforce_resolution_registry_budget_with_limit(
3112                        next.saturating_add(admitted_map_bytes),
3113                        staging_limit,
3114                    )?;
3115                    entity_bytes = next;
3116                    Ok::<_, CliError>(())
3117                },
3118            )?;
3119        for (symbol, qualified_parent) in graph.symbols.iter().zip(qualified_parents) {
3120            control.check(IndexWorkStage::SymbolParsing)?;
3121            if is_document_coverage_marker(graph, symbol) {
3122                symbol_digests.push(None);
3123                continue;
3124            }
3125            let entity = match symbol.kind {
3126                SymbolKind::Import | SymbolKind::Dependency | SymbolKind::Workspace => None,
3127                SymbolKind::Package => Some(
3128                    GraphEntity::new(
3129                        project,
3130                        EntitySelector::Package {
3131                            package: PackageSelector {
3132                                manager: GraphIdentityText::new(CARGO_PACKAGE_MANAGER)
3133                                    .map_err(invalid_graph_contract)?,
3134                                name: GraphIdentityText::new(symbol.name.clone())
3135                                    .map_err(invalid_graph_contract)?,
3136                                manifest: RepositoryFilePath::new(Path::new(&graph.path))
3137                                    .map_err(invalid_graph_contract)?,
3138                            },
3139                        },
3140                        generation,
3141                    )
3142                    .map_err(invalid_graph_contract)?,
3143                ),
3144                _ => Some(source_symbol_entity(
3145                    project,
3146                    generation,
3147                    graph,
3148                    symbol,
3149                    qualified_parent,
3150                )?),
3151            };
3152            let entity_digest = entity
3153                .as_ref()
3154                .map(|entity| entity.key().digest().to_string());
3155            if let Some(entity) = entity {
3156                entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&entity));
3157                if symbol.kind == SymbolKind::Heading {
3158                    entity_exports.push(
3159                        EntityResolutionKey::new(
3160                            entity.key().clone(),
3161                            document_heading_resolution_key(
3162                                project,
3163                                &graph.path,
3164                                &symbol.signature,
3165                            )?,
3166                        )
3167                        .map_err(invalid_graph_contract)?,
3168                    );
3169                    entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
3170                }
3171                enforce_resolution_registry_budget_with_limit(
3172                    entity_bytes.saturating_add(admitted_map_bytes),
3173                    staging_limit,
3174                )?;
3175                insert_entity(&mut entity_by_digest, entity)?;
3176            }
3177            symbol_digests.push(entity_digest);
3178        }
3179        let file = entity_by_digest
3180            .get(&file_digest)
3181            .ok_or_else(|| CliError::InvalidInput("graph file owner was not staged".to_string()))?;
3182        for key in resolution.source_keys() {
3183            reserve_entity_export(
3184                file.key(),
3185                key,
3186                &mut entity_bytes,
3187                admitted_map_bytes,
3188                staging_limit,
3189            )?;
3190            entity_exports.push(
3191                EntityResolutionKey::new(file.key().clone(), key.clone())
3192                    .map_err(invalid_graph_contract)?,
3193            );
3194            entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
3195        }
3196        for export in resolution.export_keys() {
3197            for key in export.keys() {
3198                reserve_entity_export(
3199                    file.key(),
3200                    key,
3201                    &mut entity_bytes,
3202                    admitted_map_bytes,
3203                    staging_limit,
3204                )?;
3205                entity_exports.push(
3206                    EntityResolutionKey::new(file.key().clone(), key.clone())
3207                        .map_err(invalid_graph_contract)?,
3208                );
3209                entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
3210            }
3211        }
3212        for symbol_keys in resolution.symbol_keys() {
3213            let Some(entity) = symbol_digests
3214                .get(symbol_keys.symbol_index())
3215                .and_then(Option::as_ref)
3216                .and_then(|digest| entity_by_digest.get(digest))
3217            else {
3218                continue;
3219            };
3220            for key in symbol_keys.keys() {
3221                reserve_entity_export(
3222                    entity.key(),
3223                    key,
3224                    &mut entity_bytes,
3225                    admitted_map_bytes,
3226                    staging_limit,
3227                )?;
3228                entity_exports.push(
3229                    EntityResolutionKey::new(entity.key().clone(), key.clone())
3230                        .map_err(invalid_graph_contract)?,
3231                );
3232                entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
3233            }
3234        }
3235        entity_bytes = entity_bytes
3236            .saturating_add(STAGED_GRAPH_ROW_BYTES)
3237            .saturating_add(graph.path.len() as u64);
3238        #[cfg(test)]
3239        {
3240            peak_retained_bytes =
3241                peak_retained_bytes.max(admitted_map_bytes.saturating_add(entity_bytes));
3242        }
3243        enforce_resolution_registry_budget_with_limit(
3244            admitted_map_bytes.saturating_add(entity_bytes),
3245            staging_limit,
3246        )?;
3247        retained_bytes = retained_bytes
3248            .saturating_add(resolution_projection_map_entry_retained_bytes(
3249                &graph.path,
3250                &resolution,
3251            ))
3252            .saturating_add(STAGED_GRAPH_ROW_BYTES)
3253            .saturating_add(graph.path.len() as u64)
3254            .saturating_add(file_digest.capacity() as u64)
3255            .saturating_add(
3256                (symbol_digests.capacity() * std::mem::size_of::<Option<String>>()) as u64,
3257            )
3258            .saturating_add(
3259                symbol_digests
3260                    .iter()
3261                    .flatten()
3262                    .map(|digest| digest.capacity() as u64)
3263                    .fold(0, u64::saturating_add),
3264            );
3265        owners_by_graph.insert(
3266            graph.path.clone(),
3267            GraphOwners {
3268                file_digest,
3269                symbol_digests,
3270            },
3271        );
3272        keys_by_graph.insert(graph.path.clone(), resolution);
3273    }
3274    sort_dedup_exports(&mut entity_exports);
3275    enforce_key_binding_limit(entity_exports.len())?;
3276    retained_bytes = entity_by_digest
3277        .values()
3278        .fold(retained_bytes, |bytes, entity| {
3279            bytes.saturating_add(entity_retained_bytes(entity))
3280        })
3281        .saturating_add(
3282            entity_exports.iter().fold(
3283                (entity_exports.capacity() as u64)
3284                    .saturating_mul(std::mem::size_of::<EntityResolutionKey>() as u64),
3285                |bytes, export| {
3286                    bytes
3287                        .saturating_add(export.key().retained_heap_bytes())
3288                        .saturating_add(export.entity().canonical_identity().len() as u64)
3289                },
3290            ),
3291        );
3292    #[cfg(test)]
3293    {
3294        peak_retained_bytes = peak_retained_bytes.max(retained_bytes);
3295    }
3296    enforce_resolution_registry_budget_with_limit(retained_bytes, staging_limit)?;
3297    Ok(EntityProjection {
3298        entity_by_digest,
3299        owners_by_graph,
3300        keys_by_graph,
3301        entity_exports,
3302        call_bindings: BTreeMap::new(),
3303        retained_bytes,
3304        #[cfg(test)]
3305        peak_retained_bytes,
3306        #[cfg(test)]
3307        projection_removals_before_entities,
3308    })
3309}
3310
3311/// Construct an actual source declaration through the common canonical selector owner.
3312fn source_symbol_entity(
3313    project: ProjectInstanceId,
3314    generation: IndexGeneration,
3315    graph: &SymbolGraph,
3316    symbol: &CodeSymbol,
3317    parent: Option<GraphIdentityText>,
3318) -> Result<GraphEntity, CliError> {
3319    GraphEntity::new(
3320        project,
3321        EntitySelector::Symbol {
3322            symbol: SymbolSelector {
3323                file: RepositoryFilePath::new(Path::new(&graph.path))
3324                    .map_err(invalid_graph_contract)?,
3325                name: GraphIdentityText::new(symbol.name.clone())
3326                    .map_err(invalid_graph_contract)?,
3327                kind: symbol.kind,
3328                parent,
3329                signature: GraphIdentityText::new(if symbol.signature.trim().is_empty() {
3330                    symbol.name.clone()
3331                } else {
3332                    symbol.signature.trim().to_string()
3333                })
3334                .map_err(invalid_graph_contract)?,
3335            },
3336        },
3337        generation,
3338    )
3339    .map_err(invalid_graph_contract)
3340}
3341
3342/// Acquire the one project-local graph-staging lease without waiting.
3343fn try_graph_stage_lease(staging_parent: &Path) -> Result<Option<File>, CliError> {
3344    let path = staging_parent.join(GRAPH_STAGE_LEASE_FILE_NAME);
3345    if let Ok(metadata) = fs::symlink_metadata(&path)
3346        && (!metadata.file_type().is_file() || metadata.file_type().is_symlink())
3347    {
3348        return Err(CliError::InvalidInput(format!(
3349            "repository graph staging lease is not a direct file: {}",
3350            normalize_native_path_display(&path)
3351        )));
3352    }
3353    let file = OpenOptions::new()
3354        .read(true)
3355        .write(true)
3356        .create(true)
3357        .truncate(false)
3358        .open(&path)
3359        .map_err(|source| CliError::Io {
3360            path: path.clone(),
3361            source,
3362        })?;
3363    match file.try_lock() {
3364        Ok(()) => Ok(Some(file)),
3365        Err(fs::TryLockError::WouldBlock) => Ok(None),
3366        Err(fs::TryLockError::Error(source)) => Err(CliError::Io { path, source }),
3367    }
3368}
3369
3370/// Remove inactive disposable graph stages owned by the selected project.
3371pub(super) fn cleanup_abandoned_repository_graph_staging(
3372    store: &AtlasStore,
3373    root: &Path,
3374    control: &IndexWorkControl,
3375) -> Result<(), CliError> {
3376    cleanup_abandoned_graph_staging(root, selected_project(store)?, control)
3377}
3378
3379/// Remove only validated, inactive disposable graph stages left by an earlier process.
3380fn cleanup_abandoned_graph_staging(
3381    root: &Path,
3382    project: ProjectInstanceId,
3383    control: &IndexWorkControl,
3384) -> Result<(), CliError> {
3385    control.check(IndexWorkStage::Publication)?;
3386    let staging_parent = root.join(".projectatlas");
3387    if !staging_parent.is_dir() {
3388        return Ok(());
3389    }
3390    let Some(_lease) = try_graph_stage_lease(&staging_parent)? else {
3391        return Ok(());
3392    };
3393    cleanup_abandoned_graph_staging_while_locked(&staging_parent, root, project, control)
3394}
3395
3396/// Remove stage payloads while retaining the validated ownership database as a crash marker.
3397fn remove_owned_graph_stage_payload(
3398    stage: &Path,
3399    database_path: &Path,
3400    control: Option<&IndexWorkControl>,
3401) -> Result<(), CliError> {
3402    let entries = fs::read_dir(stage).map_err(|source| CliError::Io {
3403        path: stage.to_path_buf(),
3404        source,
3405    })?;
3406    for entry in entries {
3407        if let Some(control) = control {
3408            control.check(IndexWorkStage::Publication)?;
3409        }
3410        let entry = entry.map_err(|source| CliError::Io {
3411            path: stage.to_path_buf(),
3412            source,
3413        })?;
3414        let path = entry.path();
3415        if path == database_path {
3416            continue;
3417        }
3418        let metadata = fs::symlink_metadata(&path).map_err(|source| CliError::Io {
3419            path: path.clone(),
3420            source,
3421        })?;
3422        let result = if metadata.file_type().is_symlink() {
3423            remove_graph_stage_symlink(&path)
3424        } else if metadata.file_type().is_dir() {
3425            fs::remove_dir_all(&path)
3426        } else {
3427            fs::remove_file(&path)
3428        };
3429        result.map_err(|source| CliError::Io { path, source })?;
3430    }
3431    Ok(())
3432}
3433
3434/// Remove only a graph-stage symlink leaf, never its target.
3435#[cfg(windows)]
3436fn remove_graph_stage_symlink(path: &Path) -> std::io::Result<()> {
3437    fs::remove_dir(path).or_else(|_directory_error| fs::remove_file(path))
3438}
3439
3440/// Remove only a graph-stage symlink leaf, never its target.
3441#[cfg(not(windows))]
3442fn remove_graph_stage_symlink(path: &Path) -> std::io::Result<()> {
3443    fs::remove_file(path)
3444}
3445
3446/// Remove direct child stages whose typed database binds the exact project.
3447fn cleanup_abandoned_graph_staging_while_locked(
3448    staging_parent: &Path,
3449    root: &Path,
3450    project: ProjectInstanceId,
3451    control: &IndexWorkControl,
3452) -> Result<(), CliError> {
3453    let entries = fs::read_dir(staging_parent).map_err(|source| CliError::Io {
3454        path: staging_parent.to_path_buf(),
3455        source,
3456    })?;
3457    for entry in entries {
3458        control.check(IndexWorkStage::Publication)?;
3459        let entry = entry.map_err(|source| CliError::Io {
3460            path: staging_parent.to_path_buf(),
3461            source,
3462        })?;
3463        let file_name = entry.file_name();
3464        let Some(file_name) = file_name.to_str() else {
3465            continue;
3466        };
3467        if !file_name.starts_with(GRAPH_STAGE_DIRECTORY_PREFIX)
3468            || file_name.len() == GRAPH_STAGE_DIRECTORY_PREFIX.len()
3469        {
3470            continue;
3471        }
3472        let path = entry.path();
3473        let Ok(metadata) = fs::symlink_metadata(&path) else {
3474            continue;
3475        };
3476        if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
3477            continue;
3478        }
3479        let database_path = path.join(GRAPH_STAGE_DATABASE_FILE_NAME);
3480        let database_metadata = match fs::symlink_metadata(&database_path) {
3481            Ok(metadata) => metadata,
3482            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
3483                let _remove_empty_shell = fs::remove_dir(&path);
3484                continue;
3485            }
3486            Err(_) => continue,
3487        };
3488        if !database_metadata.file_type().is_file() || database_metadata.file_type().is_symlink() {
3489            continue;
3490        }
3491        let owned = AtlasStore::repository_graph_staging_belongs_to(&database_path, root, project)
3492            .unwrap_or(false);
3493        if !owned {
3494            continue;
3495        }
3496        control.check(IndexWorkStage::Publication)?;
3497        remove_owned_graph_stage_payload(&path, &database_path, Some(control))?;
3498        control.check(IndexWorkStage::Publication)?;
3499        fs::remove_file(&database_path).map_err(|source| CliError::Io {
3500            path: database_path,
3501            source,
3502        })?;
3503        fs::remove_dir(&path).map_err(|source| CliError::Io { path, source })?;
3504    }
3505    Ok(())
3506}
3507
3508/// Spill a large full graph to typed disposable `SQLite` rows before main publication.
3509fn finish_projection_in_database_with_documents(
3510    root: &Path,
3511    nodes: &[Node],
3512    project: ProjectInstanceId,
3513    generation: IndexGeneration,
3514    graphs: &[impl Borrow<SymbolGraph>],
3515    document_facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
3516    identity_admission: &GraphIdentityAdmission,
3517    mut entities: EntityProjection,
3518    candidates: &ProjectResolutionRegistry,
3519    scan_policy: &RootScanPolicy,
3520    control: &IndexWorkControl,
3521) -> Result<StagedRepositoryGraph, CliError> {
3522    #[cfg(test)]
3523    let peak_retained_bytes = entities.peak_retained_bytes;
3524    #[cfg(test)]
3525    let projection_removals_before_entities = entities.projection_removals_before_entities.clone();
3526    let document_index = DocumentResolutionIndex::new(root, nodes, scan_policy)?;
3527    let staging_parent = root.join(".projectatlas");
3528    fs::create_dir_all(&staging_parent).map_err(|source| CliError::Io {
3529        path: staging_parent.clone(),
3530        source,
3531    })?;
3532    let lease = try_graph_stage_lease(&staging_parent)?.ok_or_else(|| {
3533        CliError::InvalidInput(
3534            "another repository graph staging operation is active for this project".to_string(),
3535        )
3536    })?;
3537    cleanup_abandoned_graph_staging_while_locked(&staging_parent, root, project, control)?;
3538    let directory = TempDirBuilder::new()
3539        .prefix(GRAPH_STAGE_DIRECTORY_PREFIX)
3540        .tempdir_in(&staging_parent)
3541        .map_err(|source| CliError::Io {
3542            path: staging_parent,
3543            source,
3544        })?;
3545    let mut database = StagedGraphDatabase {
3546        store: None,
3547        directory: Some(directory),
3548        _lease: lease,
3549    };
3550    let database_path = database
3551        .directory()?
3552        .path()
3553        .join(GRAPH_STAGE_DATABASE_FILE_NAME);
3554    database.store = Some(AtlasStore::create_repository_graph_staging(
3555        &database_path,
3556        root,
3557        project,
3558    )?);
3559    database.store_mut()?.replace_scan(nodes)?;
3560    let mut identity_rejections = identity_admission.rejections.clone();
3561    let mut identity_rejection_keys = identity_rejection_key_set(&identity_rejections);
3562    let mut identity_rejection_bytes =
3563        identity_rejection_keys_retained_bytes(&identity_rejection_keys)?
3564            .checked_add(identity_rejection_drop_paths_retained_bytes(
3565                &identity_admission.rejection_details_dropped_by_path,
3566            )?)
3567            .ok_or_else(|| {
3568                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
3569            })?;
3570    let mut rejection_details_dropped_by_path =
3571        identity_admission.rejection_details_dropped_by_path.clone();
3572    {
3573        let mut staging = database
3574            .store_mut()?
3575            .begin_repository_graph_staging(project, generation)?;
3576        let mut entity_batch = Vec::with_capacity(GRAPH_STAGE_ENTITY_BATCH_SIZE);
3577        for entity in entities.entity_by_digest.values() {
3578            entity_batch.push(entity);
3579            if entity_batch.len() == GRAPH_STAGE_ENTITY_BATCH_SIZE {
3580                control.check(IndexWorkStage::Publication)?;
3581                staging.append_entity_refs(&entity_batch)?;
3582                entity_batch.clear();
3583            }
3584        }
3585        if !entity_batch.is_empty() {
3586            staging.append_entity_refs(&entity_batch)?;
3587            entity_batch.clear();
3588        }
3589        staging.append_batch(&[], &[], &[], &[], &entities.entity_exports, &[])?;
3590        let mut staged_rows = ProjectedGraphRows::default();
3591        for graph in graphs {
3592            let graph = graph.borrow();
3593            let rows = project_graph_rows(
3594                project,
3595                generation,
3596                graph,
3597                document_facts.get(&graph.path).map(Cow::as_ref),
3598                &document_index,
3599                &mut entities,
3600                candidates,
3601                identity_admission,
3602                control,
3603            )?;
3604            staged_rows.append(rows);
3605            identity_rejection_bytes = identity_rejection_bytes
3606                .checked_add(extend_bounded_identity_rejections_with_drop_paths(
3607                    &mut identity_rejections,
3608                    &mut identity_rejection_keys,
3609                    staged_rows.identity_rejections.iter().cloned(),
3610                    &mut rejection_details_dropped_by_path,
3611                )?)
3612                .ok_or_else(|| {
3613                    CliError::InvalidInput("identity rejection bytes overflowed".to_string())
3614                })?;
3615            mark_identity_rejection_coverage_limits(
3616                &mut staged_rows.coverage,
3617                &rejection_details_dropped_by_path,
3618            )?;
3619            staged_rows.identity_rejections.clear();
3620            if staged_rows.row_count() < GRAPH_STAGE_ROW_BATCH_SIZE {
3621                continue;
3622            }
3623            staging.append_batch(
3624                &staged_rows.external_entities,
3625                &staged_rows.relations,
3626                &staged_rows.occurrences,
3627                &staged_rows.coverage,
3628                &[],
3629                &staged_rows.relation_dependencies,
3630            )?;
3631            if !staged_rows.document_unresolved_reasons.is_empty() {
3632                staging.set_document_unresolved_reasons_controlled(
3633                    &staged_rows.document_unresolved_reasons,
3634                    control,
3635                )?;
3636            }
3637            staged_rows.clear();
3638        }
3639        if !staged_rows.is_empty() {
3640            staging.append_batch(
3641                &staged_rows.external_entities,
3642                &staged_rows.relations,
3643                &staged_rows.occurrences,
3644                &staged_rows.coverage,
3645                &[],
3646                &staged_rows.relation_dependencies,
3647            )?;
3648            if !staged_rows.document_unresolved_reasons.is_empty() {
3649                staging.set_document_unresolved_reasons_controlled(
3650                    &staged_rows.document_unresolved_reasons,
3651                    control,
3652                )?;
3653            }
3654        }
3655        staging.complete()?;
3656    }
3657    database.store()?.checkpoint_repository_graph_staging()?;
3658    database.store()?.begin_index_read_snapshot()?;
3659    let _staged_generation = database.store()?.repository_graph_generation()?;
3660    let document_target_states = document_index.observed_absent_states();
3661    let retained_bytes = database_path.as_os_str().as_encoded_bytes().len() as u64
3662        + document_target_states
3663            .iter()
3664            .map(|(path, _reason)| path.len() as u64 + STAGED_GRAPH_ROW_BYTES)
3665            .sum::<u64>()
3666        + identity_rejection_bytes;
3667    Ok(StagedRepositoryGraph {
3668        project,
3669        mutation: RepositoryGraphMutation::Full,
3670        entities: Vec::new(),
3671        relations: Vec::new(),
3672        occurrences: Vec::new(),
3673        coverage: Vec::new(),
3674        entity_exports: Vec::new(),
3675        relation_dependencies: Vec::new(),
3676        document_unresolved_reasons: Vec::new(),
3677        identity_rejections,
3678        #[cfg(test)]
3679        resolution_derivations: identity_admission.resolution_derivations.clone(),
3680        #[cfg(test)]
3681        peak_retained_bytes,
3682        #[cfg(test)]
3683        projection_removals_before_entities,
3684        scan_policy: scan_policy.clone(),
3685        document_target_states,
3686        database: Some(database),
3687        source_recovery: SourceRecoveryWork::default(),
3688        retained_bytes,
3689    })
3690}
3691
3692/// Test-only compatibility wrapper for graph fixtures without Markdown facts.
3693#[cfg(test)]
3694fn finish_projection_in_database(
3695    root: &Path,
3696    nodes: &[Node],
3697    project: ProjectInstanceId,
3698    generation: IndexGeneration,
3699    graphs: &[impl Borrow<SymbolGraph>],
3700    entities: EntityProjection,
3701    candidates: &ProjectResolutionRegistry,
3702    scan_policy: &RootScanPolicy,
3703    control: &IndexWorkControl,
3704) -> Result<StagedRepositoryGraph, CliError> {
3705    finish_projection_in_database_with_documents(
3706        root,
3707        nodes,
3708        project,
3709        generation,
3710        graphs,
3711        &BTreeMap::new(),
3712        &GraphIdentityAdmission::default(),
3713        entities,
3714        candidates,
3715        scan_policy,
3716        control,
3717    )
3718}
3719
3720/// Resolve staged relationships and finish one complete normalized graph batch.
3721fn finish_projection_with_documents(
3722    project: ProjectInstanceId,
3723    generation: IndexGeneration,
3724    mutation: RepositoryGraphMutation,
3725    graphs: &[impl Borrow<SymbolGraph>],
3726    root: &Path,
3727    nodes: &[Node],
3728    document_facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
3729    identity_admission: &GraphIdentityAdmission,
3730    mut entities: EntityProjection,
3731    candidates: &ProjectResolutionRegistry,
3732    scan_policy: &RootScanPolicy,
3733    control: &IndexWorkControl,
3734) -> Result<StagedRepositoryGraph, CliError> {
3735    #[cfg(test)]
3736    let peak_retained_bytes = entities.peak_retained_bytes;
3737    #[cfg(test)]
3738    let projection_removals_before_entities = entities.projection_removals_before_entities.clone();
3739    let document_index = DocumentResolutionIndex::new(root, nodes, scan_policy)?;
3740    let mut relations_by_digest = BTreeMap::new();
3741    let mut occurrences = Vec::new();
3742    let mut relation_dependencies = Vec::new();
3743    let mut coverage = Vec::new();
3744    let mut document_unresolved_reasons = BTreeMap::new();
3745    let mut identity_rejections = identity_admission.rejections.clone();
3746    let mut identity_rejection_keys = identity_rejection_key_set(&identity_rejections);
3747    let mut identity_rejection_bytes =
3748        identity_rejection_keys_retained_bytes(&identity_rejection_keys)?
3749            .checked_add(identity_rejection_drop_paths_retained_bytes(
3750                &identity_admission.rejection_details_dropped_by_path,
3751            )?)
3752            .ok_or_else(|| {
3753                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
3754            })?;
3755    let mut rejection_details_dropped_by_path =
3756        identity_admission.rejection_details_dropped_by_path.clone();
3757    for graph in graphs {
3758        let graph = graph.borrow();
3759        let rows = project_graph_rows(
3760            project,
3761            generation,
3762            graph,
3763            document_facts.get(&graph.path).map(Cow::as_ref),
3764            &document_index,
3765            &mut entities,
3766            candidates,
3767            identity_admission,
3768            control,
3769        )?;
3770        for (relation_index, relation) in rows.relations.into_iter().enumerate() {
3771            insert_relation(
3772                &mut relations_by_digest,
3773                relation,
3774                &graph.path,
3775                "projected",
3776                relation_index,
3777            )?;
3778        }
3779        occurrences.extend(rows.occurrences);
3780        relation_dependencies.extend(rows.relation_dependencies);
3781        for (key, reason) in rows.document_unresolved_reasons {
3782            insert_document_unresolved_reason(&mut document_unresolved_reasons, key, reason)?;
3783        }
3784        coverage.extend(rows.coverage);
3785        identity_rejection_bytes = identity_rejection_bytes
3786            .checked_add(extend_bounded_identity_rejections_with_drop_paths(
3787                &mut identity_rejections,
3788                &mut identity_rejection_keys,
3789                rows.identity_rejections,
3790                &mut rejection_details_dropped_by_path,
3791            )?)
3792            .ok_or_else(|| {
3793                CliError::InvalidInput("identity rejection bytes overflowed".to_string())
3794            })?;
3795        mark_identity_rejection_coverage_limits(&mut coverage, &rejection_details_dropped_by_path)?;
3796        entities.retained_bytes = rows
3797            .external_entities
3798            .iter()
3799            .fold(entities.retained_bytes, |bytes, entity| {
3800                bytes.saturating_add(entity_retained_bytes(entity))
3801            });
3802        for entity in rows.external_entities {
3803            insert_entity(&mut entities.entity_by_digest, entity)?;
3804        }
3805    }
3806    let mut relations = relations_by_digest.into_values().collect::<Vec<_>>();
3807    relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
3808    occurrences.sort_by(|left, right| {
3809        left.relation()
3810            .digest()
3811            .cmp(right.relation().digest())
3812            .then_with(|| left.file().as_str().cmp(right.file().as_str()))
3813            .then_with(|| left.span().start_line().cmp(&right.span().start_line()))
3814            .then_with(|| left.span().start_column().cmp(&right.span().start_column()))
3815    });
3816    occurrences.dedup();
3817    sort_dedup_dependencies(&mut relation_dependencies);
3818    enforce_key_binding_limit(relation_dependencies.len())?;
3819    let added_rows = relations
3820        .len()
3821        .saturating_add(occurrences.len())
3822        .saturating_add(coverage.len())
3823        .saturating_add(relation_dependencies.len());
3824    let added_rows = added_rows.saturating_add(document_unresolved_reasons.len());
3825    let document_target_states = document_index.observed_absent_states();
3826    entities.retained_bytes = entities
3827        .retained_bytes
3828        .saturating_add(
3829            STAGED_GRAPH_ROW_BYTES.saturating_mul(u64::try_from(added_rows).unwrap_or(u64::MAX)),
3830        )
3831        .saturating_add(
3832            document_target_states
3833                .iter()
3834                .fold(0_u64, |bytes, (path, _reason)| {
3835                    bytes
3836                        .saturating_add(STAGED_GRAPH_ROW_BYTES)
3837                        .saturating_add(path.len() as u64)
3838                }),
3839        );
3840    Ok(StagedRepositoryGraph {
3841        project,
3842        mutation,
3843        entities: entities.entity_by_digest.into_values().collect(),
3844        relations,
3845        occurrences,
3846        coverage,
3847        entity_exports: entities.entity_exports,
3848        relation_dependencies,
3849        document_unresolved_reasons: document_unresolved_reasons.into_values().collect(),
3850        identity_rejections,
3851        #[cfg(test)]
3852        resolution_derivations: identity_admission.resolution_derivations.clone(),
3853        #[cfg(test)]
3854        peak_retained_bytes,
3855        #[cfg(test)]
3856        projection_removals_before_entities,
3857        scan_policy: scan_policy.clone(),
3858        document_target_states,
3859        database: None,
3860        source_recovery: SourceRecoveryWork::default(),
3861        retained_bytes: entities
3862            .retained_bytes
3863            .saturating_add(identity_rejection_bytes),
3864    })
3865}
3866
3867/// Test-only compatibility wrapper for graph fixtures without Markdown facts.
3868#[cfg(test)]
3869fn finish_projection(
3870    project: ProjectInstanceId,
3871    generation: IndexGeneration,
3872    mutation: RepositoryGraphMutation,
3873    graphs: &[impl Borrow<SymbolGraph>],
3874    entities: EntityProjection,
3875    candidates: &ProjectResolutionRegistry,
3876    control: &IndexWorkControl,
3877) -> Result<StagedRepositoryGraph, CliError> {
3878    let scan_policy = RootScanPolicy::discover(Path::new("."), &ScanOptions::default(), control)
3879        .map_err(|source| CliError::InvalidInput(source.to_string()))?;
3880    finish_projection_with_documents(
3881        project,
3882        generation,
3883        mutation,
3884        graphs,
3885        Path::new("."),
3886        &[],
3887        &BTreeMap::new(),
3888        &GraphIdentityAdmission::default(),
3889        entities,
3890        candidates,
3891        &scan_policy,
3892        control,
3893    )
3894}
3895
3896/// One graph's normalized rows, bounded by the parser graph already in memory.
3897#[derive(Default)]
3898struct ProjectedGraphRows {
3899    /// Deduplicated logical relations owned by the graph.
3900    relations: Vec<LogicalRelation>,
3901    /// Exact supporting source occurrences.
3902    occurrences: Vec<RelationOccurrence>,
3903    /// Coverage rows for the graph.
3904    coverage: Vec<CoverageRecord>,
3905    /// Content-free external entities referenced by the graph.
3906    external_entities: Vec<GraphEntity>,
3907    /// Canonical dependency keys retained by the graph relations.
3908    relation_dependencies: Vec<RelationDependencyKey>,
3909    /// Closed reasons for unresolved canonical document relations.
3910    document_unresolved_reasons: Vec<(LogicalRelationKey, DocumentTargetUnresolvedReason)>,
3911    /// Derived relation identity rejections attached to this graph.
3912    identity_rejections: Vec<GraphIdentityRejection>,
3913}
3914
3915impl ProjectedGraphRows {
3916    /// Append one graph while retaining a single bounded staging batch.
3917    fn append(&mut self, rows: Self) {
3918        self.relations.extend(rows.relations);
3919        self.occurrences.extend(rows.occurrences);
3920        self.coverage.extend(rows.coverage);
3921        self.external_entities.extend(rows.external_entities);
3922        self.relation_dependencies
3923            .extend(rows.relation_dependencies);
3924        self.document_unresolved_reasons
3925            .extend(rows.document_unresolved_reasons);
3926        self.identity_rejections.extend(rows.identity_rejections);
3927    }
3928
3929    /// Return aggregate normalized rows retained by this staging batch.
3930    fn row_count(&self) -> usize {
3931        self.relations
3932            .len()
3933            .saturating_add(self.occurrences.len())
3934            .saturating_add(self.coverage.len())
3935            .saturating_add(self.external_entities.len())
3936            .saturating_add(self.relation_dependencies.len())
3937            .saturating_add(self.document_unresolved_reasons.len())
3938    }
3939
3940    /// Return whether the staging batch is empty.
3941    fn is_empty(&self) -> bool {
3942        self.row_count() == 0
3943    }
3944
3945    /// Release all successfully staged rows while preserving allocated capacity.
3946    fn clear(&mut self) {
3947        self.relations.clear();
3948        self.occurrences.clear();
3949        self.coverage.clear();
3950        self.external_entities.clear();
3951        self.relation_dependencies.clear();
3952        self.document_unresolved_reasons.clear();
3953        self.identity_rejections.clear();
3954    }
3955}
3956
3957/// Exact staged file inventory plus platform-aware case and root checks.
3958struct DocumentResolutionIndex<'a> {
3959    /// Selected source root used only for bounded filesystem checks.
3960    root: PathBuf,
3961    /// Canonical selected root used to reject symlink escape.
3962    canonical_root: PathBuf,
3963    /// Exact case-preserving staged identities.
3964    kinds_by_path: HashMap<&'a str, NodeKind>,
3965    /// Unicode-lowercase identity counts used to refuse guesses and collisions.
3966    casefold_path_counts: HashMap<String, u32>,
3967    /// Effective repository admission policy shared with full and incremental scans.
3968    scan_policy: &'a RootScanPolicy,
3969    /// Consulted non-indexed states, memoized for repeated high-fan-out references.
3970    absent_states: RefCell<BTreeMap<String, DocumentTargetUnresolvedReason>>,
3971}
3972
3973impl<'a> DocumentResolutionIndex<'a> {
3974    /// Build one bounded exact-identity view from the staged source inventory.
3975    fn new(
3976        root: &Path,
3977        nodes: &'a [Node],
3978        scan_policy: &'a RootScanPolicy,
3979    ) -> Result<Self, CliError> {
3980        let canonical_root = fs::canonicalize(root).map_err(|source| CliError::Io {
3981            path: root.to_path_buf(),
3982            source,
3983        })?;
3984        let kinds_by_path = nodes
3985            .iter()
3986            .map(|node| (node.path.as_str(), node.kind))
3987            .collect::<HashMap<_, _>>();
3988        let mut casefold_path_counts = HashMap::new();
3989        for node in nodes {
3990            let count = casefold_path_counts
3991                .entry(node.path.to_lowercase())
3992                .or_insert(0_u32);
3993            *count = count.saturating_add(1);
3994        }
3995        let retained_bytes = nodes.iter().fold(0_u64, |bytes, node| {
3996            bytes
3997                .saturating_add(STAGED_GRAPH_ROW_BYTES.saturating_mul(2))
3998                .saturating_add(node.path.len() as u64)
3999        });
4000        enforce_resolution_registry_budget(retained_bytes)?;
4001        Ok(Self {
4002            root: root.to_path_buf(),
4003            canonical_root,
4004            kinds_by_path,
4005            casefold_path_counts,
4006            scan_policy,
4007            absent_states: RefCell::new(BTreeMap::new()),
4008        })
4009    }
4010
4011    /// Return a closed unresolved reason, or `None` for one exact admitted file.
4012    fn unresolved_reason(
4013        &self,
4014        path: &str,
4015    ) -> Result<Option<DocumentTargetUnresolvedReason>, CliError> {
4016        let casefold_count = self
4017            .casefold_path_counts
4018            .get(&path.to_lowercase())
4019            .copied()
4020            .unwrap_or(0);
4021        if casefold_count > 1 {
4022            return Ok(Some(DocumentTargetUnresolvedReason::CaseConflict));
4023        }
4024        Ok(match self.kinds_by_path.get(path).copied() {
4025            Some(NodeKind::File) => None,
4026            Some(NodeKind::Folder) => Some(DocumentTargetUnresolvedReason::Unsupported),
4027            None if casefold_count == 1 => Some(DocumentTargetUnresolvedReason::CaseConflict),
4028            None => Some(self.absent_reason(path)?),
4029        })
4030    }
4031
4032    /// Distinguish absent source from excluded or escaping filesystem state.
4033    fn absent_reason(&self, path: &str) -> Result<DocumentTargetUnresolvedReason, CliError> {
4034        if let Some(reason) = self.absent_states.borrow().get(path).copied() {
4035            return Ok(reason);
4036        }
4037        let native = self.root.join(Path::new(path));
4038        let reason = if self
4039            .scan_policy
4040            .excludes_path(&native)
4041            .map_err(|source| CliError::InvalidInput(source.to_string()))?
4042        {
4043            DocumentTargetUnresolvedReason::Ignored
4044        } else {
4045            match fs::symlink_metadata(&native) {
4046                Ok(metadata) if metadata.file_type().is_dir() => {
4047                    DocumentTargetUnresolvedReason::Unsupported
4048                }
4049                Ok(_metadata) => match fs::canonicalize(&native) {
4050                    Ok(canonical) if !canonical.starts_with(&self.canonical_root) => {
4051                        DocumentTargetUnresolvedReason::OutsideRoot
4052                    }
4053                    Ok(_canonical) => DocumentTargetUnresolvedReason::Unsupported,
4054                    Err(_source) => DocumentTargetUnresolvedReason::Unsupported,
4055                },
4056                Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
4057                    self.missing_or_escaping_ancestor(&native)
4058                }
4059                Err(_source) => DocumentTargetUnresolvedReason::Unsupported,
4060            }
4061        };
4062        self.absent_states
4063            .borrow_mut()
4064            .insert(path.to_string(), reason);
4065        Ok(reason)
4066    }
4067
4068    /// Return the bounded states actually consulted during document resolution.
4069    fn observed_absent_states(&self) -> Vec<(String, DocumentTargetUnresolvedReason)> {
4070        self.absent_states
4071            .borrow()
4072            .iter()
4073            .map(|(path, reason)| (path.clone(), *reason))
4074            .collect()
4075    }
4076
4077    /// Check the nearest existing ancestor so a missing child cannot hide symlink escape.
4078    fn missing_or_escaping_ancestor(&self, native: &Path) -> DocumentTargetUnresolvedReason {
4079        let mut ancestor = native.parent();
4080        while let Some(path) = ancestor {
4081            if !path.starts_with(&self.root) {
4082                break;
4083            }
4084            match fs::canonicalize(path) {
4085                Ok(canonical) if !canonical.starts_with(&self.canonical_root) => {
4086                    return DocumentTargetUnresolvedReason::OutsideRoot;
4087                }
4088                Ok(_canonical) => return DocumentTargetUnresolvedReason::Missing,
4089                Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
4090                    ancestor = path.parent();
4091                }
4092                Err(_source) => return DocumentTargetUnresolvedReason::Unsupported,
4093            }
4094        }
4095        DocumentTargetUnresolvedReason::Missing
4096    }
4097}
4098
4099/// One resolved or typed-unresolved document candidate plus invalidation keys.
4100struct DocumentResolutionOutcome {
4101    /// Existing normalized graph resolution envelope.
4102    resolution: RelationResolution,
4103    /// Closed reason required only for unresolved document relations.
4104    unresolved_reason: Option<DocumentTargetUnresolvedReason>,
4105    /// Exact file and optional heading keys that invalidate this relation.
4106    dependencies: Vec<CanonicalResolutionKey>,
4107}
4108
4109/// Project one Markdown fact batch into canonical document relations and coverage.
4110#[allow(clippy::too_many_arguments)]
4111fn project_document_rows(
4112    project: ProjectInstanceId,
4113    generation: IndexGeneration,
4114    graph: &SymbolGraph,
4115    facts: &MarkdownFacts,
4116    owners: &GraphOwners,
4117    document_index: &DocumentResolutionIndex<'_>,
4118    candidates: &ProjectResolutionRegistry,
4119    staged_entities: &BTreeMap<String, GraphEntity>,
4120    control: &IndexWorkControl,
4121) -> Result<ProjectedGraphRows, CliError> {
4122    let file_source = staged_entities.get(&owners.file_digest).ok_or_else(|| {
4123        CliError::InvalidInput("document graph file owner was not staged".to_string())
4124    })?;
4125    let completeness = match facts.coverage.completeness {
4126        MarkdownFactCompleteness::Complete => Completeness::Complete,
4127        MarkdownFactCompleteness::Partial => Completeness::Partial,
4128    };
4129    let mut relations_by_digest = BTreeMap::new();
4130    let mut occurrences = Vec::new();
4131    let mut relation_dependencies = Vec::new();
4132    let mut unresolved_reasons = BTreeMap::new();
4133    for (candidate_index, candidate) in facts.link_candidates.iter().enumerate() {
4134        check_graph_work(control, candidate_index)?;
4135        if normalize_document_target(&graph.path, &candidate.selector)
4136            .is_ok_and(|target| target.path == graph.path && target.fragment.is_none())
4137        {
4138            continue;
4139        }
4140        let source = file_source;
4141        let outcome = resolve_document_candidate(
4142            project,
4143            &graph.path,
4144            &candidate.selector,
4145            document_index,
4146            candidates,
4147            staged_entities,
4148            control,
4149        )?;
4150        if matches!(
4151            &outcome.resolution,
4152            RelationResolution::Resolved { target, .. } if target == source.key()
4153        ) {
4154            continue;
4155        }
4156        let relation = LogicalRelation::new(
4157            source,
4158            GraphRelationKind::Extended(ExtendedRelationKind::Documents),
4159            outcome.resolution,
4160            ConfidenceClass::High,
4161            completeness,
4162            generation,
4163        )
4164        .map_err(invalid_graph_contract)?;
4165        insert_relation(
4166            &mut relations_by_digest,
4167            relation.clone(),
4168            &graph.path,
4169            "document",
4170            candidate_index,
4171        )?;
4172        if let Some(reason) = outcome.unresolved_reason {
4173            insert_document_unresolved_reason(
4174                &mut unresolved_reasons,
4175                relation.key().clone(),
4176                reason,
4177            )?;
4178        }
4179        let start_line = u32::try_from(candidate.source.line_start).map_err(|error| {
4180            CliError::InvalidInput(format!("document source line exceeds graph range: {error}"))
4181        })?;
4182        let end_line = u32::try_from(candidate.source.line_end).map_err(|error| {
4183            CliError::InvalidInput(format!("document end line exceeds graph range: {error}"))
4184        })?;
4185        let start_column = u32::try_from(candidate.source.column_start).map_err(|error| {
4186            CliError::InvalidInput(format!(
4187                "document source column exceeds graph range: {error}"
4188            ))
4189        })?;
4190        let end_column = u32::try_from(candidate.source.column_end).map_err(|error| {
4191            CliError::InvalidInput(format!("document end column exceeds graph range: {error}"))
4192        })?;
4193        occurrences.push(
4194            RelationOccurrence::new(
4195                &relation,
4196                RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
4197                SourceSpan::new(start_line, start_column, end_line, end_column)
4198                    .map_err(invalid_graph_contract)?,
4199                generation,
4200            )
4201            .map_err(invalid_graph_contract)?,
4202        );
4203        for key in outcome.dependencies {
4204            relation_dependencies.push(
4205                RelationDependencyKey::new(relation.key().clone(), key)
4206                    .map_err(invalid_graph_contract)?,
4207            );
4208        }
4209    }
4210    Ok(ProjectedGraphRows {
4211        relations: relations_by_digest.into_values().collect(),
4212        occurrences,
4213        coverage: vec![document_coverage(&graph.path, facts, generation)?],
4214        external_entities: Vec::new(),
4215        relation_dependencies,
4216        document_unresolved_reasons: unresolved_reasons.into_values().collect(),
4217        identity_rejections: Vec::new(),
4218    })
4219}
4220
4221/// Resolve one normalized document target through exact staged/persisted identities.
4222#[allow(clippy::too_many_arguments)]
4223fn resolve_document_candidate(
4224    project: ProjectInstanceId,
4225    document_path: &str,
4226    selector: &str,
4227    document_index: &DocumentResolutionIndex<'_>,
4228    candidates: &ProjectResolutionRegistry,
4229    staged_entities: &BTreeMap<String, GraphEntity>,
4230    control: &IndexWorkControl,
4231) -> Result<DocumentResolutionOutcome, CliError> {
4232    let target = match normalize_document_target(document_path, selector) {
4233        Ok(target) => target,
4234        Err(reason) => {
4235            return unresolved_document_outcome(selector, reason, Vec::new());
4236        }
4237    };
4238    let file_key = document_file_resolution_key(project, &target.path)?;
4239    let mut dependencies = vec![
4240        file_key.clone(),
4241        document_casefold_resolution_key(project, &target.path)?,
4242    ];
4243    let heading_key = target
4244        .fragment
4245        .as_deref()
4246        .map(|fragment| document_heading_resolution_key(project, &target.path, fragment))
4247        .transpose()?;
4248    if let Some(key) = heading_key.as_ref() {
4249        dependencies.push(key.clone());
4250    }
4251    if let Some(reason) = document_index.unresolved_reason(&target.path)? {
4252        return unresolved_document_outcome(selector, reason, dependencies);
4253    }
4254    if let Some(key) = heading_key.as_ref() {
4255        let matches = registry_resolution_matches(
4256            std::slice::from_ref(key),
4257            ResolutionCandidateKind::Any,
4258            candidates,
4259            staged_entities,
4260            control,
4261        )?;
4262        match matches.count {
4263            0 => {
4264                return unresolved_document_outcome(
4265                    selector,
4266                    DocumentTargetUnresolvedReason::Missing,
4267                    dependencies,
4268                );
4269            }
4270            1 => {
4271                let target = matches.first.ok_or_else(|| {
4272                    CliError::InvalidInput("resolved document heading disappeared".to_string())
4273                })?;
4274                return Ok(DocumentResolutionOutcome {
4275                    resolution: RelationResolution::resolved(target)
4276                        .map_err(invalid_graph_contract)?,
4277                    unresolved_reason: None,
4278                    dependencies,
4279                });
4280            }
4281            _ => {
4282                return unresolved_document_outcome(
4283                    selector,
4284                    DocumentTargetUnresolvedReason::CaseConflict,
4285                    dependencies,
4286                );
4287            }
4288        }
4289    }
4290    let matches = registry_resolution_matches(
4291        std::slice::from_ref(&file_key),
4292        ResolutionCandidateKind::Any,
4293        candidates,
4294        staged_entities,
4295        control,
4296    )?;
4297    match matches.count {
4298        1 => Ok(DocumentResolutionOutcome {
4299            resolution: RelationResolution::resolved(matches.first.ok_or_else(|| {
4300                CliError::InvalidInput("resolved document file disappeared".to_string())
4301            })?)
4302            .map_err(invalid_graph_contract)?,
4303            unresolved_reason: None,
4304            dependencies,
4305        }),
4306        0 => Err(CliError::InvalidInput(format!(
4307            "admitted document target lacked its exact graph identity: {}",
4308            target.path
4309        ))),
4310        _ => unresolved_document_outcome(
4311            selector,
4312            DocumentTargetUnresolvedReason::CaseConflict,
4313            dependencies,
4314        ),
4315    }
4316}
4317
4318/// Construct one privacy-safe unresolved document outcome.
4319fn unresolved_document_outcome(
4320    selector: &str,
4321    reason: DocumentTargetUnresolvedReason,
4322    dependencies: Vec<CanonicalResolutionKey>,
4323) -> Result<DocumentResolutionOutcome, CliError> {
4324    Ok(DocumentResolutionOutcome {
4325        resolution: RelationResolution::Unresolved {
4326            reference: GraphIdentityText::new(selector).map_err(invalid_graph_contract)?,
4327        },
4328        unresolved_reason: Some(reason),
4329        dependencies,
4330    })
4331}
4332
4333/// Project bounded Markdown fact coverage into the existing relation coverage table.
4334fn document_coverage(
4335    path: &str,
4336    facts: &MarkdownFacts,
4337    generation: IndexGeneration,
4338) -> Result<CoverageRecord, CliError> {
4339    let scope = CoverageScope::Path {
4340        path: RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract)?,
4341    };
4342    let covered = u64::try_from(facts.link_candidates.len()).unwrap_or(u64::MAX);
4343    let reached_limit = facts.coverage.limits.first().map(|limit| match limit {
4344        MarkdownFactLimit::HeadingCount | MarkdownFactLimit::CandidateCount => GraphLimitKind::Rows,
4345        MarkdownFactLimit::InputBytes
4346        | MarkdownFactLimit::LabelBytes
4347        | MarkdownFactLimit::SelectorBytes
4348        | MarkdownFactLimit::EvidenceBytes => GraphLimitKind::IntermediateBytes,
4349    });
4350    let relation = Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents));
4351    match facts.coverage.completeness {
4352        MarkdownFactCompleteness::Complete if covered == 0 => CoverageRecord::new(
4353            scope,
4354            relation,
4355            CoverageState::NoCandidates,
4356            0,
4357            0,
4358            generation,
4359            None,
4360            None,
4361        )
4362        .map_err(invalid_graph_contract),
4363        MarkdownFactCompleteness::Complete => CoverageRecord::new(
4364            scope,
4365            relation,
4366            CoverageState::Complete,
4367            covered,
4368            0,
4369            generation,
4370            None,
4371            None,
4372        )
4373        .map_err(invalid_graph_contract),
4374        MarkdownFactCompleteness::Partial if covered > 0 => CoverageRecord::new(
4375            scope,
4376            relation,
4377            CoverageState::Partial,
4378            covered,
4379            1,
4380            generation,
4381            Some(
4382                GraphIdentityText::new(DOCUMENT_PARTIAL_COVERAGE_REASON)
4383                    .map_err(invalid_graph_contract)?,
4384            ),
4385            reached_limit,
4386        )
4387        .map_err(invalid_graph_contract),
4388        MarkdownFactCompleteness::Partial => CoverageRecord::new(
4389            scope,
4390            relation,
4391            if facts
4392                .coverage
4393                .limits
4394                .contains(&MarkdownFactLimit::InputBytes)
4395            {
4396                CoverageState::Oversized
4397            } else {
4398                CoverageState::Failed
4399            },
4400            0,
4401            1,
4402            generation,
4403            Some(
4404                GraphIdentityText::new(DOCUMENT_PARTIAL_COVERAGE_REASON)
4405                    .map_err(invalid_graph_contract)?,
4406            ),
4407            reached_limit,
4408        )
4409        .map_err(invalid_graph_contract),
4410    }
4411}
4412
4413/// Deduplicate unresolved reasons while rejecting one logical-key contradiction.
4414fn insert_document_unresolved_reason(
4415    reasons: &mut BTreeMap<String, (LogicalRelationKey, DocumentTargetUnresolvedReason)>,
4416    key: LogicalRelationKey,
4417    reason: DocumentTargetUnresolvedReason,
4418) -> Result<(), CliError> {
4419    let digest = key.digest().to_string();
4420    match reasons.entry(digest) {
4421        Entry::Vacant(entry) => {
4422            entry.insert((key, reason));
4423            Ok(())
4424        }
4425        Entry::Occupied(entry) if entry.get() == &(key, reason) => Ok(()),
4426        Entry::Occupied(_entry) => Err(CliError::InvalidInput(
4427            "document relation retained conflicting unresolved reasons".to_string(),
4428        )),
4429    }
4430}
4431
4432/// Resolve one parser graph and release its temporary owner/key workspace.
4433fn project_graph_rows(
4434    project: ProjectInstanceId,
4435    generation: IndexGeneration,
4436    graph: &SymbolGraph,
4437    document_facts: Option<&MarkdownFacts>,
4438    document_index: &DocumentResolutionIndex<'_>,
4439    entities: &mut EntityProjection,
4440    candidates: &ProjectResolutionRegistry,
4441    identity_admission: &GraphIdentityAdmission,
4442    control: &IndexWorkControl,
4443) -> Result<ProjectedGraphRows, CliError> {
4444    control.check(IndexWorkStage::SymbolParsing)?;
4445    let owners = entities
4446        .owners_by_graph
4447        .remove(&graph.path)
4448        .ok_or_else(|| CliError::InvalidInput("graph owners were not staged".to_string()))?;
4449    let resolution_keys = entities
4450        .keys_by_graph
4451        .remove(&graph.path)
4452        .ok_or_else(|| CliError::InvalidInput("graph keys were not staged".to_string()))?;
4453    let call_bindings = entities
4454        .call_bindings
4455        .remove(&graph.path)
4456        .unwrap_or_default();
4457    let symbol_index = GraphSymbolIndex::new(graph, control)?
4458        .with_source_calls(resolution_keys.source_calls(), control)?
4459        .with_rust_toolchain_imports(resolution_keys.rust_toolchain_imports());
4460    let keys_by_relation = resolution_keys
4461        .relation_keys()
4462        .iter()
4463        .map(|entry| (entry.relation_index(), entry.keys()))
4464        .collect::<BTreeMap<_, _>>();
4465    let mut relations_by_digest = BTreeMap::new();
4466    let mut occurrences = Vec::new();
4467    let mut relation_dependencies = Vec::new();
4468    let mut coverage = Vec::new();
4469    let mut external_entities = BTreeMap::new();
4470    let mut document_unresolved_reasons = BTreeMap::new();
4471    for (relation_index, source_relation) in graph.relations.iter().enumerate() {
4472        control.check(IndexWorkStage::SymbolParsing)?;
4473        let source = relation_source(
4474            &owners,
4475            &entities.entity_by_digest,
4476            graph,
4477            &symbol_index,
4478            source_relation,
4479            Some(relation_index),
4480            control,
4481        )?;
4482        let dependency_keys = keys_by_relation
4483            .get(&relation_index)
4484            .copied()
4485            .unwrap_or(&[]);
4486        let binding = call_bindings.get(&relation_index);
4487        let resolution = if let Some(binding) = binding {
4488            if let Some(target) = binding.external_target() {
4489                insert_entity(&mut external_entities, target.clone())?;
4490            }
4491            binding.resolution(source_relation)?
4492        } else {
4493            relation_resolution(
4494                project,
4495                generation,
4496                source_relation,
4497                Some(relation_index),
4498                &owners,
4499                graph,
4500                &symbol_index,
4501                dependency_keys,
4502                candidates,
4503                &entities.entity_by_digest,
4504                &mut external_entities,
4505                control,
4506            )?
4507        };
4508        let relation = LogicalRelation::new(
4509            source,
4510            GraphRelationKind::from_legacy(source_relation.kind),
4511            resolution,
4512            relation_confidence(source_relation.parser),
4513            relation_completeness(source_relation.parser),
4514            generation,
4515        )
4516        .map_err(invalid_graph_contract)?;
4517        insert_relation(
4518            &mut relations_by_digest,
4519            relation.clone(),
4520            &graph.path,
4521            "logical",
4522            relation_index,
4523        )?;
4524        let occurrence_span = if let Some(span) = resolution_keys.source_span(relation_index) {
4525            span
4526        } else {
4527            let line = u32::try_from(source_relation.line).map_err(|error| {
4528                CliError::InvalidInput(format!("relation source line exceeds graph range: {error}"))
4529            })?;
4530            let end_column =
4531                u32::try_from(source_relation.context.chars().count()).map_err(|error| {
4532                    CliError::InvalidInput(format!(
4533                        "relation source context exceeds graph range: {error}"
4534                    ))
4535                })?;
4536            SourceSpan::new(line.max(1), 0, line.max(1), end_column)
4537                .map_err(invalid_graph_contract)?
4538        };
4539        occurrences.push(
4540            RelationOccurrence::new(
4541                &relation,
4542                RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
4543                occurrence_span,
4544                generation,
4545            )
4546            .map_err(invalid_graph_contract)?,
4547        );
4548        for key in dependency_keys.iter().chain(
4549            binding
4550                .into_iter()
4551                .flat_map(|binding| binding.dependencies())
4552                .filter(|key| dependency_keys.binary_search(key).is_err()),
4553        ) {
4554            relation_dependencies.push(
4555                RelationDependencyKey::new(relation.key().clone(), key.clone())
4556                    .map_err(invalid_graph_contract)?,
4557            );
4558        }
4559    }
4560    let mut derived_identity_admission = GraphIdentityAdmission::default();
4561    let mut admitted_derived_facts = Vec::new();
4562    for (derived_index, fact) in derived_relation_facts(graph, &keys_by_relation)
4563        .into_iter()
4564        .enumerate()
4565    {
4566        let mut failures = Vec::new();
4567        record_identity_failure(
4568            &mut failures,
4569            GraphIdentityField::RelationSource,
4570            &fact.relation.source_name,
4571        );
4572        record_identity_failure(
4573            &mut failures,
4574            GraphIdentityField::RelationTarget,
4575            &fact.relation.target_name,
4576        );
4577        if failures.is_empty() {
4578            admitted_derived_facts.push(fact);
4579        } else {
4580            derived_identity_admission.record(
4581                &graph.path,
4582                IdentitySpan {
4583                    start_line: fact.relation.line.max(1),
4584                    start_column: 0,
4585                    end_line: fact.relation.line.max(1),
4586                    end_column: 0,
4587                },
4588                fact.relation.parser,
4589                parser_fact_index(DERIVED_RELATION_FACT_INDEX_NAMESPACE, derived_index),
4590                &failures,
4591                control,
4592            )?;
4593        }
4594    }
4595    for (fact_index, fact) in admitted_derived_facts.into_iter().enumerate() {
4596        control.check(IndexWorkStage::SymbolParsing)?;
4597        let source = relation_source(
4598            &owners,
4599            &entities.entity_by_digest,
4600            graph,
4601            &symbol_index,
4602            &fact.relation,
4603            None,
4604            control,
4605        )?;
4606        let resolution = derived_relation_resolution(
4607            project,
4608            generation,
4609            &fact,
4610            &owners,
4611            graph,
4612            &symbol_index,
4613            candidates,
4614            &entities.entity_by_digest,
4615            &mut external_entities,
4616            control,
4617        )?;
4618        let relation = LogicalRelation::new(
4619            source,
4620            GraphRelationKind::Extended(fact.kind),
4621            resolution,
4622            relation_confidence(fact.relation.parser),
4623            relation_completeness(fact.relation.parser),
4624            generation,
4625        )
4626        .map_err(invalid_graph_contract)?;
4627        insert_relation(
4628            &mut relations_by_digest,
4629            relation.clone(),
4630            &graph.path,
4631            "derived",
4632            fact_index,
4633        )?;
4634        let line = u32::try_from(fact.relation.line).map_err(|error| {
4635            CliError::InvalidInput(format!(
4636                "derived relation source line exceeds graph range: {error}"
4637            ))
4638        })?;
4639        let end_column = u32::try_from(fact.relation.context.chars().count()).map_err(|error| {
4640            CliError::InvalidInput(format!(
4641                "derived relation source context exceeds graph range: {error}"
4642            ))
4643        })?;
4644        occurrences.push(
4645            RelationOccurrence::new(
4646                &relation,
4647                RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
4648                SourceSpan::new(line.max(1), 0, line.max(1), end_column)
4649                    .map_err(invalid_graph_contract)?,
4650                generation,
4651            )
4652            .map_err(invalid_graph_contract)?,
4653        );
4654        if let DerivedRelationTarget::Parser { keys } = fact.target {
4655            for key in keys {
4656                relation_dependencies.push(
4657                    RelationDependencyKey::new(relation.key().clone(), key)
4658                        .map_err(invalid_graph_contract)?,
4659                );
4660            }
4661        }
4662    }
4663    if let Some(facts) = document_facts {
4664        let rows = project_document_rows(
4665            project,
4666            generation,
4667            graph,
4668            facts,
4669            &owners,
4670            document_index,
4671            candidates,
4672            &entities.entity_by_digest,
4673            control,
4674        )?;
4675        for (relation_index, relation) in rows.relations.into_iter().enumerate() {
4676            insert_relation(
4677                &mut relations_by_digest,
4678                relation,
4679                &graph.path,
4680                "document",
4681                relation_index,
4682            )?;
4683        }
4684        occurrences.extend(rows.occurrences);
4685        coverage.extend(rows.coverage);
4686        relation_dependencies.extend(rows.relation_dependencies);
4687        for (key, reason) in rows.document_unresolved_reasons {
4688            insert_document_unresolved_reason(&mut document_unresolved_reasons, key, reason)?;
4689        }
4690    }
4691    let mut relations = relations_by_digest.into_values().collect::<Vec<_>>();
4692    relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
4693    occurrences.sort_by(|left, right| {
4694        left.relation()
4695            .digest()
4696            .cmp(right.relation().digest())
4697            .then_with(|| left.file().as_str().cmp(right.file().as_str()))
4698            .then_with(|| left.span().start_line().cmp(&right.span().start_line()))
4699            .then_with(|| left.span().start_column().cmp(&right.span().start_column()))
4700    });
4701    occurrences.dedup();
4702    sort_dedup_dependencies(&mut relation_dependencies);
4703    enforce_key_binding_limit(relation_dependencies.len())?;
4704    entities.retained_bytes = entities
4705        .retained_bytes
4706        .saturating_sub(resolution_retained_bytes(&resolution_keys));
4707    let mut graph_coverage = vec![coverage_for_graph(
4708        graph,
4709        generation,
4710        identity_admission,
4711        &derived_identity_admission,
4712    )?];
4713    graph_coverage.extend(coverage);
4714    Ok(ProjectedGraphRows {
4715        relations,
4716        occurrences,
4717        coverage: graph_coverage,
4718        external_entities: external_entities.into_values().collect(),
4719        relation_dependencies,
4720        document_unresolved_reasons: document_unresolved_reasons.into_values().collect(),
4721        identity_rejections: derived_identity_admission.rejections,
4722    })
4723}
4724
4725/// Deduplicate relation occurrences while conservatively retaining ambiguity.
4726fn insert_relation(
4727    relations: &mut BTreeMap<String, LogicalRelation>,
4728    relation: LogicalRelation,
4729    graph_path: &str,
4730    relation_kind: &str,
4731    relation_index: usize,
4732) -> Result<(), CliError> {
4733    let digest = relation.key().digest().to_string();
4734    match relations.entry(digest) {
4735        Entry::Vacant(entry) => {
4736            entry.insert(relation);
4737            Ok(())
4738        }
4739        Entry::Occupied(mut entry) => {
4740            let existing = entry.get();
4741            if existing == &relation {
4742                return Ok(());
4743            }
4744            let mergeable = existing.key() == relation.key()
4745                && existing.source() == relation.source()
4746                && existing.kind() == relation.kind()
4747                && existing.confidence() == relation.confidence()
4748                && existing.completeness() == relation.completeness()
4749                && existing.generation() == relation.generation();
4750            if mergeable
4751                && let (
4752                    RelationResolution::Ambiguous {
4753                        reference: existing_reference,
4754                        candidates: existing_candidates,
4755                    },
4756                    RelationResolution::Ambiguous {
4757                        reference: incoming_reference,
4758                        candidates: incoming_candidates,
4759                    },
4760                ) = (existing.resolution(), relation.resolution())
4761                && existing_reference == incoming_reference
4762            {
4763                if incoming_candidates > existing_candidates {
4764                    entry.insert(relation);
4765                }
4766                return Ok(());
4767            }
4768            Err(CliError::InvalidInput(format!(
4769                "{relation_kind} relation digest retained conflicting facts for {graph_path} \
4770                 relation {relation_index}: existing={existing:?}, incoming={relation:?}"
4771            )))
4772        }
4773    }
4774}
4775
4776/// Project-wide stable entities and their canonical resolution-key bindings.
4777struct ProjectResolutionRegistry {
4778    /// Remaining operation bytes after all coexisting owners.
4779    retained_limit: u64,
4780    /// Persisted candidates not already owned by the staged entity projection.
4781    supplemental_entities_by_digest: BTreeMap<String, GraphEntity>,
4782    /// Canonical keys mapped only to sorted, deduplicated entity digests.
4783    candidate_digests_by_key: BTreeMap<CanonicalResolutionKey, BTreeSet<String>>,
4784    /// Compact hint for file-owned declaration lookup markers; witnesses remain in the key map.
4785    forwarding_key_digests: BTreeSet<[u8; 32]>,
4786    /// Conservative peak bytes retained by this temporary resolution registry.
4787    retained_bytes: u64,
4788}
4789
4790impl Default for ProjectResolutionRegistry {
4791    fn default() -> Self {
4792        Self {
4793            retained_limit: super::MAX_PUBLICATION_STAGING_BYTES,
4794            supplemental_entities_by_digest: BTreeMap::new(),
4795            candidate_digests_by_key: BTreeMap::new(),
4796            forwarding_key_digests: BTreeSet::new(),
4797            retained_bytes: 0,
4798        }
4799    }
4800}
4801
4802impl ProjectResolutionRegistry {
4803    /// Reserve a new retained owner before mutation or cloning.
4804    fn reserve(&mut self, bytes: u64) -> Result<(), CliError> {
4805        let next = self.retained_bytes.saturating_add(bytes);
4806        enforce_resolution_registry_budget_with_limit(next, self.retained_limit)?;
4807        self.retained_bytes = next;
4808        Ok(())
4809    }
4810
4811    /// Insert one key-to-entity binding without cloning the entity per exported key.
4812    fn insert_candidate(
4813        &mut self,
4814        key: &CanonicalResolutionKey,
4815        entity: &GraphEntity,
4816    ) -> Result<(), CliError> {
4817        let digest = entity.key().digest().to_string();
4818        if let Some(existing) = self.supplemental_entities_by_digest.get(&digest) {
4819            if existing != entity {
4820                return Err(CliError::InvalidInput(
4821                    "graph entity digest retained conflicting selectors".to_string(),
4822                ));
4823            }
4824        } else {
4825            self.reserve(entity_retained_bytes(entity).saturating_add(digest.len() as u64))?;
4826            self.supplemental_entities_by_digest
4827                .insert(digest.clone(), entity.clone());
4828        }
4829        self.record_forwarding_key(key, entity)?;
4830        self.insert_candidate_binding(key, digest)
4831    }
4832
4833    /// Bind an entity already owned by the staged projection without cloning it.
4834    fn insert_staged_candidate(
4835        &mut self,
4836        key: &CanonicalResolutionKey,
4837        entity: &GraphEntity,
4838    ) -> Result<(), CliError> {
4839        self.record_forwarding_key(key, entity)?;
4840        self.insert_candidate_binding(key, entity.key().digest().to_string())
4841    }
4842
4843    /// Index marker presence without walking every declaration candidate on the fast path.
4844    fn record_forwarding_key(
4845        &mut self,
4846        key: &CanonicalResolutionKey,
4847        entity: &GraphEntity,
4848    ) -> Result<(), CliError> {
4849        let digest = key.digest_bytes();
4850        if key.domain() == ResolutionKeyDomain::Declaration
4851            && matches!(entity.selector(), EntitySelector::File { .. })
4852            && !self.forwarding_key_digests.contains(&digest)
4853        {
4854            self.reserve(STAGED_GRAPH_ROW_BYTES.saturating_add(digest.len() as u64))?;
4855            self.forwarding_key_digests.insert(digest);
4856        }
4857        Ok(())
4858    }
4859
4860    /// Insert only new witnesses, reserving canonical text and both tree rows first.
4861    fn insert_candidate_binding(
4862        &mut self,
4863        key: &CanonicalResolutionKey,
4864        digest: String,
4865    ) -> Result<(), CliError> {
4866        if self
4867            .candidate_digests_by_key
4868            .get(key)
4869            .is_some_and(|existing| existing.contains(&digest))
4870        {
4871            return Ok(());
4872        }
4873        let new_key = !self.candidate_digests_by_key.contains_key(key);
4874        let bytes = STAGED_GRAPH_ROW_BYTES
4875            .saturating_add(digest.capacity() as u64)
4876            .saturating_add(if new_key {
4877                STAGED_GRAPH_ROW_BYTES
4878                    .saturating_add(std::mem::size_of::<CanonicalResolutionKey>() as u64)
4879                    .saturating_add(key.canonical_identity().len() as u64)
4880            } else {
4881                0
4882            });
4883        self.reserve(bytes)?;
4884        self.candidate_digests_by_key
4885            .entry(key.clone())
4886            .or_default()
4887            .insert(digest);
4888        Ok(())
4889    }
4890}
4891
4892/// Reject a temporary resolution registry before it can exceed publication memory.
4893fn enforce_resolution_registry_budget(retained_bytes: u64) -> Result<(), CliError> {
4894    enforce_resolution_registry_budget_with_limit(
4895        retained_bytes,
4896        super::MAX_PUBLICATION_STAGING_BYTES,
4897    )
4898}
4899
4900/// Apply the resolution staging bound with an explicit limit for deterministic tests.
4901fn enforce_resolution_registry_budget_with_limit(
4902    retained_bytes: u64,
4903    limit: u64,
4904) -> Result<(), CliError> {
4905    if retained_bytes > limit {
4906        return Err(IndexWorkFailure::resource_limit(
4907            IndexWorkStage::SymbolParsing,
4908            IndexWorkResource::OutputBytes,
4909            limit,
4910            retained_bytes,
4911        )
4912        .into());
4913    }
4914    Ok(())
4915}
4916
4917/// Reject the simultaneous entity projection and lookup registry peak.
4918fn enforce_resolution_staging_budget(
4919    projection: &EntityProjection,
4920    registry: &ProjectResolutionRegistry,
4921) -> Result<(), CliError> {
4922    enforce_resolution_registry_budget(
4923        projection
4924            .retained_bytes
4925            .saturating_add(registry.retained_bytes),
4926    )
4927}
4928
4929/// Count conservative fixed and variable bytes retained by one graph entity.
4930fn entity_retained_bytes(entity: &GraphEntity) -> u64 {
4931    let selector_bytes = match entity.selector() {
4932        EntitySelector::Project => 0,
4933        EntitySelector::Folder { path } => path.as_str().len() as u64,
4934        EntitySelector::File { path } => path.as_str().len() as u64,
4935        EntitySelector::Package { package } => {
4936            (package.manager.as_str().len()
4937                + package.name.as_str().len()
4938                + package.manifest.as_str().len()) as u64
4939        }
4940        EntitySelector::Symbol { symbol } => {
4941            (symbol.file.as_str().len()
4942                + symbol.name.as_str().len()
4943                + symbol
4944                    .parent
4945                    .as_ref()
4946                    .map_or(0, |parent| parent.as_str().len())
4947                + symbol.signature.as_str().len()) as u64
4948        }
4949        EntitySelector::External { external } => {
4950            (external.system.as_str().len() + external.identity.as_str().len()) as u64
4951        }
4952    };
4953    STAGED_GRAPH_ROW_BYTES
4954        .saturating_add(entity.key().digest().len() as u64)
4955        .saturating_add(entity.key().canonical_identity().len() as u64)
4956        .saturating_add(selector_bytes)
4957}
4958
4959/// Build current resolution candidates from newly staged entity exports.
4960#[cfg(test)]
4961fn resolution_registry_from_exports(
4962    projection: &EntityProjection,
4963    control: &IndexWorkControl,
4964) -> Result<ProjectResolutionRegistry, CliError> {
4965    resolution_registry_from_exports_with_limit(
4966        projection,
4967        control,
4968        super::MAX_PUBLICATION_STAGING_BYTES,
4969    )
4970}
4971
4972/// Build candidates under the bytes remaining beside the current entity/cache owners.
4973fn resolution_registry_from_exports_with_limit(
4974    projection: &EntityProjection,
4975    control: &IndexWorkControl,
4976    limit: u64,
4977) -> Result<ProjectResolutionRegistry, CliError> {
4978    let mut candidates = ProjectResolutionRegistry {
4979        retained_limit: limit,
4980        ..ProjectResolutionRegistry::default()
4981    };
4982    append_resolution_exports(&mut candidates, projection, control)?;
4983    Ok(candidates)
4984}
4985
4986/// Extend the same registry directly, avoiding a coexisting second candidate map.
4987fn append_resolution_exports(
4988    candidates: &mut ProjectResolutionRegistry,
4989    projection: &EntityProjection,
4990    control: &IndexWorkControl,
4991) -> Result<(), CliError> {
4992    for (index, binding) in projection.entity_exports.iter().enumerate() {
4993        check_graph_work(control, index)?;
4994        let digest = binding.entity().digest().to_string();
4995        let entity = projection.entity_by_digest.get(&digest).ok_or_else(|| {
4996            CliError::InvalidInput("resolution export owner was not staged".to_string())
4997        })?;
4998        candidates.insert_staged_candidate(binding.key(), entity)?;
4999    }
5000    Ok(())
5001}
5002
5003/// Rebind unaffected persisted candidates to the pending graph generation.
5004fn resolution_registry_from_persisted(
5005    project: ProjectInstanceId,
5006    generation: IndexGeneration,
5007    candidates: RepositoryResolutionReadPage<RepositoryResolutionCandidate>,
5008    replaced_paths: &BTreeSet<String>,
5009    control: &IndexWorkControl,
5010    limit: u64,
5011) -> Result<ProjectResolutionRegistry, CliError> {
5012    let mut by_key = ProjectResolutionRegistry {
5013        retained_limit: limit,
5014        ..ProjectResolutionRegistry::default()
5015    };
5016    append_persisted_resolution_candidates(
5017        &mut by_key,
5018        project,
5019        generation,
5020        candidates,
5021        replaced_paths,
5022        control,
5023    )?;
5024    Ok(by_key)
5025}
5026
5027/// Admit additional persisted witnesses directly into the current bounded registry.
5028fn append_persisted_resolution_candidates(
5029    by_key: &mut ProjectResolutionRegistry,
5030    project: ProjectInstanceId,
5031    generation: IndexGeneration,
5032    candidates: RepositoryResolutionReadPage<RepositoryResolutionCandidate>,
5033    replaced_paths: &BTreeSet<String>,
5034    control: &IndexWorkControl,
5035) -> Result<(), CliError> {
5036    let previous_limit = by_key.retained_limit;
5037    by_key.retained_limit = previous_limit.saturating_sub(candidates.retained_bytes);
5038    let result = (|| -> Result<(), CliError> {
5039        enforce_resolution_registry_budget_with_limit(
5040            by_key.retained_bytes,
5041            by_key.retained_limit,
5042        )?;
5043        for (index, candidate) in candidates.page.rows.into_iter().enumerate() {
5044            check_graph_work(control, index)?;
5045            if entity_owner_path(candidate.entity())
5046                .is_some_and(|path| replaced_paths.contains(path))
5047            {
5048                continue;
5049            }
5050            let entity =
5051                GraphEntity::new(project, candidate.entity().selector().clone(), generation)
5052                    .map_err(invalid_graph_contract)?;
5053            by_key.insert_candidate(candidate.key(), &entity)?;
5054        }
5055        Ok(())
5056    })();
5057    by_key.retained_limit = previous_limit;
5058    result
5059}
5060
5061/// Observe cancellation and deadline state at bounded graph-map intervals.
5062fn check_graph_work(control: &IndexWorkControl, index: usize) -> Result<(), CliError> {
5063    if index.is_multiple_of(GRAPH_WORK_CHECK_INTERVAL) {
5064        control.check(IndexWorkStage::SymbolParsing)?;
5065    }
5066    Ok(())
5067}
5068
5069/// Return the source-owner path for entities eligible to export graph keys.
5070fn entity_owner_path(entity: &GraphEntity) -> Option<&str> {
5071    match entity.selector() {
5072        EntitySelector::File { path } => Some(path.as_str()),
5073        EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
5074        EntitySelector::Package { package } => Some(package.manifest.as_str()),
5075        EntitySelector::Project
5076        | EntitySelector::Folder { .. }
5077        | EntitySelector::External { .. } => None,
5078    }
5079}
5080
5081/// Derive additive families only from exact bounded parser facts and path policy.
5082fn derived_relation_facts(
5083    graph: &SymbolGraph,
5084    keys_by_relation: &BTreeMap<usize, &[CanonicalResolutionKey]>,
5085) -> Vec<DerivedRelationFact> {
5086    let mut facts = Vec::new();
5087    let test_path = is_test_path(&graph.path);
5088    for (index, relation) in graph.relations.iter().enumerate() {
5089        if test_path && matches!(relation.kind, RelationKind::Imports | RelationKind::Calls) {
5090            let keys = keys_by_relation
5091                .get(&index)
5092                .copied()
5093                .unwrap_or_default()
5094                .to_vec();
5095            push_derived_relation(
5096                &mut facts,
5097                ExtendedRelationKind::Tests,
5098                relation.clone(),
5099                DerivedRelationTarget::Parser { keys },
5100            );
5101        }
5102        if relation.kind == RelationKind::Calls {
5103            if let Some(handler) = static_route_handler(relation) {
5104                push_derived_relation(
5105                    &mut facts,
5106                    ExtendedRelationKind::RoutesTo,
5107                    derived_parser_relation(relation, handler),
5108                    DerivedRelationTarget::Parser { keys: Vec::new() },
5109                );
5110            }
5111            if let Some(key) = static_environment_key(relation) {
5112                push_derived_relation(
5113                    &mut facts,
5114                    ExtendedRelationKind::Configures,
5115                    derived_parser_relation(relation, key),
5116                    DerivedRelationTarget::External {
5117                        system: ENVIRONMENT_SYSTEM,
5118                    },
5119                );
5120            }
5121            if let Some(kind) = static_data_access_kind(&relation.target_name)
5122                && let Some(path) = static_string_argument(&relation.context)
5123                    .and_then(normalize_static_repository_path)
5124            {
5125                push_derived_relation(
5126                    &mut facts,
5127                    kind,
5128                    derived_parser_relation(relation, path),
5129                    DerivedRelationTarget::RepositoryPath,
5130                );
5131            }
5132        }
5133    }
5134    if let Some(identity) = configuration_file_identity(&graph.path) {
5135        push_derived_relation(
5136            &mut facts,
5137            ExtendedRelationKind::Configures,
5138            file_owned_relation(graph, identity),
5139            DerivedRelationTarget::External {
5140                system: CONFIGURATION_SYSTEM,
5141            },
5142        );
5143    }
5144    if let Some(identity) = deployment_platform_identity(&graph.path) {
5145        push_derived_relation(
5146            &mut facts,
5147            ExtendedRelationKind::Deploys,
5148            file_owned_relation(graph, identity),
5149            DerivedRelationTarget::External {
5150                system: DEPLOYMENT_SYSTEM,
5151            },
5152        );
5153    }
5154    facts
5155}
5156
5157/// Retain one additive fact from the already bounded parser graph.
5158fn push_derived_relation(
5159    facts: &mut Vec<DerivedRelationFact>,
5160    kind: ExtendedRelationKind,
5161    relation: SymbolRelation,
5162    target: DerivedRelationTarget,
5163) {
5164    facts.push(DerivedRelationFact {
5165        kind,
5166        relation,
5167        target,
5168    });
5169}
5170
5171/// Copy source context while replacing only the statically selected target.
5172fn derived_parser_relation(relation: &SymbolRelation, target_name: String) -> SymbolRelation {
5173    SymbolRelation {
5174        path: relation.path.clone(),
5175        source_name: relation.source_name.clone(),
5176        target_name,
5177        kind: RelationKind::Calls,
5178        line: relation.line,
5179        context: relation.context.clone(),
5180        parser: relation.parser,
5181    }
5182}
5183
5184/// Create one file-owned content-free configuration or deployment fact.
5185fn file_owned_relation(graph: &SymbolGraph, target_name: String) -> SymbolRelation {
5186    SymbolRelation {
5187        path: graph.path.clone(),
5188        source_name: MODULE_RELATION_SOURCE.to_string(),
5189        target_name,
5190        kind: RelationKind::Calls,
5191        line: 1,
5192        context: graph.path.clone(),
5193        parser: graph.parser,
5194    }
5195}
5196
5197/// Resolve one additive relation through its closed target class.
5198fn derived_relation_resolution<'a>(
5199    project: ProjectInstanceId,
5200    generation: IndexGeneration,
5201    fact: &DerivedRelationFact,
5202    owners: &GraphOwners,
5203    graph: &SymbolGraph,
5204    symbol_index: &GraphSymbolIndex<'_>,
5205    candidates: &'a ProjectResolutionRegistry,
5206    entities: &'a BTreeMap<String, GraphEntity>,
5207    external_entities: &mut BTreeMap<String, GraphEntity>,
5208    control: &IndexWorkControl,
5209) -> Result<RelationResolution, CliError> {
5210    match &fact.target {
5211        DerivedRelationTarget::Parser { keys } => relation_resolution(
5212            project,
5213            generation,
5214            &fact.relation,
5215            None,
5216            owners,
5217            graph,
5218            symbol_index,
5219            keys,
5220            candidates,
5221            entities,
5222            external_entities,
5223            control,
5224        ),
5225        DerivedRelationTarget::RepositoryPath => {
5226            let candidate = GraphEntity::new(
5227                project,
5228                EntitySelector::File {
5229                    path: RepositoryFilePath::new(Path::new(&fact.relation.target_name))
5230                        .map_err(invalid_graph_contract)?,
5231                },
5232                generation,
5233            )
5234            .map_err(invalid_graph_contract)?;
5235            match entities.get(candidate.key().digest()) {
5236                Some(entity) if entity == &candidate => {
5237                    RelationResolution::resolved(entity).map_err(invalid_graph_contract)
5238                }
5239                Some(_conflict) => Err(CliError::InvalidInput(
5240                    "static repository-path target collided with another graph entity".to_string(),
5241                )),
5242                None => Ok(RelationResolution::Unresolved {
5243                    reference: GraphIdentityText::new(fact.relation.target_name.clone())
5244                        .map_err(invalid_graph_contract)?,
5245                }),
5246            }
5247        }
5248        DerivedRelationTarget::External { system } => {
5249            let entity = GraphEntity::new(
5250                project,
5251                EntitySelector::External {
5252                    external: ExternalSelector {
5253                        system: GraphIdentityText::new(*system).map_err(invalid_graph_contract)?,
5254                        identity: GraphIdentityText::new(fact.relation.target_name.clone())
5255                            .map_err(invalid_graph_contract)?,
5256                    },
5257                },
5258                generation,
5259            )
5260            .map_err(invalid_graph_contract)?;
5261            let resolution =
5262                RelationResolution::external(&entity).map_err(invalid_graph_contract)?;
5263            insert_entity(external_entities, entity)?;
5264            Ok(resolution)
5265        }
5266    }
5267}
5268
5269/// Return whether a repository path is an accepted test-source location.
5270fn is_test_path(path: &str) -> bool {
5271    let normalized = path.replace('\\', "/").to_ascii_lowercase();
5272    let file = normalized.rsplit('/').next().unwrap_or(&normalized);
5273    normalized
5274        .split('/')
5275        .any(|segment| matches!(segment, "test" | "tests" | "__tests__"))
5276        || file.contains(".test.")
5277        || file.contains(".spec.")
5278        || file
5279            .split_once('.')
5280            .is_some_and(|(stem, _extension)| stem.ends_with("_test") || stem.starts_with("test_"))
5281}
5282
5283/// Extract one exact handler identifier from a static route registration.
5284fn static_route_handler(relation: &SymbolRelation) -> Option<String> {
5285    let leaf = call_leaf(&relation.target_name);
5286    if !matches!(
5287        leaf,
5288        "route" | "add_route" | "map_get" | "map_post" | "map_put" | "map_patch" | "map_delete"
5289    ) {
5290        return None;
5291    }
5292    let route = static_string_argument(&relation.context)?;
5293    if !route.starts_with('/')
5294        || route.chars().any(char::is_control)
5295        || relation.context.matches(',').count() != 1
5296    {
5297        return None;
5298    }
5299    let handler = relation
5300        .context
5301        .rsplit_once(',')?
5302        .1
5303        .trim()
5304        .trim_end_matches([')', ';'])
5305        .trim();
5306    let valid_handler = !handler.is_empty()
5307        && handler != relation.source_name
5308        && handler
5309            .chars()
5310            .next()
5311            .is_some_and(|character| !character.is_ascii_digit())
5312        && handler.chars().all(|character| {
5313            character.is_ascii_alphanumeric() || matches!(character, '_' | ':' | '.' | '$')
5314        });
5315    valid_handler.then(|| handler.trim_matches('$').to_string())
5316}
5317
5318/// Extract one static environment key without retaining its value.
5319fn static_environment_key(relation: &SymbolRelation) -> Option<String> {
5320    let normalized = relation
5321        .target_name
5322        .trim()
5323        .trim_end_matches('!')
5324        .to_ascii_lowercase();
5325    if !(normalized.ends_with("env::var")
5326        || normalized.ends_with("env::var_os")
5327        || normalized.ends_with("os.getenv")
5328        || normalized.ends_with("getenvironmentvariable")
5329        || matches!(normalized.as_str(), "getenv" | "getenv_os"))
5330    {
5331        return None;
5332    }
5333    let key = static_string_argument(&relation.context)?;
5334    (!key.is_empty()
5335        && key.len() <= 128
5336        && key
5337            .bytes()
5338            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'))
5339    .then(|| key.to_string())
5340}
5341
5342/// Classify a call as one accepted bounded static read or write API.
5343fn static_data_access_kind(target: &str) -> Option<ExtendedRelationKind> {
5344    let normalized = target.trim().trim_end_matches('!').to_ascii_lowercase();
5345    let leaf = call_leaf(&normalized);
5346    if matches!(
5347        leaf,
5348        "read" | "read_to_string" | "readfile" | "readfilesync"
5349    ) || normalized.ends_with("file::open")
5350    {
5351        Some(ExtendedRelationKind::Reads)
5352    } else if matches!(leaf, "write" | "writefile" | "writefilesync" | "create")
5353        || normalized.ends_with("file::create")
5354    {
5355        Some(ExtendedRelationKind::Writes)
5356    } else {
5357        None
5358    }
5359}
5360
5361/// Return the normalized leaf of one qualified call target.
5362fn call_leaf(target: &str) -> &str {
5363    target
5364        .trim()
5365        .trim_end_matches('!')
5366        .rsplit([':', '.', '/'])
5367        .find(|part| !part.is_empty())
5368        .unwrap_or_default()
5369}
5370
5371/// Return the first argument only when it is one complete static string literal.
5372fn static_string_argument(context: &str) -> Option<&str> {
5373    let open = context.find('(')?;
5374    let argument = context[open + 1..].trim_start();
5375    let quote = argument.chars().next()?;
5376    if !matches!(quote, '\'' | '"') {
5377        return None;
5378    }
5379    let value = &argument[quote.len_utf8()..];
5380    let end = value.find(quote)?;
5381    let value = &value[..end];
5382    (!value.contains('\\')
5383        && !value.contains("${")
5384        && !value.contains("#{")
5385        && !value.chars().any(char::is_control))
5386    .then_some(value)
5387}
5388
5389/// Normalize one static relative source literal into a repository path.
5390fn normalize_static_repository_path(value: &str) -> Option<String> {
5391    let value = value.replace('\\', "/");
5392    if value.is_empty()
5393        || value.starts_with('/')
5394        || value.starts_with('~')
5395        || value.contains("://")
5396        || value.contains('$')
5397        || value.contains('{')
5398        || value.contains('}')
5399        || value
5400            .split('/')
5401            .next()
5402            .is_some_and(|part| part.contains(':'))
5403    {
5404        return None;
5405    }
5406    let mut parts = Vec::new();
5407    for part in value.split('/') {
5408        match part {
5409            "" | "." => {}
5410            ".." => {
5411                parts.pop()?;
5412            }
5413            part if part == "."
5414                || part == ".."
5415                || part.chars().any(char::is_control)
5416                || part.trim() != part =>
5417            {
5418                return None;
5419            }
5420            part => parts.push(part.to_string()),
5421        }
5422    }
5423    (!parts.is_empty()).then(|| parts.join("/"))
5424}
5425
5426/// Classify one exact repository configuration filename without reading values.
5427fn configuration_file_identity(path: &str) -> Option<String> {
5428    let normalized = path.replace('\\', "/").to_ascii_lowercase();
5429    let file = normalized.rsplit('/').next().unwrap_or(&normalized);
5430    let identity = if file == ".env" || file.starts_with(".env.") {
5431        "dotenv"
5432    } else if matches!(
5433        file,
5434        "config.json"
5435            | "config.yaml"
5436            | "config.yml"
5437            | "config.toml"
5438            | "settings.json"
5439            | "settings.yaml"
5440            | "settings.yml"
5441            | "settings.toml"
5442            | "appsettings.json"
5443    ) {
5444        "application-config"
5445    } else {
5446        return None;
5447    };
5448    Some(identity.to_string())
5449}
5450
5451/// Classify one accepted infrastructure path into a content-free platform.
5452fn deployment_platform_identity(path: &str) -> Option<String> {
5453    let normalized = path.replace('\\', "/").to_ascii_lowercase();
5454    let file = normalized.rsplit('/').next().unwrap_or(&normalized);
5455    let identity = if file_has_extension(file, "tf")
5456        || file
5457            .strip_suffix(".json")
5458            .is_some_and(|stem| file_has_extension(stem, "tf"))
5459    {
5460        "terraform"
5461    } else if file == "dockerfile"
5462        || file.starts_with("dockerfile.")
5463        || matches!(
5464            file,
5465            "compose.yaml" | "compose.yml" | "docker-compose.yaml" | "docker-compose.yml"
5466        )
5467    {
5468        "containers"
5469    } else if matches!(
5470        file,
5471        "chart.yaml" | "kustomization.yaml" | "kustomization.yml"
5472    ) || (normalized
5473        .split('/')
5474        .any(|segment| matches!(segment, "k8s" | "kubernetes" | "helm" | "kustomize"))
5475        && matches!(
5476            Path::new(file).extension().and_then(|value| value.to_str()),
5477            Some("yaml" | "yml" | "json")
5478        ))
5479    {
5480        "kubernetes"
5481    } else if file_has_extension(file, "bicep") {
5482        "azure-bicep"
5483    } else if file == "sam-template.yaml"
5484        || (file.contains("cloudformation")
5485            && matches!(
5486                Path::new(file).extension().and_then(|value| value.to_str()),
5487                Some("yaml" | "yml" | "json")
5488            ))
5489    {
5490        "cloudformation"
5491    } else if file == "playbook.yaml" || file == "playbook.yml" {
5492        "ansible"
5493    } else {
5494        return None;
5495    };
5496    Some(identity.to_string())
5497}
5498
5499/// Match one exact extension without platform case assumptions.
5500fn file_has_extension(file: &str, expected: &str) -> bool {
5501    Path::new(file)
5502        .extension()
5503        .is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
5504}
5505
5506/// Resolve one parser relation's unique local source entity when possible.
5507fn relation_source<'a>(
5508    owners: &GraphOwners,
5509    entities: &'a BTreeMap<String, GraphEntity>,
5510    graph: &SymbolGraph,
5511    symbol_index: &GraphSymbolIndex<'_>,
5512    relation: &SymbolRelation,
5513    relation_index: Option<usize>,
5514    control: &IndexWorkControl,
5515) -> Result<&'a GraphEntity, CliError> {
5516    let file = entities.get(&owners.file_digest).ok_or_else(|| {
5517        CliError::InvalidInput("graph file owner entity was not staged".to_string())
5518    })?;
5519    let is_php_call = relation.kind == RelationKind::Calls
5520        && graph
5521            .language
5522            .as_deref()
5523            .is_some_and(|language| language.eq_ignore_ascii_case("php"));
5524    let is_php_contains = relation.kind == RelationKind::Contains
5525        && graph
5526            .language
5527            .as_deref()
5528            .is_some_and(|language| language.eq_ignore_ascii_case("php"));
5529    if relation.kind == RelationKind::Calls
5530        && !is_php_call
5531        && let Some(index) = relation_index
5532        && let Some(calls) = &symbol_index.source_calls
5533    {
5534        return Ok(calls
5535            .get(&index)
5536            .and_then(|call| call.owner)
5537            .and_then(|owner| owners.symbol_digests.get(owner))
5538            .and_then(Option::as_ref)
5539            .and_then(|digest| entities.get(digest))
5540            .unwrap_or(file));
5541    }
5542    let php_source = if is_php_call {
5543        unique_php_call_source(
5544            graph,
5545            symbol_index,
5546            &relation.source_name,
5547            relation.line,
5548            control,
5549        )?
5550    } else if is_php_contains {
5551        unique_php_containment_source(graph, symbol_index, relation, control)?
5552    } else {
5553        None
5554    };
5555    let indices = if is_php_call || is_php_contains {
5556        php_source.as_slice()
5557    } else {
5558        symbol_index.get(&relation.source_name)
5559    };
5560    let mut matched = None;
5561    for (candidate_index, &index) in indices.iter().enumerate() {
5562        check_graph_work(control, candidate_index)?;
5563        let digest = owners.symbol_digests.get(index).ok_or_else(|| {
5564            CliError::InvalidInput("graph symbol owner index was not staged".to_string())
5565        })?;
5566        let Some(digest) = digest else {
5567            continue;
5568        };
5569        let entity = entities.get(digest).ok_or_else(|| {
5570            CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
5571        })?;
5572        if matched.is_some() {
5573            return Ok(file);
5574        }
5575        matched = Some(entity);
5576    }
5577    Ok(matched.unwrap_or(file))
5578}
5579
5580/// Resolve one relation from canonical dependency keys and bounded candidates.
5581#[derive(Clone, Copy)]
5582struct ResolutionMatches<'a> {
5583    /// First target in stable digest order when at least one target exists.
5584    first: Option<&'a GraphEntity>,
5585    /// Exact number of distinct target entities.
5586    count: u32,
5587}
5588
5589/// Resolve one relation from canonical dependency keys and bounded candidates.
5590fn relation_resolution<'a>(
5591    project: ProjectInstanceId,
5592    generation: IndexGeneration,
5593    relation: &projectatlas_core::symbols::SymbolRelation,
5594    relation_index: Option<usize>,
5595    owners: &GraphOwners,
5596    graph: &SymbolGraph,
5597    symbol_index: &GraphSymbolIndex<'_>,
5598    dependency_keys: &[CanonicalResolutionKey],
5599    candidates: &'a ProjectResolutionRegistry,
5600    staged_entities: &'a BTreeMap<String, GraphEntity>,
5601    external_entities: &mut BTreeMap<String, GraphEntity>,
5602    control: &IndexWorkControl,
5603) -> Result<RelationResolution, CliError> {
5604    let matches = match relation.kind {
5605        RelationKind::Contains => local_relation_matches(
5606            relation,
5607            relation_index,
5608            owners,
5609            graph,
5610            symbol_index,
5611            staged_entities,
5612            control,
5613        )?,
5614        RelationKind::Calls => {
5615            let source_policy = relation_index.and_then(|index| {
5616                symbol_index
5617                    .source_calls
5618                    .as_ref()
5619                    .map(|calls| calls.get(&index).copied())
5620            });
5621            if let Some(policy) = source_policy {
5622                match policy.map(|call| &call.binding) {
5623                    Some(SourceCallBinding::LocalDeclaration(symbol)) => source_local_call_matches(
5624                        std::slice::from_ref(symbol),
5625                        owners,
5626                        staged_entities,
5627                        control,
5628                    )?,
5629                    Some(SourceCallBinding::LocalDeclarations(symbols)) => {
5630                        source_local_call_matches(symbols, owners, staged_entities, control)?
5631                    }
5632                    Some(
5633                        SourceCallBinding::Imported { .. }
5634                        | SourceCallBinding::Qualified(_)
5635                        | SourceCallBinding::RustPath(_),
5636                    ) => registry_resolution_matches(
5637                        dependency_keys,
5638                        ResolutionCandidateKind::Declaration,
5639                        candidates,
5640                        staged_entities,
5641                        control,
5642                    )?,
5643                    Some(SourceCallBinding::Shadowed | SourceCallBinding::Unresolved) | None => {
5644                        ResolutionMatches {
5645                            first: None,
5646                            count: 0,
5647                        }
5648                    }
5649                    Some(SourceCallBinding::PhpNamespace) => {
5650                        let local = local_relation_matches(
5651                            relation,
5652                            relation_index,
5653                            owners,
5654                            graph,
5655                            symbol_index,
5656                            staged_entities,
5657                            control,
5658                        )?;
5659                        if local.count == 0 {
5660                            registry_resolution_matches(
5661                                dependency_keys,
5662                                ResolutionCandidateKind::Declaration,
5663                                candidates,
5664                                staged_entities,
5665                                control,
5666                            )?
5667                        } else {
5668                            local
5669                        }
5670                    }
5671                }
5672            } else {
5673                let local = local_relation_matches(
5674                    relation,
5675                    relation_index,
5676                    owners,
5677                    graph,
5678                    symbol_index,
5679                    staged_entities,
5680                    control,
5681                )?;
5682                if local.count == 0 {
5683                    registry_resolution_matches(
5684                        dependency_keys,
5685                        ResolutionCandidateKind::Declaration,
5686                        candidates,
5687                        staged_entities,
5688                        control,
5689                    )?
5690                } else {
5691                    local
5692                }
5693            }
5694        }
5695        RelationKind::Imports | RelationKind::DependsOn => registry_resolution_matches(
5696            dependency_keys,
5697            ResolutionCandidateKind::Any,
5698            candidates,
5699            staged_entities,
5700            control,
5701        )?,
5702    };
5703    match matches.count {
5704        0 => {
5705            if let Some(external) =
5706                source_external_selector(graph, relation, relation_index, symbol_index)?
5707            {
5708                let entity =
5709                    GraphEntity::new(project, EntitySelector::External { external }, generation)
5710                        .map_err(invalid_graph_contract)?;
5711                let resolution =
5712                    RelationResolution::external(&entity).map_err(invalid_graph_contract)?;
5713                insert_entity(external_entities, entity)?;
5714                return Ok(resolution);
5715            }
5716            Ok(RelationResolution::Unresolved {
5717                reference: GraphIdentityText::new(relation_reference(relation))
5718                    .map_err(invalid_graph_contract)?,
5719            })
5720        }
5721        1 => RelationResolution::resolved(
5722            matches
5723                .first
5724                .ok_or_else(|| CliError::InvalidInput("resolved target disappeared".to_string()))?,
5725        )
5726        .map_err(invalid_graph_contract),
5727        count => Ok(RelationResolution::Ambiguous {
5728            reference: GraphIdentityText::new(relation_reference(relation))
5729                .map_err(invalid_graph_contract)?,
5730            candidates: NonZeroU32::new(count).ok_or_else(|| {
5731                CliError::InvalidInput("ambiguous target count was zero".to_string())
5732            })?,
5733        }),
5734    }
5735}
5736
5737/// Resolve every surviving proven declaration without narrowing a lost overload set.
5738fn source_local_call_matches<'a>(
5739    symbols: &[usize],
5740    owners: &GraphOwners,
5741    entities: &'a BTreeMap<String, GraphEntity>,
5742    control: &IndexWorkControl,
5743) -> Result<ResolutionMatches<'a>, CliError> {
5744    let mut targets = BTreeMap::new();
5745    for symbol in symbols {
5746        control.check(IndexWorkStage::SymbolParsing)?;
5747        let Some(target) = owners
5748            .symbol_digests
5749            .get(*symbol)
5750            .and_then(Option::as_ref)
5751            .and_then(|digest| entities.get(digest))
5752        else {
5753            return Ok(ResolutionMatches {
5754                first: None,
5755                count: 0,
5756            });
5757        };
5758        targets.insert(target.key().digest(), target);
5759    }
5760    Ok(ResolutionMatches {
5761        first: targets.first_key_value().map(|(_, entity)| *entity),
5762        count: u32::try_from(targets.len()).map_err(|error| {
5763            CliError::InvalidInput(format!("source call candidate count overflowed: {error}"))
5764        })?,
5765    })
5766}
5767
5768/// Resolve exact declarations owned by the relation's source file.
5769fn local_relation_matches<'a>(
5770    relation: &projectatlas_core::symbols::SymbolRelation,
5771    relation_index: Option<usize>,
5772    owners: &GraphOwners,
5773    graph: &SymbolGraph,
5774    symbol_index: &GraphSymbolIndex<'_>,
5775    staged_entities: &'a BTreeMap<String, GraphEntity>,
5776    control: &IndexWorkControl,
5777) -> Result<ResolutionMatches<'a>, CliError> {
5778    let mut targets = BTreeMap::<&str, &GraphEntity>::new();
5779    let mut global_fallback_targets = BTreeMap::<&str, &GraphEntity>::new();
5780    let target_name = relation.target_name.trim();
5781    let is_php_call = relation.kind == RelationKind::Calls
5782        && graph
5783            .language
5784            .as_deref()
5785            .is_some_and(|language| language.eq_ignore_ascii_case("php"));
5786    let is_php_contains = relation.kind == RelationKind::Contains
5787        && graph
5788            .language
5789            .as_deref()
5790            .is_some_and(|language| language.eq_ignore_ascii_case("php"));
5791    let php_containment_source = if is_php_contains {
5792        unique_php_containment_source(graph, symbol_index, relation, control)?
5793            .and_then(|index| graph.symbols.get(index))
5794    } else {
5795        None
5796    };
5797    let source_parent = if relation.kind == RelationKind::Calls {
5798        unique_source_parent(
5799            graph,
5800            symbol_index,
5801            &relation.source_name,
5802            is_php_call.then_some(relation.line),
5803            control,
5804        )?
5805    } else {
5806        None
5807    };
5808    let known_source_namespace = if is_php_call {
5809        unique_php_source_namespace(graph, symbol_index, source_parent, relation, control)?
5810    } else {
5811        None
5812    };
5813    if is_php_call
5814        && target_name
5815            .split_once("::")
5816            .is_some_and(|(scope, _)| scope.eq_ignore_ascii_case("self"))
5817        && let Some(parent) = source_parent
5818    {
5819        for (candidate_index, &index) in symbol_index.get(parent).iter().enumerate() {
5820            check_graph_work(control, candidate_index)?;
5821            let owner = graph.symbols.get(index).ok_or_else(|| {
5822                CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5823            })?;
5824            if owner.kind == SymbolKind::Trait
5825                && owner.line_start <= relation.line
5826                && relation.line <= owner.line_end
5827            {
5828                // A consuming class can override the trait member selected by self.
5829                return Ok(ResolutionMatches {
5830                    first: None,
5831                    count: 0,
5832                });
5833            }
5834        }
5835    }
5836    let php_imports_may_alias = symbol_index.php_imports_have_unknown_scope
5837        || known_source_namespace.map_or(
5838            !symbol_index.php_import_positions.is_empty(),
5839            |namespace| {
5840                let namespace = namespace.to_ascii_lowercase();
5841                let block_start = symbol_index
5842                    .php_namespace_start_lines
5843                    .get(&namespace)
5844                    .and_then(|starts| starts.range(..=relation.line).next_back())
5845                    .copied()
5846                    .unwrap_or(0);
5847                symbol_index
5848                    .php_import_positions
5849                    .get(&namespace)
5850                    .and_then(|positions| positions.range(block_start..=relation.line).next())
5851                    .is_some_and(|(&line, &import_index)| {
5852                        line < relation.line
5853                            || import_index
5854                                .zip(relation_index)
5855                                .is_none_or(|(import, call)| import <= call)
5856                    })
5857            },
5858        );
5859    let namespace_relative_target = if is_php_call
5860        && let Some((prefix, relative)) = target_name.split_once('\\')
5861        && !prefix.is_empty()
5862        && (prefix.eq_ignore_ascii_case("namespace") || !php_imports_may_alias)
5863    {
5864        let Some(namespace) = known_source_namespace else {
5865            return Ok(ResolutionMatches {
5866                first: None,
5867                count: 0,
5868            });
5869        };
5870        let relative = if prefix.eq_ignore_ascii_case("namespace") {
5871            relative
5872        } else {
5873            target_name
5874        };
5875        Some(if namespace.is_empty() {
5876            format!("\\{relative}")
5877        } else {
5878            format!("\\{namespace}\\{relative}")
5879        })
5880    } else {
5881        None
5882    };
5883    let lookup_target = namespace_relative_target.as_deref().unwrap_or(target_name);
5884    if is_php_call
5885        && php_imports_may_alias
5886        && !lookup_target.starts_with('\\')
5887        && !lookup_target
5888            .split_once("::")
5889            .is_some_and(|(scope, _)| scope.eq_ignore_ascii_case("self"))
5890    {
5891        // An import in this namespace, or an import with unknown scope, can
5892        // replace an unrooted name. Do not assume a same-file declaration wins.
5893        return Ok(ResolutionMatches {
5894            first: None,
5895            count: 0,
5896        });
5897    }
5898    let (lookup_name, scoped_parent, mut scoped_namespace, target_namespace) = if is_php_call {
5899        let Some(lookup) = php_call_lookup(lookup_target, source_parent) else {
5900            return Ok(ResolutionMatches {
5901                first: None,
5902                count: 0,
5903            });
5904        };
5905        lookup
5906    } else {
5907        (target_name, None, None, None)
5908    };
5909    if is_php_call && scoped_parent.is_some() && scoped_namespace.is_none() {
5910        scoped_namespace = known_source_namespace;
5911        if scoped_namespace.is_none() {
5912            return Ok(ResolutionMatches {
5913                first: None,
5914                count: 0,
5915            });
5916        }
5917    }
5918    let source_namespace = if is_php_call && scoped_parent.is_none() {
5919        if target_namespace.is_some() {
5920            target_namespace
5921        } else {
5922            known_source_namespace
5923        }
5924    } else {
5925        None
5926    };
5927    let candidate_indices = if is_php_call {
5928        symbol_index.get_php_call(lookup_name)
5929    } else {
5930        symbol_index.get(lookup_name)
5931    };
5932    for (candidate_index, &row_index) in candidate_indices.iter().enumerate() {
5933        check_graph_work(control, candidate_index)?;
5934        let symbol = graph.symbols.get(row_index).ok_or_else(|| {
5935            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5936        })?;
5937        let digest = owners.symbol_digests.get(row_index).ok_or_else(|| {
5938            CliError::InvalidInput("graph symbol owner index was not staged".to_string())
5939        })?;
5940        let exact_match = match relation.kind {
5941            RelationKind::Contains => {
5942                symbol.name == lookup_name
5943                    && symbol.parent.as_deref() == Some(relation.source_name.as_str())
5944                    && if is_php_contains {
5945                        if let Some(source) = php_containment_source {
5946                            symbol.line_start == relation.line
5947                                && symbol.detail.as_deref() == Some(relation.context.as_str())
5948                                && php_entity_matches_qualified_scope(
5949                                    staged_entities,
5950                                    digest.as_ref(),
5951                                    &source.name,
5952                                    source.parent.as_deref().unwrap_or(""),
5953                                )?
5954                        } else {
5955                            false
5956                        }
5957                    } else {
5958                        true
5959                    }
5960            }
5961            RelationKind::Calls => {
5962                let name_matches = if is_php_call {
5963                    symbol.name.eq_ignore_ascii_case(lookup_name)
5964                } else {
5965                    symbol.name == lookup_name
5966                };
5967                name_matches
5968                    && (!is_php_call
5969                        || symbol.kind
5970                            == if scoped_parent.is_some() {
5971                                SymbolKind::Method
5972                            } else {
5973                                SymbolKind::Function
5974                            })
5975                    && scoped_parent.is_none_or(|parent| {
5976                        symbol.parent.as_deref().is_some_and(|symbol_parent| {
5977                            if is_php_call {
5978                                symbol_parent.eq_ignore_ascii_case(parent)
5979                            } else {
5980                                symbol_parent == parent
5981                            }
5982                        })
5983                    })
5984                    && if let (Some(namespace), Some(scoped_parent)) =
5985                        (scoped_namespace, scoped_parent)
5986                    {
5987                        php_entity_matches_qualified_scope(
5988                            staged_entities,
5989                            digest.as_ref(),
5990                            scoped_parent,
5991                            namespace,
5992                        )?
5993                    } else {
5994                        true
5995                    }
5996                    && source_namespace.is_none_or(|namespace| {
5997                        symbol.parent.as_deref().is_some_and(|symbol_parent| {
5998                            symbol_parent.eq_ignore_ascii_case(namespace)
5999                        }) || (target_namespace.is_none_or(str::is_empty)
6000                            && symbol.parent.is_none())
6001                    })
6002                    && (scoped_parent.is_some()
6003                        || source_namespace.is_some()
6004                        || symbol.parent.is_none()
6005                        || symbol.parent.as_deref() == Some(relation.source_name.as_str())
6006                        || source_parent.is_some_and(|source_parent| {
6007                            symbol.parent.as_deref() == Some(source_parent)
6008                        }))
6009            }
6010            RelationKind::Imports | RelationKind::DependsOn => false,
6011        };
6012        if exact_match && let Some(digest) = digest {
6013            let entity = staged_entities.get(digest).ok_or_else(|| {
6014                CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
6015            })?;
6016            let is_global_fallback = is_php_call
6017                && scoped_parent.is_none()
6018                && target_namespace.is_none()
6019                && source_namespace.is_some()
6020                && symbol.kind == SymbolKind::Function
6021                && symbol.parent.is_none();
6022            let distinct_target_count = targets.len().saturating_add(global_fallback_targets.len());
6023            let destination = if is_global_fallback {
6024                &mut global_fallback_targets
6025            } else {
6026                &mut targets
6027            };
6028            if !destination.contains_key(entity.key().digest()) {
6029                enforce_resolution_match_budget(distinct_target_count.saturating_add(1))?;
6030            }
6031            destination.insert(entity.key().digest(), entity);
6032        }
6033    }
6034    let targets = if targets.is_empty() {
6035        global_fallback_targets
6036    } else {
6037        targets
6038    };
6039    let count = distinct_resolution_count(targets.len())?;
6040    Ok(ResolutionMatches {
6041        first: targets.into_values().next(),
6042        count,
6043    })
6044}
6045
6046/// Select the local PHP call member and, for scoped calls, its owning type.
6047fn php_call_lookup<'a>(
6048    target_name: &'a str,
6049    source_parent: Option<&'a str>,
6050) -> Option<(&'a str, Option<&'a str>, Option<&'a str>, Option<&'a str>)> {
6051    let Some((scope, member)) = target_name.rsplit_once("::") else {
6052        let Some(qualified_target) = target_name.strip_prefix('\\') else {
6053            return Some((target_name, None, None, None));
6054        };
6055        let Some((namespace, function)) = qualified_target.rsplit_once('\\') else {
6056            return Some((qualified_target, None, None, Some("")));
6057        };
6058        if namespace.is_empty() || function.is_empty() {
6059            return None;
6060        }
6061        return Some((function, None, None, Some(namespace)));
6062    };
6063    if scope.is_empty() || member.is_empty() {
6064        return None;
6065    }
6066    if scope.eq_ignore_ascii_case("self") {
6067        return source_parent.map(|parent| (member, Some(parent), None, None));
6068    }
6069    if scope.eq_ignore_ascii_case("static") {
6070        return None;
6071    }
6072    if scope.eq_ignore_ascii_case("parent") {
6073        return None;
6074    }
6075    if scope.contains('\\') && !scope.starts_with('\\') {
6076        return None;
6077    }
6078    let scope_without_root = scope.strip_prefix('\\').unwrap_or(scope);
6079    let (scope, namespace) = match scope_without_root.rsplit_once('\\') {
6080        Some((namespace, scope)) if !namespace.is_empty() && !scope.is_empty() => {
6081            (scope, Some(namespace))
6082        }
6083        None if scope.starts_with('\\') && !scope_without_root.is_empty() => {
6084            (scope_without_root, Some(""))
6085        }
6086        None if !scope_without_root.is_empty() => (scope_without_root, None),
6087        _ => return None,
6088    };
6089    Some((member, Some(scope), namespace, None))
6090}
6091
6092/// Confirm a PHP entity belongs to a qualified owner scope.
6093fn php_entity_matches_qualified_scope(
6094    staged_entities: &BTreeMap<String, GraphEntity>,
6095    digest: Option<&String>,
6096    scoped_parent: &str,
6097    namespace: &str,
6098) -> Result<bool, CliError> {
6099    let Some(digest) = digest else {
6100        return Ok(false);
6101    };
6102    let entity = staged_entities.get(digest).ok_or_else(|| {
6103        CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
6104    })?;
6105    let EntitySelector::Symbol { symbol } = entity.selector() else {
6106        return Ok(false);
6107    };
6108    let Some(parent) = symbol.parent.as_ref() else {
6109        return Ok(false);
6110    };
6111    Ok(
6112        if let Some((owner_namespace, owner_name)) = parent.as_str().rsplit_once("::") {
6113            owner_name.eq_ignore_ascii_case(scoped_parent)
6114                && owner_namespace.eq_ignore_ascii_case(namespace)
6115        } else {
6116            namespace.is_empty() && parent.as_str().eq_ignore_ascii_case(scoped_parent)
6117        },
6118    )
6119}
6120
6121/// Prove a PHP caller's namespace, including the empty global namespace.
6122fn unique_php_source_namespace<'a>(
6123    graph: &'a SymbolGraph,
6124    symbol_index: &GraphSymbolIndex<'_>,
6125    source_parent: Option<&str>,
6126    relation: &projectatlas_core::symbols::SymbolRelation,
6127    control: &IndexWorkControl,
6128) -> Result<Option<&'a str>, CliError> {
6129    let Some(source_parent) = source_parent else {
6130        // Partial PHP coverage retains the grammar's proven global source marker.
6131        if relation.source_name == MODULE_RELATION_SOURCE {
6132            return Ok(Some(""));
6133        }
6134        let mut global_function = false;
6135        let mut namespace = None;
6136        for (candidate_index, &row_index) in
6137            symbol_index.get(&relation.source_name).iter().enumerate()
6138        {
6139            check_graph_work(control, candidate_index)?;
6140            let symbol = graph.symbols.get(row_index).ok_or_else(|| {
6141                CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6142            })?;
6143            if matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method)
6144                && symbol.line_start <= relation.line
6145                && relation.line <= symbol.line_end
6146            {
6147                if symbol.kind != SymbolKind::Function || symbol.parent.is_some() {
6148                    return Ok(None);
6149                }
6150                global_function = true;
6151            } else if symbol.kind == SymbolKind::Module {
6152                namespace = Some(symbol.name.as_str());
6153            }
6154        }
6155        return Ok(if global_function { Some("") } else { namespace });
6156    };
6157    let mut namespace = None;
6158    let mut module_namespace = None;
6159    let mut source_found = false;
6160    for (candidate_index, &row_index) in symbol_index.get(source_parent).iter().enumerate() {
6161        check_graph_work(control, candidate_index)?;
6162        let symbol = graph.symbols.get(row_index).ok_or_else(|| {
6163            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6164        })?;
6165        let candidate = match symbol.kind {
6166            SymbolKind::Class | SymbolKind::Interface | SymbolKind::Trait | SymbolKind::Enum => {
6167                if relation.line < symbol.line_start || relation.line > symbol.line_end {
6168                    continue;
6169                }
6170                Some(symbol.parent.as_deref().unwrap_or(""))
6171            }
6172            SymbolKind::Module if symbol.name == source_parent => {
6173                module_namespace = Some(symbol.name.as_str());
6174                continue;
6175            }
6176            _ => continue,
6177        };
6178        if !source_found {
6179            namespace = candidate;
6180            source_found = true;
6181        } else if namespace != candidate {
6182            return Ok(None);
6183        }
6184    }
6185    // A line-matched containing type owns the call before a same-named namespace.
6186    Ok(namespace.or(module_namespace))
6187}
6188
6189/// Return one unambiguous containing scope for the parser relation source.
6190fn unique_source_parent<'a>(
6191    graph: &'a SymbolGraph,
6192    symbol_index: &GraphSymbolIndex<'_>,
6193    source_name: &str,
6194    relation_line: Option<usize>,
6195    control: &IndexWorkControl,
6196) -> Result<Option<&'a str>, CliError> {
6197    if let Some(line) = relation_line {
6198        let Some(index) = unique_php_call_source(graph, symbol_index, source_name, line, control)?
6199        else {
6200            return Ok(None);
6201        };
6202        let symbol = graph.symbols.get(index).ok_or_else(|| {
6203            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6204        })?;
6205        return Ok(symbol.parent.as_deref());
6206    }
6207    let mut parent = None;
6208    let mut source_found = false;
6209    for (candidate_index, &row_index) in symbol_index.get(source_name).iter().enumerate() {
6210        check_graph_work(control, candidate_index)?;
6211        let symbol = graph.symbols.get(row_index).ok_or_else(|| {
6212            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6213        })?;
6214        let candidate = symbol.parent.as_deref();
6215        if !source_found {
6216            parent = candidate;
6217            source_found = true;
6218        } else if parent != candidate {
6219            return Ok(None);
6220        }
6221    }
6222    Ok(parent)
6223}
6224
6225/// Select a PHP containment owner from the target's exact declaration span.
6226fn unique_php_containment_source(
6227    graph: &SymbolGraph,
6228    symbol_index: &GraphSymbolIndex<'_>,
6229    relation: &SymbolRelation,
6230    control: &IndexWorkControl,
6231) -> Result<Option<usize>, CliError> {
6232    let mut target = None;
6233    for (candidate_index, &index) in symbol_index.get(&relation.target_name).iter().enumerate() {
6234        check_graph_work(control, candidate_index)?;
6235        let symbol = graph.symbols.get(index).ok_or_else(|| {
6236            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6237        })?;
6238        if symbol.parent.as_deref() == Some(relation.source_name.as_str())
6239            && symbol.line_start == relation.line
6240            && symbol.detail.as_deref() == Some(relation.context.as_str())
6241            && target.replace(index).is_some()
6242        {
6243            return Ok(None);
6244        }
6245    }
6246    let Some(target) = target else {
6247        return Ok(None);
6248    };
6249    let Some(target_span) = graph.symbols[target].source_selector else {
6250        return Ok(None);
6251    };
6252    let mut matched = None;
6253    let mut module = None;
6254    for (candidate_index, &index) in symbol_index.get(&relation.source_name).iter().enumerate() {
6255        check_graph_work(control, candidate_index)?;
6256        let symbol = graph.symbols.get(index).ok_or_else(|| {
6257            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6258        })?;
6259        let contains = match symbol.kind {
6260            SymbolKind::Module => {
6261                module.get_or_insert(index);
6262                false
6263            }
6264            SymbolKind::Class | SymbolKind::Interface | SymbolKind::Trait | SymbolKind::Enum => {
6265                index != target
6266                    && symbol.source_selector.is_some_and(|span| {
6267                        span.byte_start <= target_span.byte_start
6268                            && target_span.byte_end <= span.byte_end
6269                    })
6270            }
6271            _ => false,
6272        };
6273        if contains && matched.replace(index).is_some() {
6274            return Ok(None);
6275        }
6276    }
6277    Ok(matched.or(module))
6278}
6279
6280/// Prefer a proven PHP callable source, falling back to a unique namespace owner.
6281fn unique_php_call_source(
6282    graph: &SymbolGraph,
6283    symbol_index: &GraphSymbolIndex<'_>,
6284    source_name: &str,
6285    line: usize,
6286    control: &IndexWorkControl,
6287) -> Result<Option<usize>, CliError> {
6288    let mut callable = None;
6289    let mut callable_boundary = false;
6290    let mut module = None;
6291    for (candidate_index, &index) in symbol_index.get(source_name).iter().enumerate() {
6292        check_graph_work(control, candidate_index)?;
6293        let symbol = graph.symbols.get(index).ok_or_else(|| {
6294            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6295        })?;
6296        match symbol.kind {
6297            SymbolKind::Function | SymbolKind::Method
6298                if symbol.line_start <= line && line <= symbol.line_end =>
6299            {
6300                if callable.replace(index).is_some() {
6301                    return Ok(None);
6302                }
6303                callable_boundary = line == symbol.line_start || line == symbol.line_end;
6304            }
6305            SymbolKind::Module => {
6306                // Reopened blocks share one line-independent namespace entity.
6307                module = Some(index);
6308            }
6309            _ => {}
6310        }
6311    }
6312    if callable.is_some() {
6313        // Line-only facts cannot distinguish a boundary-line call from a
6314        // same-named namespace's top-level call beside the declaration.
6315        Ok(if module.is_some() && callable_boundary {
6316            None
6317        } else {
6318            callable
6319        })
6320    } else {
6321        Ok(module)
6322    }
6323}
6324
6325/// Entity role admitted by a canonical candidate lookup.
6326#[derive(Clone, Copy)]
6327enum ResolutionCandidateKind {
6328    /// Imports, packages and document lookups retain their existing entity roles.
6329    Any,
6330    /// Calls require declaration entities; file export markers are lookup evidence.
6331    Declaration,
6332}
6333
6334/// Merge sorted candidate streams without materializing their full union.
6335fn registry_resolution_matches<'a>(
6336    dependency_keys: &[CanonicalResolutionKey],
6337    kind: ResolutionCandidateKind,
6338    candidates: &'a ProjectResolutionRegistry,
6339    staged_entities: &'a BTreeMap<String, GraphEntity>,
6340    control: &IndexWorkControl,
6341) -> Result<ResolutionMatches<'a>, CliError> {
6342    enforce_resolution_match_budget(dependency_keys.len().saturating_mul(2))?;
6343    let mut streams = dependency_keys
6344        .iter()
6345        .filter_map(|key| candidates.candidate_digests_by_key.get(key))
6346        .map(BTreeSet::iter)
6347        .collect::<Vec<_>>();
6348    let mut frontier = BinaryHeap::new();
6349    for (stream_index, stream) in streams.iter_mut().enumerate() {
6350        if let Some(digest) = stream.next() {
6351            frontier.push(Reverse((digest.as_str(), stream_index)));
6352        }
6353    }
6354
6355    let mut first = None;
6356    let mut last_digest = None;
6357    let mut count = 0_u32;
6358    let mut visited = 0_usize;
6359    let mut forwarding_marker = false;
6360    while let Some(Reverse((digest, stream_index))) = frontier.pop() {
6361        check_graph_work(control, visited)?;
6362        visited = visited.saturating_add(1);
6363        if last_digest != Some(digest) {
6364            let entity = staged_entities
6365                .get(digest)
6366                .or_else(|| candidates.supplemental_entities_by_digest.get(digest))
6367                .ok_or_else(|| {
6368                    CliError::InvalidInput(
6369                        "resolution candidate entity was not registered".to_string(),
6370                    )
6371                })?;
6372            forwarding_marker |= matches!(entity.selector(), EntitySelector::File { .. });
6373            if matches!(kind, ResolutionCandidateKind::Any)
6374                || matches!(entity.selector(), EntitySelector::Symbol { .. })
6375            {
6376                first.get_or_insert(entity);
6377                count = count.checked_add(1).ok_or_else(|| {
6378                    IndexWorkFailure::resource_limit(
6379                        IndexWorkStage::SymbolParsing,
6380                        IndexWorkResource::RelationRows,
6381                        u64::from(u32::MAX),
6382                        u64::from(u32::MAX) + 1,
6383                    )
6384                })?;
6385            }
6386            last_digest = Some(digest);
6387        }
6388        if let Some(next) = streams[stream_index].next() {
6389            frontier.push(Reverse((next.as_str(), stream_index)));
6390        }
6391    }
6392    if forwarding_marker && matches!(kind, ResolutionCandidateKind::Declaration) {
6393        // A competing forwarding slot cannot be silently dropped to make an
6394        // unrelated declaration appear unique. Its typed evidence must be followed.
6395        return Ok(ResolutionMatches {
6396            first: None,
6397            count: 0,
6398        });
6399    }
6400    Ok(ResolutionMatches { first, count })
6401}
6402
6403/// Convert one exact distinct target count without truncation.
6404fn distinct_resolution_count(count: usize) -> Result<u32, CliError> {
6405    u32::try_from(count).map_err(|_conversion_error| {
6406        IndexWorkFailure::resource_limit(
6407            IndexWorkStage::SymbolParsing,
6408            IndexWorkResource::RelationRows,
6409            u64::from(u32::MAX),
6410            u64::try_from(count).unwrap_or(u64::MAX),
6411        )
6412        .into()
6413    })
6414}
6415
6416/// Bound temporary maps and heap storage used while selecting relation targets.
6417fn enforce_resolution_match_budget(rows: usize) -> Result<(), CliError> {
6418    enforce_resolution_registry_budget(
6419        STAGED_GRAPH_ROW_BYTES.saturating_mul(u64::try_from(rows).unwrap_or(u64::MAX)),
6420    )
6421}
6422
6423/// Return one explicit external identity without guessing ordinary missing packages.
6424fn explicit_external_selector(
6425    graph: &SymbolGraph,
6426    relation: &projectatlas_core::symbols::SymbolRelation,
6427) -> Result<Option<ExternalSelector>, CliError> {
6428    let semantic_provider = graph
6429        .language
6430        .as_deref()
6431        .and_then(language_capability)
6432        .and_then(|capability| capability.effective_semantic_provider());
6433    let classified = match (semantic_provider, relation.kind) {
6434        (Some(SemanticProviderOwner::Cargo), RelationKind::DependsOn) => {
6435            external_reference_identity(&relation.target_name)
6436                .map(|identity| (CARGO_PACKAGE_MANAGER, identity))
6437        }
6438        (Some(SemanticProviderOwner::Rust), RelationKind::Imports | RelationKind::Calls) => {
6439            rust_toolchain_identity(relation).map(|identity| (RUST_TOOLCHAIN_SYSTEM, identity))
6440        }
6441        (Some(SemanticProviderOwner::EcmaScript), RelationKind::Imports) => {
6442            node_builtin_identity(relation).map(|identity| (NODE_SYSTEM, identity))
6443        }
6444        (
6445            Some(
6446                SemanticProviderOwner::Python
6447                | SemanticProviderOwner::Unavailable
6448                | SemanticProviderOwner::Cargo
6449                | SemanticProviderOwner::EcmaScript,
6450            )
6451            | None,
6452            _,
6453        )
6454        | (Some(SemanticProviderOwner::Rust), RelationKind::Contains | RelationKind::DependsOn) => {
6455            None
6456        }
6457    };
6458    classified
6459        .map(|(system, identity)| {
6460            Ok(ExternalSelector {
6461                system: GraphIdentityText::new(system).map_err(invalid_graph_contract)?,
6462                identity: GraphIdentityText::new(identity).map_err(invalid_graph_contract)?,
6463            })
6464        })
6465        .transpose()
6466}
6467
6468/// Respect native binding ownership before considering an external spelling.
6469fn source_external_selector(
6470    graph: &SymbolGraph,
6471    relation: &SymbolRelation,
6472    relation_index: Option<usize>,
6473    index: &GraphSymbolIndex<'_>,
6474) -> Result<Option<ExternalSelector>, CliError> {
6475    let rust = graph
6476        .language
6477        .as_deref()
6478        .and_then(language_capability)
6479        .and_then(|capability| capability.effective_semantic_provider())
6480        == Some(SemanticProviderOwner::Rust);
6481    if rust {
6482        let reference = match relation.kind {
6483            RelationKind::Calls if index.source_calls.is_some() => {
6484                match relation_index
6485                    .and_then(|ordinal| index.source_calls.as_ref()?.get(&ordinal).copied())
6486                    .map(|call| &call.binding)
6487                {
6488                    Some(SourceCallBinding::Qualified(reference)) => Some(reference),
6489                    Some(SourceCallBinding::Imported { rust_toolchain, .. }) => {
6490                        rust_toolchain.as_deref()
6491                    }
6492                    _ => None,
6493                }
6494            }
6495            RelationKind::Imports if index.rust_toolchain_imports.is_some() => relation_index
6496                .and_then(|ordinal| index.rust_toolchain_imports?.get(&ordinal))
6497                .and_then(Option::as_ref),
6498            _ => return explicit_external_selector(graph, relation),
6499        };
6500        return reference
6501            .and_then(rust_import_identity)
6502            .map(|identity| {
6503                Ok(ExternalSelector {
6504                    system: GraphIdentityText::new(RUST_TOOLCHAIN_SYSTEM)
6505                        .map_err(invalid_graph_contract)?,
6506                    identity: GraphIdentityText::new(identity).map_err(invalid_graph_contract)?,
6507                })
6508            })
6509            .transpose();
6510    }
6511    explicit_external_selector(graph, relation)
6512}
6513
6514/// Normalize one explicit non-empty external reference.
6515fn external_reference_identity(reference: &str) -> Option<String> {
6516    let reference = reference.trim();
6517    (!reference.is_empty()).then(|| reference.to_string())
6518}
6519
6520/// Recognize only explicitly qualified Rust toolchain roots.
6521fn rust_toolchain_identity(
6522    relation: &projectatlas_core::symbols::SymbolRelation,
6523) -> Option<String> {
6524    if relation.kind == RelationKind::Imports {
6525        let mut references = parse_import_references(&relation.target_name).into_iter();
6526        let first = rust_import_identity(&references.next()?)?;
6527        return references.try_fold(first, |common, reference| {
6528            let identity = rust_import_identity(&reference)?;
6529            common_rust_path(&common, &identity)
6530        });
6531    }
6532    let target = relation_reference(relation);
6533    rust_toolchain_root(&target)?;
6534    Some(target)
6535}
6536
6537/// Return one explicitly toolchain-owned Rust import identity.
6538fn rust_import_identity(reference: &projectatlas_symbols::ImportReference) -> Option<String> {
6539    let module = reference.module();
6540    rust_toolchain_root(module)?;
6541    Some(reference.imported().map_or_else(
6542        || module.to_string(),
6543        |imported| format!("{module}::{imported}"),
6544    ))
6545}
6546
6547/// Return the non-empty common Rust module path shared by two imports.
6548fn common_rust_path(left: &str, right: &str) -> Option<String> {
6549    let components = left
6550        .split("::")
6551        .zip(right.split("::"))
6552        .take_while(|(left, right)| left == right)
6553        .map(|(component, _right)| component)
6554        .collect::<Vec<_>>();
6555    (!components.is_empty()).then(|| components.join("::"))
6556}
6557
6558/// Return the accepted toolchain root of one Rust module path.
6559fn rust_toolchain_root(path: &str) -> Option<&str> {
6560    let root = path.trim().split("::").next()?;
6561    matches!(root, "std" | "core" | "alloc").then_some(root)
6562}
6563
6564/// Recognize only the explicit `node:` built-in module scheme.
6565fn node_builtin_identity(relation: &projectatlas_core::symbols::SymbolRelation) -> Option<String> {
6566    parse_import_references(&relation.target_name)
6567        .into_iter()
6568        .find_map(|reference| {
6569            reference
6570                .module()
6571                .strip_prefix("node:")
6572                .filter(|identity| !identity.is_empty())
6573                .map(ToString::to_string)
6574        })
6575        .or_else(|| {
6576            quoted_ecmascript_module(&relation.target_name)
6577                .and_then(|module| module.strip_prefix("node:"))
6578                .filter(|identity| !identity.is_empty())
6579                .map(ToString::to_string)
6580        })
6581}
6582
6583/// Read the explicit quoted module from one parser-validated ECMAScript import.
6584fn quoted_ecmascript_module(import: &str) -> Option<&str> {
6585    let import = import.trim();
6586    if !import.starts_with("import ") {
6587        return None;
6588    }
6589    let (quote_index, quote) = import
6590        .char_indices()
6591        .find(|(_index, character)| matches!(character, '\'' | '"'))?;
6592    let remainder = &import[quote_index + quote.len_utf8()..];
6593    let end = remainder.find(quote)?;
6594    Some(&remainder[..end])
6595}
6596
6597/// Normalize an empty parser reference into one stable diagnostic identity.
6598fn nonempty_reference(value: &str) -> String {
6599    let value = value.trim();
6600    if value.is_empty() {
6601        UNKNOWN_REFERENCE.to_string()
6602    } else {
6603        value.to_string()
6604    }
6605}
6606
6607/// Keep call arguments and literal values out of persisted relation diagnostics.
6608fn relation_reference(relation: &SymbolRelation) -> String {
6609    let mut value = if relation.kind == RelationKind::Calls {
6610        relation
6611            .target_name
6612            .split_once('(')
6613            .map_or(relation.target_name.as_str(), |(target, _arguments)| target)
6614    } else {
6615        &relation.target_name
6616    };
6617    if relation.kind == RelationKind::Calls && value.contains(['\'', '"']) {
6618        value = call_leaf(value);
6619    }
6620    nonempty_reference(value)
6621}
6622
6623/// Keep document coverage metadata out of the user-facing entity graph.
6624pub(super) fn is_document_coverage_marker(graph: &SymbolGraph, symbol: &CodeSymbol) -> bool {
6625    matches!(graph.language.as_deref(), Some("docx" | "pdf"))
6626        && symbol.name == DOCUMENT_COVERAGE_SYMBOL
6627        && symbol.kind == SymbolKind::Unknown
6628}
6629
6630/// Count only symbols exposed by navigation and charged to publication budgets.
6631pub(super) fn navigable_symbol_count(graph: &SymbolGraph) -> usize {
6632    graph
6633        .symbols
6634        .iter()
6635        .filter(|symbol| !is_document_coverage_marker(graph, symbol))
6636        .count()
6637}
6638
6639/// Project parser trust into one path-scoped coverage record.
6640fn coverage_for_graph(
6641    graph: &SymbolGraph,
6642    generation: IndexGeneration,
6643    identity_admission: &GraphIdentityAdmission,
6644    derived_identity_admission: &GraphIdentityAdmission,
6645) -> Result<CoverageRecord, CliError> {
6646    let scope = CoverageScope::Path {
6647        path: RepositoryNodePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
6648    };
6649    let identity_omitted =
6650        identity_admission.rejected_facts_for_graph(&graph.path, derived_identity_admission)?;
6651    let parser_omitted = identity_admission.parser_rejection_for_graph(&graph.path);
6652    let document_gaps = graph
6653        .symbols
6654        .iter()
6655        .find(|symbol| is_document_coverage_marker(graph, symbol))
6656        .map(|symbol| symbol.signature.as_str());
6657    let omitted = identity_omitted
6658        .checked_add(parser_omitted)
6659        .and_then(|omitted| omitted.checked_add(u64::from(document_gaps.is_some())))
6660        .ok_or_else(|| CliError::InvalidInput("identity rejection count overflowed".to_string()))?;
6661    let identity_details_dropped = identity_admission.rejection_details_dropped_for(&graph.path)
6662        || derived_identity_admission.rejection_details_dropped_for(&graph.path);
6663    // Graph-scoped `rows` is the existing persisted coverage slot for the
6664    // publication-wide typed identity-detail ceiling. It is set only from
6665    // the admission fact that a distinct detail was actually evicted.
6666    // Graph limits group parser work with intermediate bytes; the reason retains its exact subtype.
6667    let reached_limit = if identity_details_dropped {
6668        Some(GraphLimitKind::Rows)
6669    } else if document_gaps.is_some_and(|gaps| gaps.contains("resource_limit:output_bytes")) {
6670        Some(GraphLimitKind::OutputBytes)
6671    } else if document_gaps.is_some_and(|gaps| gaps.contains("resource_limit:fact_count")) {
6672        Some(GraphLimitKind::Rows)
6673    } else if document_gaps.is_some_and(|gaps| {
6674        gaps.contains("resource_limit:memory_bytes")
6675            || gaps.contains("resource_limit:parser_work_bytes")
6676            || gaps.contains("resource_limit:expanded_bytes")
6677    }) {
6678        Some(GraphLimitKind::IntermediateBytes)
6679    } else {
6680        None
6681    };
6682    let covered = if identity_omitted > 0 || document_gaps.is_some() {
6683        u64::try_from(
6684            graph
6685                .symbols
6686                .len()
6687                .saturating_sub(usize::from(document_gaps.is_some()))
6688                .saturating_add(graph.relations.len()),
6689        )
6690        .unwrap_or(u64::MAX)
6691    } else {
6692        u64::try_from(graph.relations.len()).unwrap_or(u64::MAX)
6693    };
6694    if omitted > 0 {
6695        let state = if covered > 0 {
6696            CoverageState::Partial
6697        } else {
6698            CoverageState::Failed
6699        };
6700        let reason = document_gaps.map_or_else(
6701            || PARTIAL_COVERAGE_REASON.to_owned(),
6702            |gaps| format!("document_text_incomplete:{gaps}"),
6703        );
6704        return CoverageRecord::new(
6705            scope,
6706            None,
6707            state,
6708            covered,
6709            omitted,
6710            generation,
6711            Some(GraphIdentityText::new(reason).map_err(invalid_graph_contract)?),
6712            reached_limit,
6713        )
6714        .map_err(invalid_graph_contract);
6715    }
6716    match graph.parser {
6717        ParserKind::TreeSitter | ParserKind::Manifest => CoverageRecord::new(
6718            scope,
6719            None,
6720            CoverageState::Complete,
6721            covered,
6722            0,
6723            generation,
6724            None,
6725            None,
6726        )
6727        .map_err(invalid_graph_contract),
6728        ParserKind::Structural | ParserKind::Fallback if covered > 0 => CoverageRecord::new(
6729            scope,
6730            None,
6731            CoverageState::Partial,
6732            covered,
6733            1,
6734            generation,
6735            Some(GraphIdentityText::new(PARTIAL_COVERAGE_REASON).map_err(invalid_graph_contract)?),
6736            None,
6737        )
6738        .map_err(invalid_graph_contract),
6739        ParserKind::Structural | ParserKind::Fallback => CoverageRecord::new(
6740            scope,
6741            None,
6742            CoverageState::Failed,
6743            0,
6744            1,
6745            generation,
6746            Some(GraphIdentityText::new(PARTIAL_COVERAGE_REASON).map_err(invalid_graph_contract)?),
6747            None,
6748        )
6749        .map_err(invalid_graph_contract),
6750    }
6751}
6752
6753/// Map parser strength to relation confidence.
6754fn relation_confidence(parser: ParserKind) -> ConfidenceClass {
6755    match parser {
6756        ParserKind::TreeSitter | ParserKind::Manifest => ConfidenceClass::Exact,
6757        ParserKind::Structural => ConfidenceClass::Medium,
6758        ParserKind::Fallback => ConfidenceClass::Low,
6759    }
6760}
6761
6762/// Map parser strength to relation completeness.
6763fn relation_completeness(parser: ParserKind) -> Completeness {
6764    match parser {
6765        ParserKind::TreeSitter | ParserKind::Manifest => Completeness::Complete,
6766        ParserKind::Structural | ParserKind::Fallback => Completeness::Partial,
6767    }
6768}
6769
6770/// Reserve retained hydration scratch before extending any all-chunk collection.
6771/// A single database batch remains bounded by its existing row/decoded envelope.
6772fn reserve_identity_hydration_scratch(
6773    report: &mut GraphIdentityAdmission,
6774    scratch: &mut u64,
6775    limit: u64,
6776    additional: u64,
6777) -> Result<(), CliError> {
6778    let next = scratch.saturating_add(additional);
6779    enforce_resolution_registry_budget_with_limit(
6780        next.saturating_add(report.observed_fact_bytes),
6781        limit,
6782    )?;
6783    *scratch = next;
6784    report.retained_limit = Some(limit.saturating_sub(next));
6785    Ok(())
6786}
6787
6788/// Carry current-generation rejection state for graphs safely reused by a stage.
6789///
6790/// A publication replaces graph and rejection rows for its selected paths.
6791/// Reused sanitized graphs no longer contain rejected parser values, so the
6792/// persisted typed facts and total path coverage are hydrated before normal
6793/// admission re-derives current key, Markdown, and derived facts. The detail
6794/// count is retained separately to reconcile those two observations exactly.
6795fn hydrate_reused_identity_admission(
6796    store: &AtlasStore,
6797    project: ProjectInstanceId,
6798    reused_paths: &BTreeSet<String>,
6799    graphs: &[impl Borrow<SymbolGraph>],
6800    report: &mut GraphIdentityAdmission,
6801    control: &IndexWorkControl,
6802) -> Result<(), CliError> {
6803    let previous_limit = report.retained_limit;
6804    let result = (|| -> Result<(), CliError> {
6805        if reused_paths.is_empty() {
6806            return Ok(());
6807        }
6808        let phase_limit = report
6809            .retained_limit
6810            .unwrap_or(super::MAX_PUBLICATION_STAGING_BYTES);
6811        let mut scratch_bytes = 0_u64;
6812        reserve_identity_hydration_scratch(
6813            report,
6814            &mut scratch_bytes,
6815            phase_limit,
6816            reused_paths.iter().fold(0_u64, |bytes, path| {
6817                bytes.saturating_add(128).saturating_add(path.len() as u64)
6818            }),
6819        )?;
6820        let reused_node_paths = reused_paths
6821            .iter()
6822            .map(|path| RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract))
6823            .collect::<Result<Vec<_>, _>>()?;
6824        let mut persisted_rejections = Vec::new();
6825        let mut persisted_coverage = BTreeMap::new();
6826        let mut persisted_rejection_details_dropped_by_path = BTreeSet::new();
6827        for paths in reused_node_paths.chunks(PERSISTED_GRAPH_PATHS_PER_CHUNK) {
6828            control.check(IndexWorkStage::SymbolParsing)?;
6829            let rejection_rows = match store.repository_graph_identity_rejections(
6830                project,
6831                paths,
6832                GraphLimits::MAX_ROWS,
6833                Some(control),
6834            ) {
6835                Ok(rows) => rows,
6836                Err(projectatlas_db::DbError::GraphRowShape { table, reason })
6837                    if table == "project_identity"
6838                        && reason
6839                            == "typed graph generation does not match complete publication" =>
6840                {
6841                    // A full stage can repair an older incomplete publication, but
6842                    // it cannot safely carry diagnostic rows from that state.
6843                    return Ok(());
6844                }
6845                Err(error) => return Err(error.into()),
6846            };
6847            let row_bytes = rejection_rows.iter().fold(
6848                (rejection_rows.capacity() as u64)
6849                    .saturating_mul((2 * std::mem::size_of::<GraphIdentityRejection>()) as u64),
6850                |bytes, row| bytes.saturating_add(row.path.as_str().len() as u64),
6851            );
6852            reserve_identity_hydration_scratch(report, &mut scratch_bytes, phase_limit, row_bytes)?;
6853            persisted_rejections.extend(rejection_rows);
6854            let coverage = match store.repository_graph_path_coverage(project, paths, Some(control))
6855            {
6856                Ok(coverage) => coverage,
6857                Err(projectatlas_db::DbError::GraphRowShape { table, reason })
6858                    if table == "project_identity"
6859                        && reason
6860                            == "typed graph generation does not match complete publication" =>
6861                {
6862                    return Ok(());
6863                }
6864                Err(error) => return Err(error.into()),
6865            };
6866            if coverage.truncated {
6867                return Err(CliError::InvalidInput(
6868                    "reused graph coverage exceeded the publication row ceiling".to_string(),
6869                ));
6870            }
6871            for row in coverage.rows {
6872                let CoverageScope::Path { path } = row.scope() else {
6873                    continue;
6874                };
6875                if row.relation().is_none() && row.omitted() > 0 {
6876                    control.check(IndexWorkStage::SymbolParsing)?;
6877                    reserve_identity_hydration_scratch(
6878                        report,
6879                        &mut scratch_bytes,
6880                        phase_limit,
6881                        STAGED_GRAPH_ROW_BYTES.saturating_add(path.as_str().len() as u64),
6882                    )?;
6883                    persisted_coverage.insert(path.as_str().to_owned(), row.omitted());
6884                }
6885                if row.relation().is_none() && row.reached_limit() == Some(GraphLimitKind::Rows) {
6886                    control.check(IndexWorkStage::SymbolParsing)?;
6887                    reserve_identity_hydration_scratch(
6888                        report,
6889                        &mut scratch_bytes,
6890                        phase_limit,
6891                        STAGED_GRAPH_ROW_BYTES.saturating_add(path.as_str().len() as u64),
6892                    )?;
6893                    persisted_rejection_details_dropped_by_path.insert(path.as_str().to_owned());
6894                }
6895            }
6896        }
6897        let mut persisted_fact_counts = BTreeMap::<String, BTreeSet<IdentityFactKey>>::new();
6898        for path in persisted_rejection_details_dropped_by_path {
6899            control.check(IndexWorkStage::SymbolParsing)?;
6900            if report
6901                .rejection_details_dropped_by_path
6902                .insert(path.clone())
6903            {
6904                report.reserve_reused_path_bytes(
6905                    &path,
6906                    false,
6907                    control,
6908                    MAX_IN_MEMORY_GRAPH_WORK_BYTES,
6909                )?;
6910            }
6911        }
6912        for rejection in persisted_rejections {
6913            control.check(IndexWorkStage::SymbolParsing)?;
6914            let namespace = rejection.fact_index & !((1_u64 << 56) - 1);
6915            let path = rejection.path.as_str().to_owned();
6916            reserve_identity_hydration_scratch(
6917                report,
6918                &mut scratch_bytes,
6919                phase_limit,
6920                STAGED_GRAPH_ROW_BYTES
6921                    .saturating_mul(2)
6922                    .saturating_add(path.len() as u64),
6923            )?;
6924            persisted_fact_counts
6925                .entry(path.clone())
6926                .or_default()
6927                .insert(IdentityFactKey {
6928                    span: IdentitySpan {
6929                        start_line: rejection.span.start_line() as usize,
6930                        start_column: rejection.span.start_column() as usize,
6931                        end_line: rejection.span.end_line() as usize,
6932                        end_column: rejection.span.end_column() as usize,
6933                    },
6934                    parser: parser_fact_kind(rejection.parser),
6935                    owner: u8::from(rejection.field == GraphIdentityField::ResolutionKey),
6936                    fact_index: rejection.fact_index,
6937                });
6938            // Derived relations have their own admission report during projection;
6939            // hydrating them into the parser report would double-count coverage.
6940            if namespace == DERIVED_RELATION_FACT_INDEX_NAMESPACE {
6941                continue;
6942            }
6943            control.check(IndexWorkStage::SymbolParsing)?;
6944            let rejection_key_bytes = extend_bounded_identity_rejections_with_drop_paths_limit(
6945                &mut report.rejections,
6946                &mut report.rejection_keys,
6947                std::iter::once(rejection),
6948                &mut report.rejection_details_dropped_by_path,
6949                report
6950                    .retained_limit
6951                    .unwrap_or(MAX_IN_MEMORY_GRAPH_WORK_BYTES)
6952                    .min(MAX_IN_MEMORY_GRAPH_WORK_BYTES)
6953                    .saturating_sub(report.observed_fact_bytes),
6954            )?;
6955            report.reserve_identity_bytes(
6956                rejection_key_bytes,
6957                control,
6958                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
6959            )?;
6960        }
6961        for (path, facts) in persisted_fact_counts {
6962            control.check(IndexWorkStage::SymbolParsing)?;
6963            if !report.reused_rejection_facts.contains_key(&path) {
6964                report.reserve_identity_bytes(
6965                    identity_fact_set_retained_bytes(&path, &facts)?,
6966                    control,
6967                    MAX_IN_MEMORY_GRAPH_WORK_BYTES,
6968                )?;
6969                report.reused_rejection_facts.insert(path.clone(), facts);
6970            }
6971            let detail_count = u64::try_from(
6972                report
6973                    .reused_rejection_facts
6974                    .get(&path)
6975                    .map_or(0, BTreeSet::len),
6976            )
6977            .map_err(|error| {
6978                CliError::InvalidInput(format!(
6979                    "identity rejection detail count overflowed: {error}"
6980                ))
6981            })?;
6982            if !report.reused_rejection_detail_counts.contains_key(&path) {
6983                report.reserve_reused_path_bytes(
6984                    &path,
6985                    false,
6986                    control,
6987                    MAX_IN_MEMORY_GRAPH_WORK_BYTES,
6988                )?;
6989                report
6990                    .reused_rejection_detail_counts
6991                    .insert(path, detail_count);
6992            }
6993        }
6994        for (path, omitted) in persisted_coverage {
6995            // Persisted path coverage is authoritative for safely reused graphs,
6996            // including structural/fallback rows whose typed details were evicted
6997            // by the global rejection-detail ceiling.
6998            control.check(IndexWorkStage::SymbolParsing)?;
6999            if !report.reused_rejection_counts.contains_key(&path) {
7000                report.reserve_reused_path_bytes(
7001                    &path,
7002                    false,
7003                    control,
7004                    MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7005                )?;
7006                report.reused_rejection_counts.insert(path, omitted);
7007            }
7008        }
7009        reserve_identity_hydration_scratch(
7010            report,
7011            &mut scratch_bytes,
7012            phase_limit,
7013            (graphs.len() as u64).saturating_mul(STAGED_GRAPH_ROW_BYTES),
7014        )?;
7015        let graph_parsers = graphs
7016            .iter()
7017            .map(|graph| {
7018                let graph = Borrow::<SymbolGraph>::borrow(graph);
7019                (graph.path.as_str(), graph.parser)
7020            })
7021            .collect::<BTreeMap<_, _>>();
7022        for path in reused_paths {
7023            let Some(_omitted) = report.reused_rejection_counts.get(path).copied() else {
7024                continue;
7025            };
7026            let details = report
7027                .reused_rejection_detail_counts
7028                .get(path)
7029                .copied()
7030                .unwrap_or(0);
7031            let parser = graph_parsers.get(path.as_str()).copied();
7032            let baseline = parser.map_or(0, |parser| {
7033                u64::from(matches!(
7034                    parser,
7035                    ParserKind::Structural | ParserKind::Fallback
7036                ))
7037            });
7038            if details == 0 && baseline > 0 {
7039                // A structural/fallback path with no retained typed identity fact
7040                // still owns its coarse parser omission. Keep that provenance out
7041                // of identity reconciliation so unchanged graphs retain the same
7042                // covered count as a clean parse.
7043                if !report.reused_parser_rejection_counts.contains_key(path) {
7044                    report.reserve_reused_path_bytes(
7045                        path,
7046                        false,
7047                        control,
7048                        MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7049                    )?;
7050                    report
7051                        .reused_parser_rejection_counts
7052                        .insert(path.clone(), baseline);
7053                }
7054            }
7055            // Fallback rows intentionally carry one coarse parser omission and do
7056            // not re-derive identity details. Structural and exact parsers must
7057            // be reparsed when a capped persisted row set cannot identify every
7058            // old fact without guessing.
7059            if parser != Some(ParserKind::Fallback)
7060                && report.rejection_details_dropped_for(path)
7061                && !report.reused_rejection_details_incomplete.contains(path)
7062            {
7063                report.reserve_reused_path_bytes(
7064                    path,
7065                    false,
7066                    control,
7067                    MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7068                )?;
7069                report
7070                    .reused_rejection_details_incomplete
7071                    .insert(path.clone());
7072            }
7073        }
7074        let retained_bytes =
7075            checked_identity_admission_budget(report, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
7076        report.observed_fact_bytes = retained_bytes;
7077        report.retained_limit = Some(phase_limit);
7078        Ok(())
7079    })();
7080    report.retained_limit = previous_limit;
7081    result
7082}
7083
7084/// One admitted captured source, shared across repeated bounded projection requests.
7085struct CapturedSourceGraph {
7086    /// Actual parser ordering, independent of persisted row presentation order.
7087    graph: SymbolGraph,
7088    /// Source identity and ordinal-owned lexical evidence.
7089    semantics: SourceSemanticFacts,
7090    /// Identity rejection details produced by the same raw admission boundary.
7091    admission: GraphIdentityAdmission,
7092}
7093
7094/// Graph ownership retains its corresponding source proof through projection.
7095enum SourceGraph<'a> {
7096    /// Already-admitted fresh parser output, borrowed without another parse/copy.
7097    Staged {
7098        /// Raw graph in its actual parser ordering.
7099        graph: &'a SymbolGraph,
7100        /// Corresponding source proof when the native parser produced it.
7101        semantics: Option<&'a SourceSemanticFacts>,
7102    },
7103    /// Required unchanged source recovered once from verified captured bytes.
7104    Captured(Arc<CapturedSourceGraph>),
7105    /// A graph whose parser contract does not require lexical source evidence.
7106    Persisted(SymbolGraph),
7107}
7108
7109impl SourceGraph<'_> {
7110    /// Borrow the exact graph whose ordinals own this source proof.
7111    fn graph(&self) -> &SymbolGraph {
7112        match self {
7113            Self::Staged { graph, .. } => graph,
7114            Self::Captured(source) => &source.graph,
7115            Self::Persisted(graph) => graph,
7116        }
7117    }
7118
7119    /// Borrow source evidence without reconstructing it from stored display text.
7120    fn semantics(&self) -> Option<&SourceSemanticFacts> {
7121        match self {
7122            Self::Staged { semantics, .. } => *semantics,
7123            Self::Captured(source) => Some(&source.semantics),
7124            Self::Persisted(_) => None,
7125        }
7126    }
7127}
7128
7129impl Borrow<SymbolGraph> for SourceGraph<'_> {
7130    fn borrow(&self) -> &SymbolGraph {
7131        self.graph()
7132    }
7133}
7134
7135impl AsRef<SymbolGraph> for SourceGraph<'_> {
7136    fn as_ref(&self) -> &SymbolGraph {
7137        self.graph()
7138    }
7139}
7140
7141impl<'a> From<Cow<'a, SymbolGraph>> for SourceGraph<'a> {
7142    fn from(graph: Cow<'a, SymbolGraph>) -> Self {
7143        match graph {
7144            Cow::Borrowed(graph) => Self::Staged {
7145                graph,
7146                semantics: None,
7147            },
7148            Cow::Owned(graph) => Self::Persisted(graph),
7149        }
7150    }
7151}
7152
7153/// Additional source parses charged to the existing public work report.
7154#[derive(Clone, Copy, Debug, Default)]
7155struct SourceRecoveryWork {
7156    /// Considered sources whose raw graph had otherwise been reused.
7157    previously_unchanged: usize,
7158    /// Required closure/context sources outside the original direct candidate set.
7159    additional_candidates: usize,
7160}
7161
7162/// Per-operation captured source cache, limited to requested projection/context paths.
7163struct SourceBindingCache<'a> {
7164    /// Allowance after subtracting coexisting publication owners.
7165    retained_limit: u64,
7166    /// Exact selected checkout.
7167    root: &'a Path,
7168    /// Captured file inventory, used to reject changed or unindexed source.
7169    nodes: Arc<BTreeMap<&'a str, &'a Node>>,
7170    /// Direct paths for incremental work; absence means a full candidate set.
7171    direct_paths: Option<BTreeSet<&'a str>>,
7172    /// One immutable parse per required unchanged path.
7173    captured: BTreeMap<String, Arc<CapturedSourceGraph>>,
7174    /// Derived Cargo cohorts retain no second manifest graph or parser facts.
7175    cargo_cohorts: BTreeMap<String, Arc<CargoRootCohortIndex>>,
7176    /// Additional actual parser work, separate from reused fresh sidecars.
7177    work: SourceRecoveryWork,
7178    /// Bounded owned source graphs/evidence retained during projection.
7179    retained_bytes: u64,
7180}
7181
7182impl<'a> SourceBindingCache<'a> {
7183    /// Index captured metadata once without reading any source file.
7184    fn new(root: &'a Path, nodes: &'a [Node], direct_paths: Option<&BTreeSet<String>>) -> Self {
7185        Self {
7186            root,
7187            retained_limit: super::MAX_PUBLICATION_STAGING_BYTES,
7188            nodes: Arc::new(
7189                nodes
7190                    .iter()
7191                    .filter(|node| node.kind == NodeKind::File)
7192                    .map(|node| (node.path.as_str(), node))
7193                    .collect(),
7194            ),
7195            direct_paths: direct_paths.map(|direct| {
7196                nodes
7197                    .iter()
7198                    .filter(|node| direct.contains(&node.path))
7199                    .map(|node| node.path.as_str())
7200                    .collect()
7201            }),
7202            captured: BTreeMap::new(),
7203            cargo_cohorts: BTreeMap::new(),
7204            work: SourceRecoveryWork::default(),
7205            retained_bytes: (nodes.len() as u64).saturating_mul(STAGED_GRAPH_ROW_BYTES),
7206        }
7207    }
7208
7209    /// Update a phase allowance without discarding already retained cache owners.
7210    fn set_retained_limit(&mut self, limit: u64) -> Result<(), CliError> {
7211        enforce_resolution_registry_budget_with_limit(self.retained_bytes, limit)?;
7212        self.retained_limit = limit;
7213        Ok(())
7214    }
7215
7216    /// Recover only an explicitly requested source, preserving its original parser order.
7217    fn capture(
7218        &mut self,
7219        path: &str,
7220        control: &IndexWorkControl,
7221    ) -> Result<Arc<CapturedSourceGraph>, CliError> {
7222        if let Some(source) = self.captured.get(path) {
7223            return Ok(Arc::clone(source));
7224        }
7225        control.check(IndexWorkStage::SymbolParsing)?;
7226        let node = self
7227            .nodes
7228            .get(path)
7229            .ok_or_else(|| source_changed_during_derivation(self.root, path))?;
7230        let native_path = self.root.join(Path::new(path));
7231        let bytes = match read_source_bytes_controlled(
7232            &native_path,
7233            MAX_SYMBOL_FILE_BYTES,
7234            IndexWorkStage::SymbolParsing,
7235            control,
7236        ) {
7237            Ok(bytes) => bytes,
7238            Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
7239            Err(SourceReadFailure::LimitExceeded { .. }) => {
7240                return Err(source_changed_during_derivation(self.root, path));
7241            }
7242            Err(SourceReadFailure::Io(source)) => {
7243                return Err(CliError::Io {
7244                    path: native_path,
7245                    source,
7246                });
7247            }
7248        };
7249        if node
7250            .content_hash
7251            .as_deref()
7252            .is_none_or(|expected| blake3::hash(&bytes).to_hex().as_str() != expected)
7253        {
7254            return Err(source_changed_during_derivation(self.root, path));
7255        }
7256        let content = String::from_utf8(bytes)
7257            .map_err(|_source| source_changed_during_derivation(self.root, path))?;
7258        let (_, graph, mut semantics) = extract_symbol_graph_with_semantics_controlled(
7259            path,
7260            node.language.as_deref(),
7261            &content,
7262            control,
7263        )?
7264        .into_parts();
7265        let identity_limit = self
7266            .retained_limit
7267            .saturating_sub(self.retained_bytes)
7268            .saturating_sub(source_graph_retained_bytes(&graph))
7269            .saturating_sub(semantics.retained_bytes());
7270        let (graph, admission) = admit_symbol_graph_with_semantics_limit(
7271            Cow::Owned(graph),
7272            Some(&mut semantics),
7273            control,
7274            identity_limit,
7275        )?;
7276        let graph = graph.into_owned();
7277        let bytes = source_graph_retained_bytes(&graph)
7278            .saturating_add(semantics.retained_bytes())
7279            .saturating_add(admission.observed_fact_bytes)
7280            .saturating_add(STAGED_GRAPH_ROW_BYTES)
7281            .saturating_add(path.len() as u64);
7282        enforce_resolution_registry_budget_with_limit(
7283            self.retained_bytes.saturating_add(bytes),
7284            self.retained_limit,
7285        )?;
7286        let source = Arc::new(CapturedSourceGraph {
7287            graph,
7288            semantics,
7289            admission,
7290        });
7291        self.retained_bytes = self.retained_bytes.saturating_add(bytes);
7292        if self
7293            .direct_paths
7294            .as_ref()
7295            .is_none_or(|direct| direct.contains(path))
7296        {
7297            self.work.previously_unchanged += 1;
7298        } else {
7299            self.work.additional_candidates += 1;
7300        }
7301        self.captured.insert(path.to_string(), Arc::clone(&source));
7302        control.check(IndexWorkStage::SymbolParsing)?;
7303        Ok(source)
7304    }
7305}
7306
7307/// Account owned graph capacities without serializing or copying parser output.
7308fn source_graph_retained_bytes(graph: &SymbolGraph) -> u64 {
7309    let mut bytes = (std::mem::size_of::<SymbolGraph>() as u64)
7310        .saturating_add(graph.path.capacity() as u64)
7311        .saturating_add(graph.language.as_ref().map_or(0, String::capacity) as u64)
7312        .saturating_add(
7313            (graph.symbols.capacity() as u64)
7314                .saturating_mul(std::mem::size_of::<CodeSymbol>() as u64),
7315        )
7316        .saturating_add(
7317            (graph.relations.capacity() as u64)
7318                .saturating_mul(std::mem::size_of::<SymbolRelation>() as u64),
7319        );
7320    for symbol in &graph.symbols {
7321        for text in [&symbol.path, &symbol.name, &symbol.signature] {
7322            bytes = bytes.saturating_add(text.capacity() as u64);
7323        }
7324        for text in [
7325            &symbol.language,
7326            &symbol.documentation,
7327            &symbol.parent,
7328            &symbol.detail,
7329        ]
7330        .into_iter()
7331        .flatten()
7332        {
7333            bytes = bytes.saturating_add(text.capacity() as u64);
7334        }
7335    }
7336    for relation in &graph.relations {
7337        for text in [
7338            &relation.path,
7339            &relation.source_name,
7340            &relation.target_name,
7341            &relation.context,
7342        ] {
7343            bytes = bytes.saturating_add(text.capacity() as u64);
7344        }
7345    }
7346    bytes
7347}
7348
7349/// Recover source only when the current projection consumes binding evidence.
7350fn graph_requires_source_bindings(graph: &SymbolGraph) -> bool {
7351    if graph
7352        .relations
7353        .iter()
7354        .any(|relation| relation.kind == RelationKind::Calls)
7355    {
7356        return true;
7357    }
7358    let provider = graph
7359        .language
7360        .as_deref()
7361        .and_then(language_capability)
7362        .and_then(|capability| capability.effective_semantic_provider());
7363    let embedded = graph
7364        .language
7365        .as_deref()
7366        .and_then(language_capability)
7367        .is_some_and(|capability| capability.embedded_language.is_some());
7368    match provider {
7369        Some(SemanticProviderOwner::Rust) => graph
7370            .relations
7371            .iter()
7372            .any(|relation| relation.kind == RelationKind::Imports),
7373        Some(SemanticProviderOwner::Python) => {
7374            graph
7375                .relations
7376                .iter()
7377                .any(|relation| relation.kind == RelationKind::Imports)
7378                || graph.symbols.iter().any(|symbol| {
7379                    symbol.parent.is_none()
7380                        && !symbol.name.starts_with('_')
7381                        && !matches!(
7382                            symbol.kind,
7383                            SymbolKind::Import
7384                                | SymbolKind::Dependency
7385                                | SymbolKind::Package
7386                                | SymbolKind::Workspace
7387                                | SymbolKind::Heading
7388                        )
7389                })
7390        }
7391        Some(SemanticProviderOwner::EcmaScript) => {
7392            graph
7393                .relations
7394                .iter()
7395                .any(|relation| relation.kind == RelationKind::Imports)
7396                || graph.symbols.iter().any(|symbol| {
7397                    symbol.parent.is_none()
7398                        && (symbol.exported || embedded)
7399                        && !matches!(
7400                            symbol.kind,
7401                            SymbolKind::Import
7402                                | SymbolKind::Dependency
7403                                | SymbolKind::Package
7404                                | SymbolKind::Workspace
7405                                | SymbolKind::Heading
7406                        )
7407                })
7408        }
7409        Some(SemanticProviderOwner::Cargo | SemanticProviderOwner::Unavailable) | None => false,
7410    }
7411}
7412
7413/// Overlay staged output and recover required unchanged source before ordinal use.
7414fn complete_symbol_graphs<'a>(
7415    store: &AtlasStore,
7416    paths: &BTreeSet<String>,
7417    symbols: &'a SymbolBuildStage,
7418    sources: &mut SourceBindingCache<'_>,
7419    control: &IndexWorkControl,
7420) -> Result<Vec<SourceGraph<'a>>, CliError> {
7421    let paths = paths.iter().cloned().collect::<Vec<_>>();
7422    let mut graphs = BTreeMap::new();
7423    for chunk in paths.chunks(PERSISTED_GRAPH_PATHS_PER_CHUNK) {
7424        control.check(IndexWorkStage::SymbolParsing)?;
7425        for graph in store.load_symbol_graphs_for_paths(chunk)? {
7426            graphs.insert(graph.path.clone(), SourceGraph::Persisted(graph));
7427        }
7428    }
7429    for (index, change) in symbols.changes.iter().enumerate() {
7430        check_graph_work(control, index)?;
7431        match change {
7432            SymbolProjectionChange::Parsed(parsed) if paths.binary_search(&parsed.path).is_ok() => {
7433                graphs.insert(
7434                    parsed.path.clone(),
7435                    SourceGraph::Staged {
7436                        graph: &parsed.graph,
7437                        semantics: parsed.source_semantics.as_deref(),
7438                    },
7439                );
7440            }
7441            SymbolProjectionChange::Clear { path, .. } if paths.binary_search(path).is_ok() => {
7442                graphs.remove(path);
7443            }
7444            SymbolProjectionChange::Parsed(_) | SymbolProjectionChange::Clear { .. } => {}
7445        }
7446    }
7447    graphs.retain(|path, _graph| paths.binary_search(path).is_ok());
7448    for (path, graph) in &mut graphs {
7449        control.check(IndexWorkStage::SymbolParsing)?;
7450        if matches!(graph, SourceGraph::Persisted(_))
7451            && graph_requires_source_bindings(graph.graph())
7452        {
7453            *graph = SourceGraph::Captured(sources.capture(path, control)?);
7454        }
7455    }
7456    Ok(graphs.into_values().collect())
7457}
7458
7459/// Use the ordinary identity ceiling for isolated graph-admission fixtures.
7460#[cfg(test)]
7461fn admit_symbol_graphs<'a>(
7462    graphs: Vec<impl Into<SourceGraph<'a>>>,
7463    control: &IndexWorkControl,
7464) -> Result<(Vec<SourceGraph<'a>>, GraphIdentityAdmission), CliError> {
7465    admit_symbol_graphs_with_limit(graphs, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)
7466}
7467
7468/// Admit parser graphs at one shared boundary before any strict graph object is built.
7469fn admit_symbol_graphs_with_limit<'a>(
7470    graphs: Vec<impl Into<SourceGraph<'a>>>,
7471    control: &IndexWorkControl,
7472    limit: u64,
7473) -> Result<(Vec<SourceGraph<'a>>, GraphIdentityAdmission), CliError> {
7474    let mut admitted = Vec::with_capacity(graphs.len());
7475    let mut report = GraphIdentityAdmission {
7476        retained_limit: Some(limit),
7477        ..GraphIdentityAdmission::default()
7478    };
7479    for (index, graph) in graphs.into_iter().enumerate() {
7480        check_graph_work(control, index)?;
7481        let (graph, graph_report) = match graph.into() {
7482            SourceGraph::Captured(source) => {
7483                enforce_resolution_registry_budget_with_limit(
7484                    report
7485                        .observed_fact_bytes
7486                        .saturating_add(source.admission.observed_fact_bytes),
7487                    limit,
7488                )?;
7489                let report = source.admission.clone();
7490                (SourceGraph::Captured(source), report)
7491            }
7492            SourceGraph::Staged {
7493                graph,
7494                semantics: Some(semantics),
7495            } => {
7496                if !semantics.matches_graph(graph) {
7497                    return Err(CliError::InvalidInput(
7498                        "fresh source binding evidence does not match its graph".to_string(),
7499                    ));
7500                }
7501                (
7502                    SourceGraph::Staged {
7503                        graph,
7504                        semantics: Some(semantics),
7505                    },
7506                    GraphIdentityAdmission::default(),
7507                )
7508            }
7509            SourceGraph::Staged {
7510                graph,
7511                semantics: None,
7512            } => {
7513                let (graph, report) = admit_symbol_graph_with_semantics_limit(
7514                    Cow::Borrowed(graph),
7515                    None,
7516                    control,
7517                    limit.saturating_sub(report.observed_fact_bytes),
7518                )?;
7519                (graph.into(), report)
7520            }
7521            SourceGraph::Persisted(graph) => {
7522                let (graph, report) = admit_symbol_graph_with_semantics_limit(
7523                    Cow::Owned(graph),
7524                    None,
7525                    control,
7526                    limit.saturating_sub(report.observed_fact_bytes),
7527                )?;
7528                (graph.into(), report)
7529            }
7530        };
7531        report.merge(graph_report, control)?;
7532        admitted.push(graph);
7533    }
7534    Ok((admitted, report))
7535}
7536
7537/// Retain valid parser facts while recording every invalid identity field.
7538#[cfg(test)]
7539fn admit_symbol_graph<'a>(
7540    graph: Cow<'a, SymbolGraph>,
7541    control: &IndexWorkControl,
7542) -> Result<(Cow<'a, SymbolGraph>, GraphIdentityAdmission), CliError> {
7543    admit_symbol_graph_with_semantics(graph, None, control)
7544}
7545
7546/// Preserve the ordinary identity sublimit for parser-stage callers.
7547#[cfg(test)]
7548fn admit_symbol_graph_with_semantics<'a>(
7549    graph: Cow<'a, SymbolGraph>,
7550    semantics: Option<&mut SourceSemanticFacts>,
7551    control: &IndexWorkControl,
7552) -> Result<(Cow<'a, SymbolGraph>, GraphIdentityAdmission), CliError> {
7553    admit_symbol_graph_with_semantics_limit(
7554        graph,
7555        semantics,
7556        control,
7557        MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7558    )
7559}
7560
7561/// Filter raw graph identities and source-owned ordinals through the same masks.
7562fn admit_symbol_graph_with_semantics_limit<'a>(
7563    graph: Cow<'a, SymbolGraph>,
7564    semantics: Option<&mut projectatlas_symbols::SourceSemanticFacts>,
7565    control: &IndexWorkControl,
7566    limit: u64,
7567) -> Result<(Cow<'a, SymbolGraph>, GraphIdentityAdmission), CliError> {
7568    if semantics
7569        .as_ref()
7570        .is_some_and(|facts| !facts.matches_graph(&graph))
7571    {
7572        return Err(CliError::InvalidInput(
7573            "source binding evidence does not match the admitted graph".to_string(),
7574        ));
7575    }
7576    let mut report = GraphIdentityAdmission {
7577        retained_limit: Some(limit),
7578        ..GraphIdentityAdmission::default()
7579    };
7580    let paired_import_relations = paired_import_relations(&graph, control)?;
7581    #[cfg(test)]
7582    {
7583        report.paired_import_pairing_work = paired_import_relations.work_items;
7584    }
7585    let mut rejected_symbols = vec![false; graph.symbols.len()];
7586    for (index, symbol) in graph.symbols.iter().enumerate() {
7587        check_graph_work(control, index)?;
7588        let paired_relation_index = paired_import_relations.by_symbol[index];
7589        let span = symbol_identity_span(&graph, index, paired_relation_index);
7590        let mut failures = Vec::new();
7591        let name_field = if symbol.kind == SymbolKind::Package {
7592            GraphIdentityField::Package
7593        } else {
7594            GraphIdentityField::Symbol
7595        };
7596        record_identity_failure(&mut failures, name_field, &symbol.name);
7597        if let Some(parent) = symbol.parent.as_deref() {
7598            record_identity_failure(&mut failures, GraphIdentityField::Parent, parent);
7599        }
7600        let signature = if symbol.signature.is_empty() {
7601            &symbol.name
7602        } else {
7603            &symbol.signature
7604        };
7605        record_identity_failure(&mut failures, GraphIdentityField::Signature, signature);
7606        if !failures.is_empty() {
7607            rejected_symbols[index] = true;
7608            report.record(
7609                &graph.path,
7610                span,
7611                symbol.parser,
7612                symbol_parser_fact_index(index, paired_relation_index),
7613                &failures,
7614                control,
7615            )?;
7616        }
7617    }
7618    let mut rejected_relations = vec![false; graph.relations.len()];
7619    for (index, relation) in graph.relations.iter().enumerate() {
7620        check_graph_work(control, index)?;
7621        let span = IdentitySpan {
7622            start_line: relation.line.max(1),
7623            start_column: 0,
7624            end_line: relation.line.max(1),
7625            end_column: 0,
7626        };
7627        let mut failures = Vec::new();
7628        record_identity_failure(
7629            &mut failures,
7630            GraphIdentityField::RelationSource,
7631            &relation.source_name,
7632        );
7633        record_identity_failure(
7634            &mut failures,
7635            GraphIdentityField::RelationTarget,
7636            &relation.target_name,
7637        );
7638        if !failures.is_empty() {
7639            rejected_relations[index] = true;
7640            report.record(
7641                &graph.path,
7642                span,
7643                relation.parser,
7644                parser_fact_index(RELATION_FACT_INDEX_NAMESPACE, index),
7645                &failures,
7646                control,
7647            )?;
7648        }
7649    }
7650    if report.rejected_facts_by_path.is_empty() {
7651        return Ok((graph, report));
7652    }
7653    let mut graph = graph.into_owned();
7654    let relation_fact_indices = if rejected_relations.iter().any(|rejected| *rejected) {
7655        let count = rejected_relations
7656            .iter()
7657            .filter(|rejected| !**rejected)
7658            .count();
7659        if count > 0 {
7660            report.reserve_identity_bytes(
7661                identity_relation_indices_bytes(&graph.path, count),
7662                control,
7663                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7664            )?;
7665        }
7666        rejected_relations
7667            .iter()
7668            .enumerate()
7669            .filter_map(|(index, rejected)| (!rejected).then_some(index))
7670            .collect::<Vec<_>>()
7671    } else {
7672        Vec::new()
7673    };
7674    if let Some(semantics) = semantics {
7675        semantics
7676            .retain_graph_facts(
7677                &mut graph,
7678                &rejected_symbols
7679                    .iter()
7680                    .map(|rejected| !rejected)
7681                    .collect::<Vec<_>>(),
7682                &rejected_relations
7683                    .iter()
7684                    .map(|rejected| !rejected)
7685                    .collect::<Vec<_>>(),
7686            )
7687            .map_err(|error| {
7688                CliError::InvalidInput(format!("source binding admission failed: {error}"))
7689            })?;
7690    } else {
7691        graph.symbols = graph
7692            .symbols
7693            .into_iter()
7694            .enumerate()
7695            .filter_map(|(index, symbol)| (!rejected_symbols[index]).then_some(symbol))
7696            .collect();
7697        graph.relations = graph
7698            .relations
7699            .into_iter()
7700            .enumerate()
7701            .filter_map(|(index, relation)| (!rejected_relations[index]).then_some(relation))
7702            .collect();
7703    }
7704    if rejected_relations.iter().any(|rejected| *rejected) && !relation_fact_indices.is_empty() {
7705        report
7706            .relation_fact_indices
7707            .insert(graph.path.clone(), relation_fact_indices);
7708    }
7709    Ok((Cow::Owned(graph), report))
7710}
7711
7712/// Preserve the ordinary source identity sublimit for existing fixture callers.
7713#[cfg(test)]
7714pub(super) fn admit_symbol_build_stage(
7715    staged: &mut SymbolBuildStage,
7716    control: &IndexWorkControl,
7717) -> Result<GraphIdentityAdmission, CliError> {
7718    admit_symbol_build_stage_with_limit(staged, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)
7719}
7720
7721/// Sanitize parser output before any summary, persistence, or graph projection sink.
7722pub(super) fn admit_symbol_build_stage_with_limit(
7723    staged: &mut SymbolBuildStage,
7724    control: &IndexWorkControl,
7725    limit: u64,
7726) -> Result<GraphIdentityAdmission, CliError> {
7727    let mut report = GraphIdentityAdmission {
7728        retained_limit: Some(limit),
7729        ..GraphIdentityAdmission::default()
7730    };
7731    for (index, change) in staged.changes.iter_mut().enumerate() {
7732        check_graph_work(control, index)?;
7733        let SymbolProjectionChange::Parsed(parsed) = change else {
7734            continue;
7735        };
7736        let placeholder = SymbolGraph {
7737            path: parsed.path.clone(),
7738            language: None,
7739            parser: parsed.source_parser,
7740            symbols: Vec::new(),
7741            relations: Vec::new(),
7742        };
7743        let graph = std::mem::replace(&mut parsed.graph, placeholder);
7744        let (admitted, graph_report) = admit_symbol_graph_with_semantics_limit(
7745            Cow::Owned(graph),
7746            parsed.source_semantics.as_deref_mut(),
7747            control,
7748            limit.saturating_sub(report.observed_fact_bytes),
7749        )?;
7750        parsed.graph = admitted.into_owned();
7751        if let Some(markdown) = parsed.markdown_facts.as_deref_mut() {
7752            admit_markdown_fact_batch(
7753                &parsed.path,
7754                parsed.source_parser,
7755                markdown,
7756                &mut report,
7757                control,
7758            )?;
7759        }
7760        if graph_report.has_rejections() {
7761            // Parser summaries and generated suggestions are sinks too. Rebuild both
7762            // from the admitted graph so rejected identity text cannot survive there.
7763            parsed.summary = super::summarize_symbol_graph(&parsed.graph, None);
7764            parsed.summary_is_structural = false;
7765            if parsed.purpose_suggestion.is_some() {
7766                parsed.purpose_suggestion =
7767                    Some(super::suggest_file_purpose(&parsed.path, &parsed.summary));
7768            }
7769        }
7770        report.merge(graph_report, control)?;
7771    }
7772    let mut symbols = 0_usize;
7773    let mut relations = 0_usize;
7774    for change in &staged.changes {
7775        let SymbolProjectionChange::Parsed(parsed) = change else {
7776            continue;
7777        };
7778        symbols = symbols
7779            .checked_add(navigable_symbol_count(&parsed.graph))
7780            .ok_or_else(|| {
7781                CliError::InvalidInput("admitted symbol report count overflowed".to_string())
7782            })?;
7783        relations = relations
7784            .checked_add(parsed.graph.relations.len())
7785            .ok_or_else(|| {
7786                CliError::InvalidInput("admitted relation report count overflowed".to_string())
7787            })?;
7788    }
7789    staged.report.symbols = symbols;
7790    staged.report.relations = relations;
7791    report.source_admitted = true;
7792    Ok(report)
7793}
7794
7795/// Validate one source identity without retaining the rejected raw value.
7796fn record_identity_failure(
7797    failures: &mut Vec<(GraphIdentityField, GraphIdentityRejectionReason)>,
7798    field: GraphIdentityField,
7799    value: &str,
7800) {
7801    if let Err(error) = source_symbol_identity_error(value) {
7802        failures.push((field, GraphIdentityRejectionReason::from_error(&error)));
7803    }
7804}
7805
7806/// Validate one source identity without allocating on the valid hot path.
7807fn source_symbol_identity_error(value: &str) -> Result<(), GraphContractError> {
7808    projectatlas_symbols::declaration_identity::validate_source_symbol_identity(value)
7809}
7810
7811/// Admit source projections under an injectable operation staging ceiling.
7812#[allow(clippy::too_many_arguments)]
7813fn admit_resolution_key_failures_with_limit(
7814    project: ProjectInstanceId,
7815    generation: IndexGeneration,
7816    graphs: &[SourceGraph<'_>],
7817    packages: &PackageIndex,
7818    configured_modules: &ConfiguredModuleResolution,
7819    report: &mut GraphIdentityAdmission,
7820    control: &IndexWorkControl,
7821    limit: u64,
7822) -> Result<(), CliError> {
7823    let mut retained = resolution_projection_map_retained_bytes(&report.resolution_projections);
7824    for (index, graph) in graphs.iter().enumerate() {
7825        let semantics = graph.semantics();
7826        let graph = graph.graph();
7827        check_graph_work(control, index)?;
7828        #[cfg(not(test))]
7829        let _ = generation;
7830        if report.resolution_projections.contains_key(&graph.path) {
7831            return Err(CliError::InvalidInput(
7832                "duplicate resolution projection admission".to_string(),
7833            ));
7834        }
7835        #[cfg(test)]
7836        report.record_resolution_derivation(&graph.path, generation);
7837        let entry_bytes = STAGED_GRAPH_ROW_BYTES.saturating_add(graph.path.len() as u64);
7838        let before = retained
7839            .saturating_add(report.observed_fact_bytes)
7840            .saturating_add(entry_bytes);
7841        enforce_resolution_registry_budget_with_limit(before, limit)?;
7842        let mut context = resolution_projection_context(&graph.path, packages, configured_modules)
7843            .with_retained_bytes_limit(limit.saturating_sub(before));
7844        if let Some(semantics) = semantics {
7845            context = context.with_source_semantics(semantics);
7846        }
7847        match derive_resolution_keys_with_context(
7848            project,
7849            packages.package_name(&graph.path),
7850            graph,
7851            context,
7852        ) {
7853            Err(ResolutionProjectionError::SourceEvidenceMismatch) => {
7854                return Err(CliError::InvalidInput(
7855                    "source semantic evidence changed before key projection".to_string(),
7856                ));
7857            }
7858            Ok(projection) => {
7859                retained = retained.saturating_add(resolution_projection_map_entry_retained_bytes(
7860                    &graph.path,
7861                    &projection,
7862                ));
7863                enforce_resolution_registry_budget_with_limit(
7864                    retained.saturating_add(report.observed_fact_bytes),
7865                    limit,
7866                )?;
7867                report
7868                    .resolution_projections
7869                    .insert(graph.path.clone(), projection);
7870            }
7871            Err(ResolutionProjectionError::KeyLimit { requested, .. }) => {
7872                return Err(resolution_key_limit_failure(requested));
7873            }
7874            Err(ResolutionProjectionError::MemoryLimit { requested, .. }) => {
7875                return Err(resolution_projection_memory_failure(
7876                    limit,
7877                    before.saturating_add(requested),
7878                ));
7879            }
7880            Err(ResolutionProjectionError::Contract(failure)) => {
7881                let (failures, rejected_count, projection) = (*failure).into_parts();
7882                retained = retained.saturating_add(resolution_projection_map_entry_retained_bytes(
7883                    &graph.path,
7884                    &projection,
7885                ));
7886                let failure_scratch = (failures.capacity() as u64)
7887                    .saturating_mul(std::mem::size_of::<
7888                        projectatlas_symbols::ResolutionProjectionFactFailure,
7889                    >() as u64);
7890                enforce_resolution_registry_budget_with_limit(
7891                    retained
7892                        .saturating_add(failure_scratch)
7893                        .saturating_add(report.observed_fact_bytes),
7894                    limit,
7895                )?;
7896                let previous_limit = report.retained_limit;
7897                report.retained_limit = Some(
7898                    limit
7899                        .saturating_sub(retained)
7900                        .saturating_sub(failure_scratch),
7901                );
7902                for failure in &failures {
7903                    report.record(
7904                        &graph.path,
7905                        resolution_projection_span(graph, semantics, failure.fact()),
7906                        graph.parser,
7907                        resolution_projection_fact_index(report, &graph.path, failure.fact()),
7908                        &[(
7909                            GraphIdentityField::ResolutionKey,
7910                            GraphIdentityRejectionReason::from_error(failure.error()),
7911                        )],
7912                        control,
7913                    )?;
7914                }
7915                report.record_rejected_fact_count(
7916                    &graph.path,
7917                    rejected_count.saturating_sub(failures.len()),
7918                    control,
7919                )?;
7920                report.retained_limit = previous_limit;
7921                report
7922                    .resolution_projections
7923                    .insert(graph.path.clone(), projection);
7924            }
7925        }
7926    }
7927    Ok(())
7928}
7929
7930/// Require one admitted resolution projection for every graph being published.
7931fn ensure_admitted_resolution_projections(
7932    graphs: &[impl Borrow<SymbolGraph>],
7933    projections: &BTreeMap<String, ResolutionKeyProjection>,
7934) -> Result<(), CliError> {
7935    if projections.len() != graphs.len()
7936        || graphs
7937            .iter()
7938            .any(|graph| !projections.contains_key(&graph.borrow().path))
7939    {
7940        return Err(CliError::InvalidInput(
7941            "resolution projection admission did not match staged graphs".to_string(),
7942        ));
7943    }
7944    Ok(())
7945}
7946
7947/// Map one resolution fact into its deterministic internal rejection identity.
7948fn resolution_projection_fact_index(
7949    report: &GraphIdentityAdmission,
7950    path: &str,
7951    fact: ResolutionProjectionFact,
7952) -> u64 {
7953    match fact {
7954        ResolutionProjectionFact::Source => 0,
7955        ResolutionProjectionFact::Export(index) => {
7956            parser_fact_index(EXPORT_FACT_INDEX_NAMESPACE, index)
7957        }
7958        ResolutionProjectionFact::Symbol(index) => {
7959            parser_fact_index(SYMBOL_FACT_INDEX_NAMESPACE, index)
7960        }
7961        ResolutionProjectionFact::Relation(index) => parser_fact_index(
7962            RELATION_FACT_INDEX_NAMESPACE,
7963            report.relation_parser_index(path, index),
7964        ),
7965    }
7966}
7967
7968/// Select the narrowest parser span available for one rejected derived key.
7969fn resolution_projection_span(
7970    graph: &SymbolGraph,
7971    semantics: Option<&SourceSemanticFacts>,
7972    fact: ResolutionProjectionFact,
7973) -> IdentitySpan {
7974    match fact {
7975        ResolutionProjectionFact::Source => graph_identity_span(graph),
7976        ResolutionProjectionFact::Export(index) => semantics
7977            .and_then(|facts| facts.exports().get(index))
7978            .map_or_else(
7979                || graph_identity_span(graph),
7980                |fact| IdentitySpan {
7981                    start_line: fact.line.max(1),
7982                    start_column: 0,
7983                    end_line: fact.line.max(1),
7984                    end_column: 0,
7985                },
7986            ),
7987        ResolutionProjectionFact::Symbol(index) => graph.symbols.get(index).map_or_else(
7988            || graph_identity_span(graph),
7989            |symbol| IdentitySpan {
7990                start_line: symbol.line_start.max(1),
7991                start_column: 0,
7992                end_line: symbol.line_end.max(symbol.line_start).max(1),
7993                end_column: 0,
7994            },
7995        ),
7996        ResolutionProjectionFact::Relation(index) => graph.relations.get(index).map_or_else(
7997            || graph_identity_span(graph),
7998            |relation| IdentitySpan {
7999                start_line: relation.line.max(1),
8000                start_column: 0,
8001                end_line: relation.line.max(1),
8002                end_column: 0,
8003            },
8004        ),
8005    }
8006}
8007
8008/// Bound one graph-wide rejection span when canonical-key derivation has no fact index.
8009fn graph_identity_span(graph: &SymbolGraph) -> IdentitySpan {
8010    let mut start_line = usize::MAX;
8011    let mut end_line = 1;
8012    for symbol in &graph.symbols {
8013        start_line = start_line.min(symbol.line_start.max(1));
8014        end_line = end_line.max(symbol.line_end.max(symbol.line_start).max(1));
8015    }
8016    for relation in &graph.relations {
8017        start_line = start_line.min(relation.line.max(1));
8018        end_line = end_line.max(relation.line.max(1));
8019    }
8020    IdentitySpan {
8021        start_line: if start_line == usize::MAX {
8022            1
8023        } else {
8024            start_line
8025        },
8026        start_column: 0,
8027        end_line,
8028        end_column: 0,
8029    }
8030}
8031
8032/// Admit parser-owned Markdown selectors before document-key derivation.
8033fn admit_markdown_facts(
8034    facts: &mut BTreeMap<String, Cow<'_, MarkdownFacts>>,
8035    graphs: &[impl Borrow<SymbolGraph>],
8036    report: &mut GraphIdentityAdmission,
8037    control: &IndexWorkControl,
8038) -> Result<(), CliError> {
8039    for (graph_index, graph) in graphs.iter().enumerate() {
8040        check_graph_work(control, graph_index)?;
8041        let graph = graph.borrow();
8042        let Some(markdown) = facts.get_mut(&graph.path) else {
8043            continue;
8044        };
8045        if !markdown
8046            .link_candidates
8047            .iter()
8048            .any(|candidate| GraphIdentityText::validate(&candidate.selector).is_err())
8049        {
8050            continue;
8051        }
8052        admit_markdown_fact_batch(
8053            &graph.path,
8054            graph.parser,
8055            markdown.to_mut(),
8056            report,
8057            control,
8058        )?;
8059    }
8060    Ok(())
8061}
8062
8063/// Admit one parser-owned Markdown fact batch before document-key derivation.
8064fn admit_markdown_fact_batch(
8065    path: &str,
8066    parser: ParserKind,
8067    markdown: &mut MarkdownFacts,
8068    report: &mut GraphIdentityAdmission,
8069    control: &IndexWorkControl,
8070) -> Result<(), CliError> {
8071    let mut rejected = Vec::new();
8072    for (candidate_index, candidate) in markdown.link_candidates.iter().enumerate() {
8073        check_graph_work(control, candidate_index)?;
8074        if let Err(error) = GraphIdentityText::validate(&candidate.selector) {
8075            rejected.push((
8076                candidate_index,
8077                candidate.source.line_start,
8078                candidate.source.column_start,
8079                candidate.source.line_end,
8080                candidate.source.column_end,
8081                GraphIdentityRejectionReason::from_error(&error),
8082            ));
8083        }
8084    }
8085    for (candidate_index, start_line, start_column, end_line, end_column, reason) in rejected {
8086        report.record(
8087            path,
8088            IdentitySpan {
8089                start_line,
8090                start_column,
8091                end_line,
8092                end_column,
8093            },
8094            parser,
8095            parser_fact_index(MARKDOWN_FACT_INDEX_NAMESPACE, candidate_index),
8096            &[(GraphIdentityField::RelationTarget, reason)],
8097            control,
8098        )?;
8099    }
8100    markdown
8101        .link_candidates
8102        .retain(|candidate| GraphIdentityText::validate(&candidate.selector).is_ok());
8103    Ok(())
8104}
8105
8106/// Overlay staged Markdown facts and parse only persisted graphs not parsed in this operation.
8107fn complete_markdown_facts<'a>(
8108    root: &Path,
8109    nodes: &[Node],
8110    graphs: &[impl Borrow<SymbolGraph>],
8111    symbols: &'a SymbolBuildStage,
8112    control: &IndexWorkControl,
8113    limit: u64,
8114) -> Result<BTreeMap<String, Cow<'a, MarkdownFacts>>, CliError> {
8115    let graph_paths = graphs
8116        .iter()
8117        .map(|graph| graph.borrow().path.as_str())
8118        .collect::<BTreeSet<_>>();
8119    let nodes_by_path = nodes
8120        .iter()
8121        .filter(|node| node.kind == NodeKind::File)
8122        .map(|node| (node.path.as_str(), node))
8123        .collect::<BTreeMap<_, _>>();
8124    let mut facts = BTreeMap::new();
8125    let mut retained =
8126        (nodes.len().saturating_add(graphs.len()) as u64).saturating_mul(STAGED_GRAPH_ROW_BYTES);
8127    enforce_resolution_registry_budget_with_limit(retained, limit)?;
8128    for change in &symbols.changes {
8129        let SymbolProjectionChange::Parsed(parsed) = change else {
8130            continue;
8131        };
8132        if graph_paths.contains(parsed.path.as_str())
8133            && let Some(markdown) = parsed.markdown_facts.as_ref()
8134        {
8135            retained = retained
8136                .saturating_add(STAGED_GRAPH_ROW_BYTES)
8137                .saturating_add(parsed.path.len() as u64);
8138            enforce_resolution_registry_budget_with_limit(retained, limit)?;
8139            facts.insert(parsed.path.clone(), Cow::Borrowed(markdown.as_ref()));
8140        }
8141    }
8142    for graph in graphs {
8143        let graph = graph.borrow();
8144        control.check(IndexWorkStage::SymbolParsing)?;
8145        if facts.contains_key(&graph.path)
8146            || !graph
8147                .language
8148                .as_deref()
8149                .and_then(language_capability)
8150                .is_some_and(|capability| capability.symbol_parser == SymbolParserOwner::Markdown)
8151        {
8152            continue;
8153        }
8154        let node = nodes_by_path.get(graph.path.as_str()).ok_or_else(|| {
8155            CliError::InvalidInput(format!(
8156                "Markdown graph path was absent from the staged file inventory: {}",
8157                graph.path
8158            ))
8159        })?;
8160        let native_path = root.join(Path::new(&graph.path));
8161        let bytes = match read_source_bytes_controlled(
8162            &native_path,
8163            MAX_SYMBOL_FILE_BYTES,
8164            IndexWorkStage::SymbolParsing,
8165            control,
8166        ) {
8167            Ok(bytes) => bytes,
8168            Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
8169            Err(SourceReadFailure::LimitExceeded { .. }) => {
8170                return Err(source_changed_during_derivation(root, &graph.path));
8171            }
8172            Err(SourceReadFailure::Io(source)) => {
8173                return Err(CliError::Io {
8174                    path: native_path,
8175                    source,
8176                });
8177            }
8178        };
8179        if node
8180            .content_hash
8181            .as_deref()
8182            .is_none_or(|expected| blake3::hash(&bytes).to_hex().as_str() != expected)
8183        {
8184            return Err(source_changed_during_derivation(root, &graph.path));
8185        }
8186        let content = String::from_utf8(bytes)
8187            .map_err(|_source| source_changed_during_derivation(root, &graph.path))?;
8188        let extracted = extract_markdown_facts_controlled(&content, control)?;
8189        retained = retained.saturating_add(document_fact_entry_retained_bytes(
8190            &graph.path,
8191            &extracted,
8192            true,
8193        ));
8194        enforce_resolution_registry_budget_with_limit(retained, limit)?;
8195        facts.insert(graph.path.clone(), Cow::Owned(extracted));
8196    }
8197    enforce_resolution_registry_budget(document_fact_map_retained_bytes(&facts))?;
8198    Ok(facts)
8199}
8200
8201/// Count map ownership plus evidence owned only for persisted graphs parsed on demand.
8202fn document_fact_map_retained_bytes(facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>) -> u64 {
8203    facts.iter().fold(0_u64, |bytes, (path, facts)| {
8204        bytes.saturating_add(document_fact_entry_retained_bytes(
8205            path,
8206            facts,
8207            matches!(facts, Cow::Owned(_)),
8208        ))
8209    })
8210}
8211
8212/// Account a document without cloning its extracted facts.
8213fn document_fact_entry_retained_bytes(path: &str, facts: &MarkdownFacts, owned: bool) -> u64 {
8214    let bytes = STAGED_GRAPH_ROW_BYTES.saturating_add(path.len() as u64);
8215    if !owned {
8216        return bytes;
8217    }
8218    let heading_bytes = facts.headings.iter().fold(0_u64, |bytes, heading| {
8219        bytes
8220            .saturating_add(STAGED_GRAPH_ROW_BYTES)
8221            .saturating_add(heading.text.len() as u64)
8222            .saturating_add(heading.slug.len() as u64)
8223    });
8224    facts
8225        .link_candidates
8226        .iter()
8227        .fold(bytes.saturating_add(heading_bytes), |bytes, candidate| {
8228            bytes
8229                .saturating_add(STAGED_GRAPH_ROW_BYTES)
8230                .saturating_add(candidate.selector.len() as u64)
8231                .saturating_add(candidate.label.as_ref().map_or(0, String::len) as u64)
8232                .saturating_add(candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64)
8233        })
8234}
8235
8236/// Count document projection state that can survive the per-candidate filters.
8237///
8238/// Same-file links without a fragment are discarded before resolution and never
8239/// enter the emitted relation, occurrence, dependency, or reason state. Keep
8240/// those candidates out of the preflight estimate so incremental admission and
8241/// the post-projection aggregate use the same ownership boundary.
8242fn document_projection_retained_bytes(
8243    facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
8244    control: &IndexWorkControl,
8245) -> Result<u64, CliError> {
8246    let mut retained_bytes = 0_u64;
8247    let mut candidate_index = 0_usize;
8248    for (document_path, facts) in facts {
8249        for candidate in &facts.link_candidates {
8250            check_graph_work(control, candidate_index)?;
8251            candidate_index = candidate_index.saturating_add(1);
8252            if normalize_document_target(document_path, &candidate.selector).is_ok_and(|target| {
8253                target.path == document_path.as_str() && target.fragment.is_none()
8254            }) {
8255                continue;
8256            }
8257            retained_bytes = retained_bytes
8258                .saturating_add(DOCUMENT_PROJECTION_ROW_BYTES)
8259                .saturating_add(candidate.selector.len() as u64)
8260                .saturating_add(candidate.label.as_ref().map_or(0, String::len) as u64)
8261                .saturating_add(candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64);
8262        }
8263    }
8264    Ok(retained_bytes)
8265}
8266
8267/// One normalized repository-local document target and optional heading fragment.
8268#[derive(Clone, Debug, Eq, PartialEq)]
8269struct DocumentTargetIdentity {
8270    /// Exact slash-separated path under the selected root.
8271    path: String,
8272    /// Static fragment retained only when it can address a heading export.
8273    fragment: Option<String>,
8274}
8275
8276/// Normalize one parser-admitted selector relative to its owning document.
8277fn normalize_document_target(
8278    document_path: &str,
8279    selector: &str,
8280) -> Result<DocumentTargetIdentity, DocumentTargetUnresolvedReason> {
8281    let (path_and_query, fragment) = selector
8282        .split_once('#')
8283        .map_or((selector, None), |(path, fragment)| (path, Some(fragment)));
8284    let path = path_and_query
8285        .split_once('?')
8286        .map_or(path_and_query, |(path, _query)| path);
8287    let path = strip_document_line_selector(path);
8288    if path.is_empty() {
8289        return Err(DocumentTargetUnresolvedReason::NoStaticTarget);
8290    }
8291    let mut components = document_path
8292        .rsplit_once('/')
8293        .map_or_else(Vec::new, |(parent, _file)| {
8294            parent.split('/').map(str::to_owned).collect::<Vec<_>>()
8295        });
8296    for component in path.split('/') {
8297        match component {
8298            "" => return Err(DocumentTargetUnresolvedReason::Unsupported),
8299            "." => {}
8300            ".." => {
8301                if components.pop().is_none() {
8302                    return Err(DocumentTargetUnresolvedReason::OutsideRoot);
8303                }
8304            }
8305            value if value.contains(':') => {
8306                return Err(DocumentTargetUnresolvedReason::Unsupported);
8307            }
8308            value => components.push(value.to_owned()),
8309        }
8310    }
8311    if components.is_empty() {
8312        return Err(DocumentTargetUnresolvedReason::NoStaticTarget);
8313    }
8314    let fragment = match fragment.map(str::trim) {
8315        None => None,
8316        Some(value)
8317            if !value.is_empty()
8318                && !value.contains(['/', '\\', '?', '#', '{', '}', '<', '>', '|', '*', '$'])
8319                && !value.chars().any(char::is_whitespace) =>
8320        {
8321            Some(value.to_lowercase())
8322        }
8323        Some(_value) => return Err(DocumentTargetUnresolvedReason::NoStaticTarget),
8324    };
8325    Ok(DocumentTargetIdentity {
8326        path: components.join("/"),
8327        fragment,
8328    })
8329}
8330
8331/// Remove one supported `:12`, `:L12-L20`, or equivalent line selector.
8332fn strip_document_line_selector(path: &str) -> &str {
8333    let Some((identity, selector)) = path.rsplit_once(':') else {
8334        return path;
8335    };
8336    let selector = selector.strip_prefix('L').unwrap_or(selector);
8337    let valid = selector.split_once('-').map_or_else(
8338        || !selector.is_empty() && selector.chars().all(|character| character.is_ascii_digit()),
8339        |(start, end)| {
8340            !start.is_empty()
8341                && !end.is_empty()
8342                && start.chars().all(|character| character.is_ascii_digit())
8343                && end
8344                    .strip_prefix('L')
8345                    .unwrap_or(end)
8346                    .chars()
8347                    .all(|character| character.is_ascii_digit())
8348        },
8349    );
8350    if valid { identity } else { path }
8351}
8352
8353/// Construct one exact file identity through the existing module-key domain.
8354fn document_file_resolution_key(
8355    project: ProjectInstanceId,
8356    path: &str,
8357) -> Result<CanonicalResolutionKey, CliError> {
8358    document_resolution_key(project, ResolutionKeyDomain::Module, path)
8359}
8360
8361/// Construct one case-fold collision key through the existing module-key domain.
8362fn document_casefold_resolution_key(
8363    project: ProjectInstanceId,
8364    path: &str,
8365) -> Result<CanonicalResolutionKey, CliError> {
8366    document_resolution_key_with_language(
8367        project,
8368        ResolutionKeyDomain::Module,
8369        DOCUMENT_CASEFOLD_LANGUAGE,
8370        &path.to_lowercase(),
8371    )
8372}
8373
8374/// Construct one exact Markdown heading identity through the declaration-key domain.
8375fn document_heading_resolution_key(
8376    project: ProjectInstanceId,
8377    path: &str,
8378    fragment: &str,
8379) -> Result<CanonicalResolutionKey, CliError> {
8380    document_resolution_key(
8381        project,
8382        ResolutionKeyDomain::Declaration,
8383        &format!("{path}#{fragment}"),
8384    )
8385}
8386
8387/// Construct one project-qualified canonical document identity without a new key family.
8388fn document_resolution_key(
8389    project: ProjectInstanceId,
8390    domain: ResolutionKeyDomain,
8391    identity: &str,
8392) -> Result<CanonicalResolutionKey, CliError> {
8393    document_resolution_key_with_language(project, domain, DOCUMENT_PATH_LANGUAGE, identity)
8394}
8395
8396/// Construct one project-qualified document key under a selected resolver language.
8397fn document_resolution_key_with_language(
8398    project: ProjectInstanceId,
8399    domain: ResolutionKeyDomain,
8400    resolver_language: &str,
8401    identity: &str,
8402) -> Result<CanonicalResolutionKey, CliError> {
8403    let provider =
8404        GraphIdentityText::new(DOCUMENT_PATH_PROVIDER).map_err(invalid_graph_contract)?;
8405    let language = GraphIdentityText::new(resolver_language).map_err(invalid_graph_contract)?;
8406    let identity = GraphIdentityText::new(identity).map_err(invalid_graph_contract)?;
8407    Ok(CanonicalResolutionKey::new(
8408        project,
8409        domain,
8410        &provider,
8411        &language,
8412        None,
8413        None,
8414        Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
8415        &identity,
8416    ))
8417}
8418
8419/// Return all exact path and heading keys that can affect staged document relations.
8420fn append_document_dependency_keys(
8421    project: ProjectInstanceId,
8422    facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
8423    keys: &mut ResolutionKeyWorkspace<'_>,
8424) -> Result<(), CliError> {
8425    for (document_path, facts) in facts {
8426        for candidate in &facts.link_candidates {
8427            let Ok(target) = normalize_document_target(document_path, &candidate.selector) else {
8428                continue;
8429            };
8430            keys.insert_owned(document_file_resolution_key(project, &target.path)?)?;
8431            keys.insert_owned(document_casefold_resolution_key(project, &target.path)?)?;
8432            if let Some(fragment) = target.fragment {
8433                keys.insert_owned(document_heading_resolution_key(
8434                    project,
8435                    &target.path,
8436                    &fragment,
8437                )?)?;
8438            }
8439        }
8440    }
8441    Ok(())
8442}
8443
8444/// Derive canonical resolution keys with repository compiler configuration.
8445fn resolution_projection_with_config(
8446    project: ProjectInstanceId,
8447    packages: &PackageIndex,
8448    graph: &SymbolGraph,
8449    configured_modules: &ConfiguredModuleResolution,
8450    limit: u64,
8451) -> Result<ResolutionKeyProjection, CliError> {
8452    let context = resolution_projection_context(&graph.path, packages, configured_modules)
8453        .with_retained_bytes_limit(limit);
8454    match derive_resolution_keys_with_context(
8455        project,
8456        packages.package_name(&graph.path),
8457        graph,
8458        context,
8459    ) {
8460        Err(ResolutionProjectionError::SourceEvidenceMismatch) => Err(CliError::InvalidInput(
8461            "source semantic evidence does not match the graph".to_string(),
8462        )),
8463        Ok(projection) => Ok(projection),
8464        Err(ResolutionProjectionError::MemoryLimit { limit, requested }) => {
8465            Err(resolution_projection_memory_failure(limit, requested))
8466        }
8467        Err(ResolutionProjectionError::KeyLimit { requested, .. }) => {
8468            Err(resolution_key_limit_failure(requested))
8469        }
8470        Err(ResolutionProjectionError::Contract(failure)) => {
8471            let (mut failures, _rejected_count, _projection) = (*failure).into_parts();
8472            let Some(failure) = failures.pop() else {
8473                return Err(CliError::InvalidInput(
8474                    "resolution projection reported an empty contract failure".to_string(),
8475                ));
8476            };
8477            let (_fact, error) = failure.into_parts();
8478            Err(invalid_graph_contract(error))
8479        }
8480    }
8481}
8482
8483/// Share exact package and configured-module context across fresh and recovered graphs.
8484fn resolution_projection_context<'a>(
8485    path: &str,
8486    packages: &'a PackageIndex,
8487    configured: &'a ConfiguredModuleResolution,
8488) -> ResolutionProjectionContext<'a> {
8489    let mut context = ResolutionProjectionContext::with_configured_modules(configured);
8490    if let Some(package) = packages.package_owner(path) {
8491        context = context.with_rust_package(&package.manifest, &package.root);
8492        if let Some(directory) = packages.root_cohort_directory(path) {
8493            context = context.with_rust_root_cohort(directory);
8494        }
8495    }
8496    context
8497}
8498
8499/// Preserve resource-limit failures while admitting contract-invalid key facts.
8500fn resolution_key_limit_failure(requested: usize) -> CliError {
8501    IndexWorkFailure::resource_limit(
8502        IndexWorkStage::SymbolParsing,
8503        IndexWorkResource::RelationRows,
8504        u64::try_from(MAX_RESOLUTION_KEYS_PER_FACT).unwrap_or(u64::MAX),
8505        u64::try_from(requested).unwrap_or(u64::MAX),
8506    )
8507    .into()
8508}
8509
8510/// Lift database admission refusals into the shared CLI/MCP indexing error family.
8511fn resolution_read_error(error: projectatlas_db::DbError) -> CliError {
8512    match error {
8513        projectatlas_db::DbError::IndexWork(failure) => failure.into(),
8514        other => other.into(),
8515    }
8516}
8517
8518/// Preserve a producer byte refusal at the existing indexing resource boundary.
8519fn resolution_projection_memory_failure(limit: u64, requested: u64) -> CliError {
8520    IndexWorkFailure::resource_limit(
8521        IndexWorkStage::SymbolParsing,
8522        IndexWorkResource::OutputBytes,
8523        limit,
8524        requested,
8525    )
8526    .into()
8527}
8528
8529/// Load the project identity required by normalized graph projection.
8530fn selected_project(store: &AtlasStore) -> Result<ProjectInstanceId, CliError> {
8531    store.project_instance_id()?.ok_or_else(|| {
8532        CliError::InvalidInput("repository graph requires a bound project identity".to_string())
8533    })
8534}
8535
8536/// Return the pending graph generation after one successful publication.
8537fn next_generation(base: IndexGeneration) -> Result<IndexGeneration, CliError> {
8538    base.checked_next().ok_or_else(|| {
8539        CliError::InvalidInput("repository graph generation is exhausted".to_string())
8540    })
8541}
8542
8543/// Insert one stable entity while rejecting digest collisions.
8544fn insert_entity(
8545    entities: &mut BTreeMap<String, GraphEntity>,
8546    entity: GraphEntity,
8547) -> Result<(), CliError> {
8548    let digest = entity.key().digest().to_string();
8549    if let Some(existing) = entities.get(&digest) {
8550        if !existing
8551            .key()
8552            .reconcile(entity.key())
8553            .map_err(invalid_graph_contract)?
8554        {
8555            return Err(CliError::InvalidInput(
8556                "graph entity digest retained conflicting ownership".to_string(),
8557            ));
8558        }
8559        return Ok(());
8560    }
8561    entities.insert(digest, entity);
8562    Ok(())
8563}
8564
8565/// Sort and deduplicate entity export bindings by canonical identity and owner.
8566fn sort_dedup_exports(exports: &mut Vec<EntityResolutionKey>) {
8567    exports.sort_by(|left, right| {
8568        left.key()
8569            .cmp(right.key())
8570            .then_with(|| left.entity().digest().cmp(right.entity().digest()))
8571    });
8572    exports.dedup();
8573}
8574
8575/// Sort and deduplicate relation dependency bindings by canonical key and owner.
8576fn sort_dedup_dependencies(dependencies: &mut Vec<RelationDependencyKey>) {
8577    dependencies.sort_by(|left, right| {
8578        left.key()
8579            .cmp(right.key())
8580            .then_with(|| left.relation().digest().cmp(right.relation().digest()))
8581    });
8582    dependencies.dedup();
8583}
8584
8585/// Enforce the complete-projection ceiling for canonical key bindings.
8586fn enforce_key_binding_limit(count: usize) -> Result<(), CliError> {
8587    let observed = u64::try_from(count).unwrap_or(u64::MAX);
8588    if observed > MAX_GRAPH_KEY_BINDINGS {
8589        return Err(IndexWorkFailure::resource_limit(
8590            IndexWorkStage::SymbolParsing,
8591            IndexWorkResource::RelationRows,
8592            MAX_GRAPH_KEY_BINDINGS,
8593            observed,
8594        )
8595        .into());
8596    }
8597    Ok(())
8598}
8599
8600/// Enforce one path/key count before incremental graph work expands further.
8601fn enforce_incremental_count<T>(
8602    root: &Path,
8603    _context: &'static str,
8604    count: usize,
8605    sample: &BTreeSet<T>,
8606) -> Result<(), CliError>
8607where
8608    T: ToString + Ord,
8609{
8610    if count > MAX_INCREMENTAL_RESOLUTION_ITEMS as usize {
8611        return Err(dependency_closure_limit(
8612            root,
8613            sample.iter().map(ToString::to_string),
8614            count,
8615        ));
8616    }
8617    Ok(())
8618}
8619
8620/// Load and admit the persisted rows that an incremental replacement will remove.
8621fn admitted_persisted_footprint(
8622    store: &AtlasStore,
8623    project: ProjectInstanceId,
8624    root: &Path,
8625    affected_paths: &BTreeSet<String>,
8626    control: &IndexWorkControl,
8627) -> Result<RepositoryAffectedSourceFootprint, CliError> {
8628    control.check(IndexWorkStage::SymbolParsing)?;
8629    let footprint = store.repository_affected_source_footprint(
8630        project,
8631        &affected_paths.iter().cloned().collect::<Vec<_>>(),
8632        u32::try_from(MAX_INCREMENTAL_GRAPH_ROWS).unwrap_or(u32::MAX),
8633    )?;
8634    control.check(IndexWorkStage::SymbolParsing)?;
8635    if footprint.truncated {
8636        return Err(dependency_closure_limit(
8637            root,
8638            affected_paths.iter().cloned(),
8639            usize::try_from(footprint.rows).unwrap_or(usize::MAX),
8640        ));
8641    }
8642    enforce_incremental_projection_budget(
8643        root,
8644        affected_paths,
8645        footprint.rows,
8646        footprint.retained_bytes,
8647    )?;
8648    Ok(footprint)
8649}
8650
8651/// Enforce aggregate old-removal and new-insertion work for one closure.
8652fn enforce_incremental_projection_limits(
8653    root: &Path,
8654    affected_paths: &BTreeSet<String>,
8655    persisted: RepositoryAffectedSourceFootprint,
8656    staged: &StagedRepositoryGraph,
8657) -> Result<(), CliError> {
8658    let staged_rows = [
8659        affected_paths.len(),
8660        staged.entities.len(),
8661        staged.relations.len(),
8662        staged.occurrences.len(),
8663        staged.coverage.len(),
8664        staged.entity_exports.len(),
8665        staged.relation_dependencies.len(),
8666        staged.document_unresolved_reasons.len(),
8667    ]
8668    .into_iter()
8669    .fold(0_u64, |total, count| {
8670        total.saturating_add(u64::try_from(count).unwrap_or(u64::MAX))
8671    });
8672    enforce_incremental_projection_budget(
8673        root,
8674        affected_paths,
8675        persisted.rows.saturating_add(staged_rows),
8676        persisted
8677            .retained_bytes
8678            .saturating_add(staged.retained_bytes),
8679    )
8680}
8681
8682/// Return typed full-refresh guidance when an incremental projection exceeds a bound.
8683fn enforce_incremental_projection_budget(
8684    root: &Path,
8685    affected_paths: &BTreeSet<String>,
8686    rows: u64,
8687    retained_bytes: u64,
8688) -> Result<(), CliError> {
8689    if rows > MAX_INCREMENTAL_GRAPH_ROWS || retained_bytes > MAX_INCREMENTAL_GRAPH_BYTES {
8690        return Err(dependency_closure_limit(
8691            root,
8692            affected_paths.iter().cloned(),
8693            affected_paths.len(),
8694        ));
8695    }
8696    Ok(())
8697}
8698
8699/// Construct deterministic full-refresh guidance for an oversized dependency closure.
8700fn dependency_closure_limit(
8701    root: &Path,
8702    sample: impl IntoIterator<Item = String>,
8703    observed: usize,
8704) -> CliError {
8705    CliError::RefreshRequired(Box::new(IndexRefreshRequired {
8706        project_root: lossless_project_root_display(root),
8707        worktree: None,
8708        status: IndexReadStatus::RefreshRequired,
8709        reason: IndexRefreshReason::DependencyClosureLimit,
8710        scope: IndexRefreshScope::Full,
8711        changed: observed,
8712        added: 0,
8713        removed: 0,
8714        modified: observed,
8715        sample_paths: sample
8716            .into_iter()
8717            .take(INDEX_FRESHNESS_SAMPLE_LIMIT)
8718            .collect(),
8719    }))
8720}
8721
8722/// Translate a graph-domain contract violation for the CLI boundary.
8723fn invalid_graph_contract(error: GraphContractError) -> CliError {
8724    CliError::from(error)
8725}
8726
8727impl From<GraphContractError> for CliError {
8728    fn from(error: GraphContractError) -> Self {
8729        Self::InvalidInput(format!("repository graph projection failed: {error}"))
8730    }
8731}
8732
8733/// Count conservative retained canonical resolution-key bytes.
8734fn resolution_retained_bytes(projection: &ResolutionKeyProjection) -> u64 {
8735    projection.retained_bytes()
8736}
8737
8738/// Count one admitted projection entry while it remains in the source map.
8739fn resolution_projection_map_entry_retained_bytes(
8740    path: &str,
8741    projection: &ResolutionKeyProjection,
8742) -> u64 {
8743    STAGED_GRAPH_ROW_BYTES
8744        .saturating_add(std::mem::size_of::<ResolutionKeyProjection>() as u64)
8745        .saturating_add(path.len() as u64)
8746        .saturating_add(resolution_retained_bytes(projection))
8747}
8748
8749/// Count all projection-map entries that coexist with the growing entity state.
8750fn resolution_projection_map_retained_bytes(
8751    projections: &BTreeMap<String, ResolutionKeyProjection>,
8752) -> u64 {
8753    projections.iter().fold(0_u64, |bytes, (path, projection)| {
8754        bytes.saturating_add(resolution_projection_map_entry_retained_bytes(
8755            path, projection,
8756        ))
8757    })
8758}
8759
8760#[cfg(test)]
8761mod tests {
8762    use super::{
8763        CliError, DOCUMENT_PROJECTION_ROW_BYTES, DocumentResolutionIndex, DocumentTargetIdentity,
8764        GRAPH_STAGE_DATABASE_FILE_NAME, GRAPH_STAGE_DIRECTORY_PREFIX, GraphIdentityAdmission,
8765        GraphOwners, GraphSymbolIndex, MAX_IN_MEMORY_GRAPH_WORK_BYTES, MAX_INCREMENTAL_GRAPH_BYTES,
8766        MAX_INCREMENTAL_GRAPH_ROWS, PARTIAL_COVERAGE_REASON, PackageIndex,
8767        ProjectResolutionRegistry, QUALIFIED_SYMBOL_SCOPE_PREFIX, RepositoryGraphMutation,
8768        SourceGraph, SourceRecoveryWork, StagedRepositoryGraph, build_entity_projection,
8769        build_entity_projection_with_config, build_entity_projection_with_config_limit,
8770        cleanup_abandoned_graph_staging, coverage_for_graph, document_casefold_resolution_key,
8771        document_coverage, document_fact_map_retained_bytes, document_projection_retained_bytes,
8772        enforce_incremental_projection_budget, enforce_incremental_projection_limits,
8773        enforce_resolution_staging_budget, explicit_external_selector, finish_projection,
8774        finish_projection_in_database, finish_projection_in_database_with_documents,
8775        finish_projection_with_documents, identity_rejection_keys_retained_bytes, insert_relation,
8776        is_cargo_manifest_path, normalize_document_target, project_document_rows,
8777        qualified_symbol_identity, qualified_symbol_parents, registry_resolution_matches,
8778        relation_resolution, remove_owned_graph_stage_payload, repository_path_belongs_to,
8779        resolution_projection_map_retained_bytes, resolution_registry_from_exports,
8780        rust_toolchain_identity, source_symbol_identity, stage_full_repository_graph,
8781        stage_incremental_repository_graph, stage_incremental_repository_graph_with_test_limit,
8782        try_graph_stage_lease,
8783    };
8784    use crate::runtime::{
8785        IndexRefreshReason, IndexRefreshScope, SymbolBuildReport, SymbolBuildStage,
8786        SymbolParseSuccess, SymbolProjectionChange,
8787    };
8788    use projectatlas_core::graph::{
8789        CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
8790        CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
8791        ExtendedRelationKind, GraphEntity, GraphIdentityField, GraphIdentityRejectionReason,
8792        GraphIdentityText, GraphLimitKind, GraphLimits, GraphRelationKind, LogicalRelation,
8793        MAX_GRAPH_IDENTITY_BYTES, PackageSelector, ProjectInstanceId, RelationDependencyKey,
8794        RelationResolution, RepositoryFilePath, RepositoryNodePath, ResolutionKeyDomain,
8795        ReusableTargetSelector, SymbolSelector,
8796    };
8797    use projectatlas_core::relation_capabilities::{
8798        RELATION_FAMILY_CAPABILITIES, RelationFamilyState,
8799    };
8800    use projectatlas_core::symbols::{
8801        CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
8802        SymbolRelation,
8803    };
8804    use projectatlas_core::{
8805        IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkResource,
8806        IndexWorkStage, Node, NodeKind,
8807    };
8808    use projectatlas_db::{
8809        AtlasStore, RepositoryAffectedSourceFootprint, RepositoryGraphRelationQuery,
8810    };
8811    use projectatlas_fs::{RootScanPolicy, ScanOptions};
8812    use projectatlas_symbols::extract_symbol_graph;
8813    use projectatlas_symbols::{
8814        ConfiguredModuleResolution, EcmaScriptConfigKind, EcmaScriptModuleConfig,
8815        EcmaScriptPathMapping, MAX_DOCUMENT_LINK_CANDIDATES, MAX_DOCUMENT_SELECTOR_BYTES,
8816        MAX_MARKDOWN_EVIDENCE_BYTES, MAX_MARKDOWN_LABEL_BYTES, MarkdownFactLimit,
8817    };
8818    use rusqlite::Connection;
8819    use std::borrow::Cow;
8820    use std::collections::{BTreeMap, BTreeSet};
8821    use std::error::Error;
8822    use std::fmt::Debug;
8823    use std::fs;
8824    use std::io;
8825    use std::num::NonZeroU32;
8826    use std::path::Path;
8827    use std::thread;
8828    use std::time::{Duration, Instant};
8829
8830    #[test]
8831    fn paired_multiline_import_rejection_is_one_parser_fact() -> Result<(), Box<dyn Error>> {
8832        let graph = extract_symbol_graph(
8833            "src/page.ts",
8834            Some("typescript"),
8835            "import {\n  helper\n} from './LeakedIdentity\u{0}module';\n",
8836        );
8837        let control = IndexWorkControl::new(IndexCancellation::new(), None);
8838        let (admitted, report) = super::admit_symbol_graph(Cow::Owned(graph), &control)?;
8839        require_eq(
8840            &report.rejected_facts_for("src/page.ts"),
8841            &1,
8842            "paired multiline import omission count",
8843        )?;
8844        require_eq(
8845            &report.rejections.len(),
8846            &3,
8847            "paired multiline import typed detail count",
8848        )?;
8849        require(
8850            report.rejections.iter().all(|rejection| {
8851                rejection.fact_index == report.rejections[0].fact_index
8852                    && rejection.span.start_line() == 1
8853                    && rejection.span.end_line() == 1
8854            }),
8855            "paired multiline import details did not share one parser span",
8856        )?;
8857        for field in [
8858            GraphIdentityField::RelationTarget,
8859            GraphIdentityField::Signature,
8860            GraphIdentityField::Symbol,
8861        ] {
8862            require(
8863                report
8864                    .rejections
8865                    .iter()
8866                    .any(|rejection| rejection.field == field),
8867                "paired multiline import lost a typed invalid field",
8868            )?;
8869        }
8870        require(
8871            admitted.symbols.is_empty() && admitted.relations.is_empty(),
8872            "paired multiline import retained an invalid parser fact",
8873        )?;
8874        Ok(())
8875    }
8876
8877    #[test]
8878    fn paired_import_admission_scans_scale_bound_once_and_deduplicates_replay()
8879    -> Result<(), Box<dyn Error>> {
8880        const IMPORT_SYMBOLS: usize = 4_000;
8881        const IMPORT_RELATIONS: usize = 8_000;
8882        const PAIRING_WORK_ITEMS: usize = IMPORT_SYMBOLS + IMPORT_RELATIONS;
8883        let graph = |path: &str, invalid: bool| SymbolGraph {
8884            path: path.to_string(),
8885            language: Some("typescript".to_string()),
8886            parser: ParserKind::TreeSitter,
8887            symbols: (0..IMPORT_SYMBOLS)
8888                .map(|index| {
8889                    let name = if invalid {
8890                        format!("invalid\0import-{index}")
8891                    } else {
8892                        format!("import-{index}")
8893                    };
8894                    CodeSymbol {
8895                        path: path.to_string(),
8896                        language: Some("typescript".to_string()),
8897                        name: name.clone(),
8898                        kind: SymbolKind::Import,
8899                        signature: name,
8900                        exported: false,
8901                        documentation: None,
8902                        line_start: 1,
8903                        line_end: 2,
8904                        source_selector: None,
8905                        parent: None,
8906                        parser: ParserKind::TreeSitter,
8907                        detail: Some("import_statement".to_string()),
8908                    }
8909                })
8910                .collect(),
8911            relations: (0..IMPORT_RELATIONS)
8912                .map(|index| SymbolRelation {
8913                    path: path.to_string(),
8914                    source_name: path.to_string(),
8915                    target_name: if invalid {
8916                        format!("invalid\0module-{index}")
8917                    } else {
8918                        format!("module-{index}")
8919                    },
8920                    kind: RelationKind::Imports,
8921                    line: 1,
8922                    context: "import".to_string(),
8923                    parser: ParserKind::TreeSitter,
8924                })
8925                .collect(),
8926        };
8927        let control = IndexWorkControl::new(IndexCancellation::new(), None);
8928
8929        let (valid, valid_report) = super::admit_symbol_graph(
8930            Cow::Owned(graph("src/import-scale-valid.ts", false)),
8931            &control,
8932        )?;
8933        require_eq(
8934            &valid_report.paired_import_pairing_work,
8935            &PAIRING_WORK_ITEMS,
8936            "valid import pairing work",
8937        )?;
8938        require_eq(
8939            &valid.symbols.len(),
8940            &IMPORT_SYMBOLS,
8941            "valid import symbol count",
8942        )?;
8943        require_eq(
8944            &valid.relations.len(),
8945            &IMPORT_RELATIONS,
8946            "valid import relation count",
8947        )?;
8948        require_eq(
8949            &valid_report.rejected_facts_for("src/import-scale-valid.ts"),
8950            &0,
8951            "valid import rejection count",
8952        )?;
8953
8954        let invalid_graph = graph("src/import-scale-invalid.ts", true);
8955        let (invalid, mut invalid_report) =
8956            super::admit_symbol_graph(Cow::Owned(invalid_graph.clone()), &control)?;
8957        require_eq(
8958            &invalid_report.paired_import_pairing_work,
8959            &PAIRING_WORK_ITEMS,
8960            "invalid import pairing work",
8961        )?;
8962        require(
8963            invalid.symbols.is_empty() && invalid.relations.is_empty(),
8964            "invalid import facts survived admission",
8965        )?;
8966        require_eq(
8967            &invalid_report.rejected_facts_for("src/import-scale-invalid.ts"),
8968            &u64::try_from(IMPORT_RELATIONS)?,
8969            "invalid import rejection count",
8970        )?;
8971        require_eq(
8972            &invalid_report.rejections.len(),
8973            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
8974            "invalid import detail ceiling",
8975        )?;
8976
8977        let (replayed, replay_report) =
8978            super::admit_symbol_graph(Cow::Owned(invalid_graph), &control)?;
8979        require(
8980            replayed.symbols.is_empty() && replayed.relations.is_empty(),
8981            "replayed invalid import facts survived admission",
8982        )?;
8983        require_eq(
8984            &replay_report.paired_import_pairing_work,
8985            &PAIRING_WORK_ITEMS,
8986            "replayed import pairing work",
8987        )?;
8988        invalid_report.merge(replay_report, &control)?;
8989        require_eq(
8990            &invalid_report.paired_import_pairing_work,
8991            &(PAIRING_WORK_ITEMS * 2),
8992            "merged replay import pairing work",
8993        )?;
8994        require_eq(
8995            &invalid_report.rejected_facts_for("src/import-scale-invalid.ts"),
8996            &u64::try_from(IMPORT_RELATIONS)?,
8997            "replayed import rejection count",
8998        )?;
8999        require_eq(
9000            &invalid_report.rejections.len(),
9001            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9002            "replayed import detail ceiling",
9003        )?;
9004        Ok(())
9005    }
9006
9007    #[test]
9008    fn admission_counts_distinct_same_span_facts_but_deduplicates_replays()
9009    -> Result<(), Box<dyn Error>> {
9010        let mut admission = GraphIdentityAdmission::default();
9011        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9012        let span = super::IdentitySpan {
9013            start_line: 7,
9014            start_column: 0,
9015            end_line: 7,
9016            end_column: 0,
9017        };
9018        let failure = [(
9019            GraphIdentityField::RelationTarget,
9020            GraphIdentityRejectionReason::Empty,
9021        )];
9022        admission.record(
9023            "src/facts.ts",
9024            span,
9025            ParserKind::TreeSitter,
9026            super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
9027            &failure,
9028            &control,
9029        )?;
9030        admission.record(
9031            "src/facts.ts",
9032            span,
9033            ParserKind::TreeSitter,
9034            super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 2),
9035            &failure,
9036            &control,
9037        )?;
9038        admission.record(
9039            "src/facts.ts",
9040            span,
9041            ParserKind::TreeSitter,
9042            super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
9043            &failure,
9044            &control,
9045        )?;
9046        require_eq(
9047            &admission.rejected_facts_for("src/facts.ts"),
9048            &2,
9049            "same-span distinct rejection count",
9050        )?;
9051        require_eq(
9052            &admission.rejections.len(),
9053            &2,
9054            "same-span distinct typed detail count",
9055        )?;
9056        require(
9057            admission.rejections[0].fact_index != admission.rejections[1].fact_index,
9058            "same-span distinct facts shared an internal identity",
9059        )?;
9060        Ok(())
9061    }
9062
9063    #[test]
9064    fn rejection_membership_preserves_distinct_fields_across_replay_and_merge()
9065    -> Result<(), Box<dyn Error>> {
9066        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9067        let span = super::IdentitySpan {
9068            start_line: 7,
9069            start_column: 0,
9070            end_line: 7,
9071            end_column: 0,
9072        };
9073        let failures = [
9074            (
9075                GraphIdentityField::RelationSource,
9076                GraphIdentityRejectionReason::ControlCharacters,
9077            ),
9078            (
9079                GraphIdentityField::RelationTarget,
9080                GraphIdentityRejectionReason::ControlCharacters,
9081            ),
9082            (
9083                GraphIdentityField::Parent,
9084                GraphIdentityRejectionReason::ControlCharacters,
9085            ),
9086            (
9087                GraphIdentityField::Signature,
9088                GraphIdentityRejectionReason::ControlCharacters,
9089            ),
9090        ];
9091        let mut admission = GraphIdentityAdmission::default();
9092        admission.record(
9093            "src/multi-field.ts",
9094            span,
9095            ParserKind::TreeSitter,
9096            17,
9097            &failures,
9098            &control,
9099        )?;
9100        admission.record(
9101            "src/multi-field.ts",
9102            span,
9103            ParserKind::TreeSitter,
9104            17,
9105            &failures,
9106            &control,
9107        )?;
9108        require_eq(
9109            &admission.rejections.len(),
9110            &failures.len(),
9111            "same-fact replay collapsed distinct rejection fields",
9112        )?;
9113        for &(field, reason) in &failures {
9114            require(
9115                admission
9116                    .rejections
9117                    .iter()
9118                    .any(|rejection| rejection.field == field && rejection.reason == reason),
9119                "same-fact rejection field was lost",
9120            )?;
9121        }
9122
9123        let mut aggregated = Vec::new();
9124        let mut aggregated_keys = BTreeSet::new();
9125        super::extend_bounded_identity_rejections(
9126            &mut aggregated,
9127            &mut aggregated_keys,
9128            admission.rejections.iter().cloned(),
9129        )?;
9130        super::extend_bounded_identity_rejections(
9131            &mut aggregated,
9132            &mut aggregated_keys,
9133            admission.rejections,
9134        )?;
9135        require_eq(
9136            &aggregated.len(),
9137            &failures.len(),
9138            "bounded aggregation collapsed distinct rejection fields",
9139        )?;
9140        require_eq(
9141            &aggregated_keys.len(),
9142            &failures.len(),
9143            "bounded aggregation membership did not deduplicate replay",
9144        )?;
9145        Ok(())
9146    }
9147
9148    #[test]
9149    fn admission_merge_deduplicates_replayed_observed_facts() -> Result<(), Box<dyn Error>> {
9150        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9151        let span = super::IdentitySpan {
9152            start_line: 7,
9153            start_column: 0,
9154            end_line: 7,
9155            end_column: 0,
9156        };
9157        let failure = [(
9158            GraphIdentityField::RelationTarget,
9159            GraphIdentityRejectionReason::Empty,
9160        )];
9161        let mut first = GraphIdentityAdmission::default();
9162        for fact_index in 1..=2 {
9163            first.record(
9164                "src/merged.ts",
9165                span,
9166                ParserKind::TreeSitter,
9167                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
9168                &failure,
9169                &control,
9170            )?;
9171        }
9172        let mut replay = GraphIdentityAdmission::default();
9173        for fact_index in 1..=3 {
9174            replay.record(
9175                "src/merged.ts",
9176                span,
9177                ParserKind::TreeSitter,
9178                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
9179                &failure,
9180                &control,
9181            )?;
9182        }
9183        first.merge(replay, &control)?;
9184        require_eq(
9185            &first.rejected_facts_for("src/merged.ts"),
9186            &3,
9187            "merged replayed identity count",
9188        )?;
9189        require_eq(
9190            &first.rejections.len(),
9191            &3,
9192            "merged replayed typed detail count",
9193        )?;
9194        Ok(())
9195    }
9196
9197    #[test]
9198    fn admission_deduplicates_replayed_facts_after_detail_ceiling() -> Result<(), Box<dyn Error>> {
9199        let mut admission = GraphIdentityAdmission::default();
9200        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9201        let span = super::IdentitySpan {
9202            start_line: 7,
9203            start_column: 0,
9204            end_line: 7,
9205            end_column: 0,
9206        };
9207        let failure = [(
9208            GraphIdentityField::RelationTarget,
9209            GraphIdentityRejectionReason::Empty,
9210        )];
9211        for fact_index in 0..super::MAX_GRAPH_IDENTITY_REJECTIONS {
9212            admission.record(
9213                "src/capped.ts",
9214                span,
9215                ParserKind::TreeSitter,
9216                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
9217                &failure,
9218                &control,
9219            )?;
9220        }
9221        require_eq(
9222            &admission.rejected_facts_for("src/capped.ts"),
9223            &u64::try_from(super::MAX_GRAPH_IDENTITY_REJECTIONS)?,
9224            "capped distinct rejection count",
9225        )?;
9226        require_eq(
9227            &admission.rejections.len(),
9228            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9229            "capped typed detail count",
9230        )?;
9231        admission.record(
9232            "src/capped.ts",
9233            span,
9234            ParserKind::TreeSitter,
9235            super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 0),
9236            &failure,
9237            &control,
9238        )?;
9239        require_eq(
9240            &admission.rejected_facts_for("src/capped.ts"),
9241            &u64::try_from(super::MAX_GRAPH_IDENTITY_REJECTIONS)?,
9242            "replayed capped rejection count",
9243        )?;
9244        require_eq(
9245            &admission.rejections.len(),
9246            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9247            "replayed capped typed detail count",
9248        )?;
9249        admission.record(
9250            "src/capped.ts",
9251            span,
9252            ParserKind::TreeSitter,
9253            super::parser_fact_index(
9254                super::RELATION_FACT_INDEX_NAMESPACE,
9255                super::MAX_GRAPH_IDENTITY_REJECTIONS,
9256            ),
9257            &failure,
9258            &control,
9259        )?;
9260        require_eq(
9261            &admission.rejected_facts_for("src/capped.ts"),
9262            &u64::try_from(super::MAX_GRAPH_IDENTITY_REJECTIONS + 1)?,
9263            "new capped rejection count",
9264        )?;
9265        require_eq(
9266            &admission.rejections.len(),
9267            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9268            "new capped typed detail count",
9269        )?;
9270        let mut aggregated = Vec::new();
9271        let mut aggregated_keys = BTreeSet::new();
9272        super::extend_bounded_identity_rejections(
9273            &mut aggregated,
9274            &mut aggregated_keys,
9275            admission.rejections.iter().cloned(),
9276        )?;
9277        super::extend_bounded_identity_rejections(
9278            &mut aggregated,
9279            &mut aggregated_keys,
9280            admission.rejections,
9281        )?;
9282        require_eq(
9283            &aggregated.len(),
9284            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9285            "bounded aggregation changed the distinct detail cardinality",
9286        )?;
9287        require_eq(
9288            &aggregated_keys.len(),
9289            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9290            "bounded aggregation membership lost a distinct detail",
9291        )?;
9292        Ok(())
9293    }
9294
9295    #[test]
9296    fn identity_rejection_limit_marker_is_causal_and_path_scoped() -> Result<(), Box<dyn Error>> {
9297        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9298        let failure = [(
9299            GraphIdentityField::Symbol,
9300            GraphIdentityRejectionReason::Empty,
9301        )];
9302        let span = |line| super::IdentitySpan {
9303            start_line: line,
9304            start_column: 0,
9305            end_line: line,
9306            end_column: 0,
9307        };
9308        let mut admission = GraphIdentityAdmission::default();
9309        for fact_index in 0..super::MAX_GRAPH_IDENTITY_REJECTIONS {
9310            admission.record(
9311                "src/exact-cap.ts",
9312                span(fact_index + 1),
9313                ParserKind::TreeSitter,
9314                super::parser_fact_index(super::SYMBOL_FACT_INDEX_NAMESPACE, fact_index),
9315                &failure,
9316                &control,
9317            )?;
9318        }
9319        require(
9320            admission.rejection_details_dropped_by_path.is_empty(),
9321            "an exactly full retained detail set claimed an eviction",
9322        )?;
9323
9324        let graph = |path: &str, parser: ParserKind| SymbolGraph {
9325            path: path.to_string(),
9326            language: Some("test".to_string()),
9327            parser,
9328            symbols: Vec::new(),
9329            relations: Vec::new(),
9330        };
9331        let exact_cap = super::coverage_for_graph(
9332            &graph("src/exact-cap.ts", ParserKind::TreeSitter),
9333            IndexGeneration::new(1),
9334            &admission,
9335            &GraphIdentityAdmission::default(),
9336        )?;
9337        require_eq(
9338            &exact_cap.reached_limit(),
9339            &None,
9340            "exactly full retained details reported an eviction",
9341        )?;
9342
9343        admission.record(
9344            "src/exact-parser.ts",
9345            span(1),
9346            ParserKind::TreeSitter,
9347            super::parser_fact_index(super::SYMBOL_FACT_INDEX_NAMESPACE, 10_000),
9348            &failure,
9349            &control,
9350        )?;
9351        admission.record(
9352            "src/markdown-structural.md",
9353            span(1),
9354            ParserKind::Structural,
9355            super::parser_fact_index(super::MARKDOWN_FACT_INDEX_NAMESPACE, 0),
9356            &failure,
9357            &control,
9358        )?;
9359        require(
9360            admission
9361                .rejection_details_dropped_by_path
9362                .contains("src/exact-parser.ts")
9363                && admission
9364                    .rejection_details_dropped_by_path
9365                    .contains("src/markdown-structural.md"),
9366            "distinct exact and structural evictions were not marked by path",
9367        )?;
9368        for (path, parser) in [
9369            ("src/exact-parser.ts", ParserKind::TreeSitter),
9370            ("src/markdown-structural.md", ParserKind::Structural),
9371        ] {
9372            let coverage = super::coverage_for_graph(
9373                &graph(path, parser),
9374                IndexGeneration::new(1),
9375                &admission,
9376                &GraphIdentityAdmission::default(),
9377            )?;
9378            require_eq(
9379                &coverage.reached_limit(),
9380                &Some(GraphLimitKind::Rows),
9381                "causal identity eviction did not reach path coverage",
9382            )?;
9383        }
9384
9385        for parser in [ParserKind::Structural, ParserKind::Fallback] {
9386            let coverage = super::coverage_for_graph(
9387                &graph("src/baseline.rs", parser),
9388                IndexGeneration::new(1),
9389                &GraphIdentityAdmission::default(),
9390                &GraphIdentityAdmission::default(),
9391            )?;
9392            require_eq(
9393                &coverage.reached_limit(),
9394                &None,
9395                "parser baseline omission claimed identity-detail eviction",
9396            )?;
9397        }
9398        let unrelated = super::coverage_for_graph(
9399            &graph("src/unrelated.rs", ParserKind::TreeSitter),
9400            IndexGeneration::new(1),
9401            &admission,
9402            &GraphIdentityAdmission::default(),
9403        )?;
9404        require_eq(
9405            &unrelated.reached_limit(),
9406            &None,
9407            "an unrelated path inherited another path's identity eviction",
9408        )?;
9409        Ok(())
9410    }
9411
9412    #[test]
9413    fn document_text_gap_survives_graph_coverage_projection() -> Result<(), Box<dyn Error>> {
9414        for (limit, reached, reason) in [
9415            (
9416                projectatlas_symbols::DocumentLimit::OutputBytes,
9417                GraphLimitKind::OutputBytes,
9418                "resource_limit:output_bytes",
9419            ),
9420            (
9421                projectatlas_symbols::DocumentLimit::ParserWorkBytes,
9422                GraphLimitKind::IntermediateBytes,
9423                "resource_limit:parser_work_bytes",
9424            ),
9425        ] {
9426            let facts = projectatlas_symbols::DocumentFacts {
9427                format: projectatlas_symbols::DocumentFormat::Docx,
9428                text: String::new(),
9429                facts: Vec::new(),
9430                symbols: Vec::new(),
9431                completeness: projectatlas_symbols::DocumentCompleteness::Partial {
9432                    gaps: vec![projectatlas_symbols::DocumentCoverageGap::ResourceLimit(
9433                        limit,
9434                    )],
9435                },
9436                provenance: projectatlas_symbols::DocumentParserProvenance::QuickXml,
9437            };
9438            for language in [None, Some("DOCX")] {
9439                let graph = facts.symbol_graph("docs/limited.docx", language);
9440                require_eq(
9441                    &graph.language.as_deref(),
9442                    &Some("docx"),
9443                    "canonical language",
9444                )?;
9445                require_eq(
9446                    &super::navigable_symbol_count(&graph),
9447                    &0,
9448                    "coverage marker was counted",
9449                )?;
9450                let coverage = super::coverage_for_graph(
9451                    &graph,
9452                    IndexGeneration::new(1),
9453                    &GraphIdentityAdmission::default(),
9454                    &GraphIdentityAdmission::default(),
9455                )?;
9456                require_eq(
9457                    &coverage.state(),
9458                    &CoverageState::Failed,
9459                    "empty document gap was lost",
9460                )?;
9461                require_eq(
9462                    &coverage.reached_limit(),
9463                    &Some(reached),
9464                    "document ceiling was lost",
9465                )?;
9466                require(
9467                    coverage
9468                        .reason()
9469                        .is_some_and(|value| value.as_str().contains(reason)),
9470                    "document-local reason was lost",
9471                )?;
9472            }
9473        }
9474        for (limit, reached_limit, reason) in [
9475            (
9476                projectatlas_symbols::DocumentLimit::ExecutionFuel,
9477                None,
9478                "resource_limit:execution_fuel",
9479            ),
9480            (
9481                projectatlas_symbols::DocumentLimit::ExpandedBytes,
9482                Some(GraphLimitKind::IntermediateBytes),
9483                "resource_limit:expanded_bytes",
9484            ),
9485        ] {
9486            let facts = projectatlas_symbols::DocumentFacts {
9487                format: projectatlas_symbols::DocumentFormat::Pdf,
9488                text: String::new(),
9489                facts: Vec::new(),
9490                symbols: Vec::new(),
9491                completeness: projectatlas_symbols::DocumentCompleteness::Partial {
9492                    gaps: vec![projectatlas_symbols::DocumentCoverageGap::ResourceLimit(
9493                        limit,
9494                    )],
9495                },
9496                provenance: projectatlas_symbols::DocumentParserProvenance::PdfExtract,
9497            };
9498            let graph = facts.symbol_graph("docs/limited.pdf", Some("PDF"));
9499            let coverage = super::coverage_for_graph(
9500                &graph,
9501                IndexGeneration::new(1),
9502                &GraphIdentityAdmission::default(),
9503                &GraphIdentityAdmission::default(),
9504            )?;
9505            require_eq(&graph.language.as_deref(), &Some("pdf"), "PDF language")?;
9506            require_eq(
9507                &super::navigable_symbol_count(&graph),
9508                &0,
9509                "PDF coverage marker",
9510            )?;
9511            require_eq(
9512                &coverage.state(),
9513                &CoverageState::Failed,
9514                "PDF incomplete state",
9515            )?;
9516            require_eq(
9517                &coverage.reached_limit(),
9518                &reached_limit,
9519                "PDF resource ceiling",
9520            )?;
9521            require(
9522                coverage
9523                    .reason()
9524                    .is_some_and(|value| value.as_str().contains(reason)),
9525                "PDF resource reason was lost",
9526            )?;
9527        }
9528        Ok(())
9529    }
9530
9531    #[test]
9532    fn document_coverage_marker_preserves_entities_and_accounts_ordinal_metadata()
9533    -> Result<(), Box<dyn Error>> {
9534        let facts = projectatlas_symbols::DocumentFacts {
9535            format: projectatlas_symbols::DocumentFormat::Docx,
9536            text: "Hello".to_owned(),
9537            facts: vec![projectatlas_symbols::DocumentFact {
9538                text: "Hello".to_owned(),
9539                locator: projectatlas_symbols::DocumentLocator::Docx {
9540                    part: "word/document.xml".to_owned(),
9541                    paragraph: 1,
9542                    run: 1,
9543                    text_start: 0,
9544                    text_end: 5,
9545                },
9546                line_start: 1,
9547                line_end: 1,
9548            }],
9549            symbols: Vec::new(),
9550            completeness: projectatlas_symbols::DocumentCompleteness::Partial {
9551                gaps: vec![projectatlas_symbols::DocumentCoverageGap::UnexaminedStory],
9552            },
9553            provenance: projectatlas_symbols::DocumentParserProvenance::QuickXml,
9554        };
9555        let graph = facts.symbol_graph("guide.docx", None);
9556        let mut without_marker = graph.clone();
9557        without_marker.symbols.pop();
9558        let project = ProjectInstanceId::from_bytes([33; 16])?;
9559        let generation = IndexGeneration::new(1);
9560        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9561        let packages = PackageIndex::from_graphs(std::slice::from_ref(&graph))?;
9562        let baseline = build_entity_projection_with_config_limit(
9563            project,
9564            generation,
9565            &[],
9566            std::slice::from_ref(&without_marker),
9567            &packages,
9568            &ConfiguredModuleResolution::default(),
9569            None,
9570            false,
9571            &control,
9572            super::super::MAX_PUBLICATION_STAGING_BYTES,
9573        )?;
9574        let with_marker = build_entity_projection_with_config_limit(
9575            project,
9576            generation,
9577            &[],
9578            std::slice::from_ref(&graph),
9579            &packages,
9580            &ConfiguredModuleResolution::default(),
9581            None,
9582            false,
9583            &control,
9584            super::super::MAX_PUBLICATION_STAGING_BYTES,
9585        )?;
9586        require_eq(
9587            &with_marker.entity_by_digest,
9588            &baseline.entity_by_digest,
9589            "coverage marker created an entity",
9590        )?;
9591        require_eq(
9592            &with_marker.entity_exports,
9593            &baseline.entity_exports,
9594            "coverage marker created exported keys",
9595        )?;
9596        require(
9597            with_marker.retained_bytes > baseline.retained_bytes,
9598            "coverage marker's retained ordinal metadata was omitted",
9599        )?;
9600        build_entity_projection_with_config_limit(
9601            project,
9602            generation,
9603            &[],
9604            std::slice::from_ref(&graph),
9605            &packages,
9606            &ConfiguredModuleResolution::default(),
9607            None,
9608            false,
9609            &control,
9610            with_marker.peak_retained_bytes,
9611        )?;
9612        let mut stage = symbol_build_stage_for_graphs(vec![graph.clone()]);
9613        super::admit_symbol_build_stage(&mut stage, &control)?;
9614        require_eq(&stage.report.symbols, &1, "coverage marker inflated report")?;
9615        let coverage = coverage_for_graph(
9616            &graph,
9617            generation,
9618            &GraphIdentityAdmission::default(),
9619            &GraphIdentityAdmission::default(),
9620        )?;
9621        require_eq(
9622            &coverage.state(),
9623            &CoverageState::Partial,
9624            "coverage was lost",
9625        )?;
9626        Ok(())
9627    }
9628
9629    #[test]
9630    fn admission_merges_many_graphs_with_incremental_retained_bytes() -> Result<(), Box<dyn Error>>
9631    {
9632        const GRAPH_COUNT: usize = 1_000;
9633        const FACTS_PER_GRAPH: usize = 10;
9634        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9635        let span = super::IdentitySpan {
9636            start_line: 7,
9637            start_column: 0,
9638            end_line: 7,
9639            end_column: 0,
9640        };
9641        let failure = [(
9642            GraphIdentityField::RelationTarget,
9643            GraphIdentityRejectionReason::Empty,
9644        )];
9645        let mut admission = GraphIdentityAdmission::default();
9646        let mut expected_bytes = 0_u64;
9647        for graph_index in 0..GRAPH_COUNT {
9648            let path = format!("src/many-graphs-{graph_index}.ts");
9649            let mut graph = GraphIdentityAdmission::default();
9650            for fact_index in 0..FACTS_PER_GRAPH {
9651                graph.record(
9652                    &path,
9653                    span,
9654                    ParserKind::TreeSitter,
9655                    super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
9656                    &failure,
9657                    &control,
9658                )?;
9659            }
9660            admission.merge(graph, &control)?;
9661            let key_bytes = super::identity_rejection_key_retained_bytes(&path)?
9662                .checked_mul(u64::try_from(FACTS_PER_GRAPH)?)
9663                .ok_or_else(|| io::Error::other("many-graph identity bytes overflowed"))?;
9664            let graph_bytes = super::identity_observed_path_retained_bytes(&path, FACTS_PER_GRAPH)?
9665                .checked_add(super::identity_count_path_retained_bytes(&path)?)
9666                .and_then(|bytes| bytes.checked_add(key_bytes))
9667                .ok_or_else(|| io::Error::other("many-graph identity bytes overflowed"))?;
9668            expected_bytes = expected_bytes
9669                .checked_add(graph_bytes)
9670                .ok_or_else(|| io::Error::other("many-graph identity bytes overflowed"))?;
9671            require_eq(
9672                &admission.observed_fact_bytes,
9673                &expected_bytes,
9674                "incremental identity byte cache",
9675            )?;
9676        }
9677        require_eq(
9678            &admission.rejections.len(),
9679            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9680            "many-graph retained detail ceiling",
9681        )?;
9682        require_eq(
9683            &admission.rejection_keys.len(),
9684            &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9685            "many-graph keyed detail ceiling",
9686        )?;
9687        require_eq(
9688            &super::identity_admission_retained_bytes(&admission),
9689            &expected_bytes,
9690            "incremental identity byte cache remained authoritative",
9691        )?;
9692
9693        let replay_path = "src/many-graphs-999.ts";
9694        let mut replay = GraphIdentityAdmission::default();
9695        for fact_index in 0..FACTS_PER_GRAPH {
9696            replay.record(
9697                replay_path,
9698                span,
9699                ParserKind::TreeSitter,
9700                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
9701                &failure,
9702                &control,
9703            )?;
9704        }
9705        admission.merge(replay, &control)?;
9706        require_eq(
9707            &admission.observed_fact_bytes,
9708            &expected_bytes,
9709            "replayed graph did not inflate identity byte cache",
9710        )?;
9711        Ok(())
9712    }
9713
9714    #[test]
9715    fn observed_identity_facts_charge_budget_at_minus_equal_plus_boundaries()
9716    -> Result<(), Box<dyn Error>> {
9717        let path = "src/budget.ts";
9718        let span = super::IdentitySpan {
9719            start_line: 7,
9720            start_column: 0,
9721            end_line: 7,
9722            end_column: 0,
9723        };
9724        let failure = [(
9725            GraphIdentityField::RelationTarget,
9726            GraphIdentityRejectionReason::Empty,
9727        )];
9728        let additional = super::identity_fact_retained_bytes(path, true, true)?
9729            .checked_add(super::identity_rejection_key_retained_bytes(path)?)
9730            .ok_or_else(|| io::Error::other("identity budget fixture overflowed"))?;
9731        let next_fact_additional = super::STAGED_GRAPH_ROW_BYTES
9732            .checked_add(super::identity_rejection_key_retained_bytes(path)?)
9733            .ok_or_else(|| io::Error::other("identity budget fixture overflowed"))?;
9734
9735        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9736        let mut below = GraphIdentityAdmission {
9737            observed_fact_bytes: MAX_IN_MEMORY_GRAPH_WORK_BYTES - additional + 1,
9738            ..GraphIdentityAdmission::default()
9739        };
9740        let error = below
9741            .record(
9742                path,
9743                span,
9744                ParserKind::TreeSitter,
9745                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
9746                &failure,
9747                &control,
9748            )
9749            .err()
9750            .ok_or_else(|| io::Error::other("one byte over the identity budget was retained"))?;
9751        require(
9752            matches!(
9753                error,
9754                CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
9755                    stage: IndexWorkStage::SymbolParsing,
9756                    resource: IndexWorkResource::OutputBytes,
9757                    limit: MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9758                    observed,
9759                }) if observed == MAX_IN_MEMORY_GRAPH_WORK_BYTES + 1
9760            ),
9761            "identity budget overflow did not return its typed refusal",
9762        )?;
9763        require(
9764            below.observed_facts.is_empty() && below.rejected_facts_by_path.is_empty(),
9765            "identity budget refusal retained partial state",
9766        )?;
9767
9768        let mut equal = GraphIdentityAdmission {
9769            observed_fact_bytes: MAX_IN_MEMORY_GRAPH_WORK_BYTES - additional,
9770            ..GraphIdentityAdmission::default()
9771        };
9772        equal.record(
9773            path,
9774            span,
9775            ParserKind::TreeSitter,
9776            super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
9777            &failure,
9778            &control,
9779        )?;
9780        require_eq(
9781            &equal.observed_fact_bytes,
9782            &MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9783            "identity budget exact boundary",
9784        )?;
9785        require_eq(
9786            &equal.rejected_facts_for(path),
9787            &1,
9788            "identity budget exact boundary count",
9789        )?;
9790
9791        let error = equal
9792            .record(
9793                path,
9794                span,
9795                ParserKind::TreeSitter,
9796                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 2),
9797                &failure,
9798                &control,
9799            )
9800            .err()
9801            .ok_or_else(|| io::Error::other("one additional identity fact exceeded the budget"))?;
9802        require(
9803            matches!(
9804                error,
9805                CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
9806                    stage: IndexWorkStage::SymbolParsing,
9807                    resource: IndexWorkResource::OutputBytes,
9808                    limit: MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9809                    observed,
9810                }) if observed == MAX_IN_MEMORY_GRAPH_WORK_BYTES + next_fact_additional
9811            ),
9812            "identity budget plus boundary did not return its typed refusal",
9813        )?;
9814        require_eq(
9815            &equal.rejected_facts_for(path),
9816            &1,
9817            "identity plus boundary changed retained count",
9818        )?;
9819        Ok(())
9820    }
9821
9822    #[test]
9823    fn observed_identity_facts_honor_cancellation_before_retention() -> Result<(), Box<dyn Error>> {
9824        let cancellation = IndexCancellation::new();
9825        let control = IndexWorkControl::new(cancellation.clone(), None);
9826        cancellation.cancel();
9827        let mut admission = GraphIdentityAdmission::default();
9828        let error = admission
9829            .record(
9830                "src/canceled.ts",
9831                super::IdentitySpan {
9832                    start_line: 1,
9833                    start_column: 0,
9834                    end_line: 1,
9835                    end_column: 0,
9836                },
9837                ParserKind::TreeSitter,
9838                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 0),
9839                &[(
9840                    GraphIdentityField::RelationTarget,
9841                    GraphIdentityRejectionReason::Empty,
9842                )],
9843                &control,
9844            )
9845            .err()
9846            .ok_or_else(|| io::Error::other("canceled identity admission retained a fact"))?;
9847        require(
9848            matches!(
9849                error,
9850                CliError::IndexWork(IndexWorkFailure::Cancelled {
9851                    stage: IndexWorkStage::SymbolParsing
9852                })
9853            ),
9854            "identity admission cancellation was not typed",
9855        )?;
9856        require(
9857            admission.observed_facts.is_empty()
9858                && admission.rejected_facts_by_path.is_empty()
9859                && admission.observed_fact_bytes == 0,
9860            "canceled identity admission retained partial state",
9861        )?;
9862        Ok(())
9863    }
9864
9865    #[test]
9866    fn reused_reconciliation_paths_charge_all_maps_at_injected_boundaries()
9867    -> Result<(), Box<dyn Error>> {
9868        let mut report = GraphIdentityAdmission::default();
9869        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9870        for index in 0..32 {
9871            let path = format!("src/reused-{index}.ts");
9872            for _ in 0..4 {
9873                report.reserve_reused_path_bytes(
9874                    &path,
9875                    false,
9876                    &control,
9877                    MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9878                )?;
9879            }
9880            report.reused_rejection_counts.insert(path.clone(), 2);
9881            report
9882                .reused_parser_rejection_counts
9883                .insert(path.clone(), 1);
9884            report
9885                .reused_rejection_detail_counts
9886                .insert(path.clone(), 0);
9887            report.reused_rejection_details_incomplete.insert(path);
9888        }
9889        let retained = super::identity_admission_retained_bytes(&report);
9890        require(
9891            retained > 0,
9892            "reused reconciliation bytes were not retained",
9893        )?;
9894
9895        let below = retained - 1;
9896        let error = super::checked_identity_admission_budget(&report, &control, below)
9897            .err()
9898            .ok_or_else(|| io::Error::other("below-limit reused paths were accepted"))?;
9899        require(
9900            matches!(
9901                error,
9902                CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
9903                    stage: IndexWorkStage::SymbolParsing,
9904                    resource: IndexWorkResource::OutputBytes,
9905                    limit,
9906                    observed,
9907                }) if limit == below && observed == retained
9908            ),
9909            "below-limit reused paths did not return their typed refusal",
9910        )?;
9911        require_eq(
9912            &super::checked_identity_admission_budget(&report, &control, retained)?,
9913            &retained,
9914            "equal-limit reused paths",
9915        )?;
9916        require_eq(
9917            &super::checked_identity_admission_budget(&report, &control, retained + 1)?,
9918            &retained,
9919            "above-limit reused paths",
9920        )?;
9921        Ok(())
9922    }
9923
9924    #[test]
9925    fn reused_reconciliation_retention_honors_cancellation_before_insertion()
9926    -> Result<(), Box<dyn Error>> {
9927        let cancellation = IndexCancellation::new();
9928        let control = IndexWorkControl::new(cancellation.clone(), None);
9929        cancellation.cancel();
9930        let mut report = GraphIdentityAdmission::default();
9931        let error = report
9932            .reserve_reused_path_bytes(
9933                "src/canceled-reuse.ts",
9934                false,
9935                &control,
9936                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9937            )
9938            .err()
9939            .ok_or_else(|| io::Error::other("canceled reused path was retained"))?;
9940        require(
9941            matches!(
9942                error,
9943                CliError::IndexWork(IndexWorkFailure::Cancelled {
9944                    stage: IndexWorkStage::SymbolParsing
9945                })
9946            ),
9947            "reused path cancellation was not typed",
9948        )?;
9949        require(
9950            report.reused_rejection_counts.is_empty()
9951                && report.reused_parser_rejection_counts.is_empty()
9952                && report.reused_rejection_detail_counts.is_empty()
9953                && report.reused_rejection_details_incomplete.is_empty()
9954                && report.observed_fact_bytes == 0,
9955            "canceled reused path retained reconciliation state",
9956        )?;
9957
9958        let mut incoming = GraphIdentityAdmission::default();
9959        incoming
9960            .reused_rejection_counts
9961            .insert("src/incoming.ts".to_string(), 1);
9962        let error = report
9963            .merge(incoming, &control)
9964            .err()
9965            .ok_or_else(|| io::Error::other("canceled merge retained reconciliation state"))?;
9966        require(
9967            matches!(
9968                error,
9969                CliError::IndexWork(IndexWorkFailure::Cancelled {
9970                    stage: IndexWorkStage::SymbolParsing
9971                })
9972            ),
9973            "merge cancellation was not typed",
9974        )?;
9975        require(
9976            report.reused_rejection_counts.is_empty() && report.observed_fact_bytes == 0,
9977            "canceled merge retained reconciliation state",
9978        )?;
9979        Ok(())
9980    }
9981
9982    #[test]
9983    fn reused_identity_counts_preserve_new_and_removed_derived_outcomes()
9984    -> Result<(), Box<dyn Error>> {
9985        let path = "src/reused.ts";
9986        let control = IndexWorkControl::new(IndexCancellation::new(), None);
9987        let failure = [(
9988            GraphIdentityField::ResolutionKey,
9989            GraphIdentityRejectionReason::Oversized,
9990        )];
9991        let mut admission = GraphIdentityAdmission::default();
9992        for _ in 0..2 {
9993            admission.reserve_reused_path_bytes(
9994                path,
9995                false,
9996                &control,
9997                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9998            )?;
9999        }
10000        admission
10001            .reused_rejection_counts
10002            .insert(path.to_string(), 3);
10003        admission
10004            .reused_rejection_detail_counts
10005            .insert(path.to_string(), 2);
10006        for fact_index in 0..2 {
10007            admission.record(
10008                path,
10009                super::IdentitySpan {
10010                    start_line: fact_index + 1,
10011                    start_column: 0,
10012                    end_line: fact_index + 1,
10013                    end_column: 0,
10014                },
10015                ParserKind::TreeSitter,
10016                fact_index as u64,
10017                &failure,
10018                &control,
10019            )?;
10020        }
10021        let persisted_facts = admission
10022            .observed_facts
10023            .get(path)
10024            .cloned()
10025            .ok_or("persisted identity fact keys are missing")?;
10026        admission.reserve_identity_bytes(
10027            super::identity_fact_set_retained_bytes(path, &persisted_facts)?,
10028            &control,
10029            MAX_IN_MEMORY_GRAPH_WORK_BYTES,
10030        )?;
10031        admission
10032            .reused_rejection_facts
10033            .insert(path.to_string(), persisted_facts);
10034        require_eq(
10035            &admission.rejected_facts_for_graph(path, &GraphIdentityAdmission::default())?,
10036            &3,
10037            "reused unchanged rejection count",
10038        )?;
10039
10040        let mut derived = GraphIdentityAdmission::default();
10041        derived.record(
10042            path,
10043            super::IdentitySpan {
10044                start_line: 3,
10045                start_column: 0,
10046                end_line: 3,
10047                end_column: 0,
10048            },
10049            ParserKind::TreeSitter,
10050            2,
10051            &failure,
10052            &control,
10053        )?;
10054        require_eq(
10055            &admission.rejected_facts_for_graph(path, &derived)?,
10056            &4,
10057            "reused genuinely new rejection count",
10058        )?;
10059
10060        let mut removed = GraphIdentityAdmission::default();
10061        for _ in 0..2 {
10062            removed.reserve_reused_path_bytes(
10063                path,
10064                false,
10065                &control,
10066                MAX_IN_MEMORY_GRAPH_WORK_BYTES,
10067            )?;
10068        }
10069        removed.reused_rejection_counts.insert(path.to_string(), 2);
10070        removed
10071            .reused_rejection_detail_counts
10072            .insert(path.to_string(), 2);
10073        removed.reused_rejection_facts.insert(
10074            path.to_string(),
10075            admission
10076                .reused_rejection_facts
10077                .get(path)
10078                .cloned()
10079                .ok_or("persisted identity fact keys are missing")?,
10080        );
10081        let persisted_facts = removed
10082            .reused_rejection_facts
10083            .get(path)
10084            .cloned()
10085            .ok_or("persisted identity fact keys are missing")?;
10086        removed.reserve_identity_bytes(
10087            super::identity_fact_set_retained_bytes(path, &persisted_facts)?,
10088            &control,
10089            MAX_IN_MEMORY_GRAPH_WORK_BYTES,
10090        )?;
10091        removed.record(
10092            path,
10093            super::IdentitySpan {
10094                start_line: 1,
10095                start_column: 0,
10096                end_line: 1,
10097                end_column: 0,
10098            },
10099            ParserKind::TreeSitter,
10100            0,
10101            &failure,
10102            &control,
10103        )?;
10104        require_eq(
10105            &removed.rejected_facts_for_graph(path, &GraphIdentityAdmission::default())?,
10106            &1,
10107            "reused removed rejection count",
10108        )?;
10109        require_eq(
10110            &removed.rejected_facts_for_graph(path, &derived)?,
10111            &2,
10112            "reused removed plus new rejection count",
10113        )?;
10114        Ok(())
10115    }
10116
10117    #[test]
10118    fn relation_rejections_keep_original_parser_ordinals_through_reopen_and_retry()
10119    -> Result<(), Box<dyn Error>> {
10120        let temp = tempfile::tempdir()?;
10121        let root = fs::canonicalize(temp.path())?;
10122        fs::create_dir_all(root.join(".projectatlas"))?;
10123        let database = root.join(".projectatlas/projectatlas.db");
10124        let components = (0..20)
10125            .map(|_| "d".repeat(200))
10126            .collect::<Vec<_>>()
10127            .join("/");
10128        let path = format!("src/{components}/page.ts");
10129        let invalid_target = "x".repeat(MAX_GRAPH_IDENTITY_BYTES + 1);
10130        let oversized_resolved_module = "m".repeat(100);
10131        let graph = SymbolGraph {
10132            path: path.clone(),
10133            language: Some("typescript".to_string()),
10134            parser: ParserKind::TreeSitter,
10135            symbols: vec![CodeSymbol {
10136                path: path.clone(),
10137                language: Some("typescript".to_string()),
10138                name: "caller".to_string(),
10139                kind: SymbolKind::Function,
10140                signature: "function caller()".to_string(),
10141                exported: true,
10142                documentation: None,
10143                line_start: 1,
10144                line_end: 1,
10145                source_selector: None,
10146                parent: None,
10147                parser: ParserKind::TreeSitter,
10148                detail: None,
10149            }],
10150            relations: vec![
10151                SymbolRelation {
10152                    path: path.clone(),
10153                    source_name: "caller".to_string(),
10154                    target_name: invalid_target,
10155                    kind: RelationKind::Calls,
10156                    line: 1,
10157                    context: "caller()".to_string(),
10158                    parser: ParserKind::TreeSitter,
10159                },
10160                SymbolRelation {
10161                    path: path.clone(),
10162                    source_name: "<module>".to_string(),
10163                    target_name: format!("import {{ run }} from './{oversized_resolved_module}';"),
10164                    kind: RelationKind::Imports,
10165                    line: 1,
10166                    context: "relation ordinal fixture".to_string(),
10167                    parser: ParserKind::TreeSitter,
10168                },
10169            ],
10170        };
10171        let nodes = vec![test_file_node(&path, "typescript")];
10172        let mut store = AtlasStore::open_for_project(&database, &root)?;
10173        let control = IndexWorkControl::new(IndexCancellation::new(), None);
10174        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
10175        let symbols = symbol_build_stage_for_graphs(vec![graph.clone()]);
10176        let staged = stage_full_repository_graph(
10177            &store,
10178            &root,
10179            IndexGeneration::ZERO,
10180            &nodes,
10181            &scan_policy,
10182            &symbols,
10183            &control,
10184        )?;
10185        let relation_rejections = staged
10186            .identity_rejections
10187            .iter()
10188            .filter(|row| {
10189                matches!(
10190                    row.field,
10191                    GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
10192                )
10193            })
10194            .collect::<Vec<_>>();
10195        require_eq(
10196            &relation_rejections.len(),
10197            &2,
10198            "same-line relation rejection detail count",
10199        )?;
10200        require(
10201            relation_rejections.iter().all(|row| {
10202                row.path.as_str() == path
10203                    && row.span.start_line() == 1
10204                    && row.span.end_line() == 1
10205                    && row.parser == ParserKind::TreeSitter
10206                    && row.reason == GraphIdentityRejectionReason::Oversized
10207            }),
10208            "same-line relation rejection provenance was not exact",
10209        )?;
10210        let mut fact_indices = relation_rejections
10211            .iter()
10212            .map(|row| row.fact_index)
10213            .collect::<Vec<_>>();
10214        fact_indices.sort_unstable();
10215        require_eq(
10216            &fact_indices,
10217            &vec![
10218                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 0),
10219                super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
10220            ],
10221            "same-line relation parser ordinals",
10222        )?;
10223        require_eq(
10224            &staged.relations.len(),
10225            &1,
10226            "valid relation retained after source admission",
10227        )?;
10228        publish_full_staged_graph(
10229            &mut store,
10230            &nodes,
10231            &staged,
10232            &control,
10233            "relation-ordinal-full",
10234        )?;
10235        let first_generation = store
10236            .index_publication()?
10237            .ok_or("relation ordinal full publication is missing")?
10238            .generation;
10239        let path_key = RepositoryNodePath::new(Path::new(&path))?;
10240        let persisted = store.repository_graph_identity_rejections(
10241            store
10242                .project_instance_id()?
10243                .ok_or("relation ordinal project identity is missing")?,
10244            std::slice::from_ref(&path_key),
10245            16,
10246            None,
10247        )?;
10248        let persisted_fact_indices = persisted
10249            .iter()
10250            .filter(|row| {
10251                matches!(
10252                    row.field,
10253                    GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
10254                )
10255            })
10256            .map(|row| row.fact_index)
10257            .collect::<Vec<_>>();
10258        require_eq(
10259            &persisted_fact_indices,
10260            &fact_indices,
10261            "persisted same-line relation parser ordinals",
10262        )?;
10263        let persisted_wire = serde_json::to_string(&persisted)?;
10264        require(
10265            !persisted_wire.contains(&"x".repeat(32)),
10266            "persisted relation rejection retained invalid identity text",
10267        )?;
10268        drop(store);
10269
10270        let mut store = AtlasStore::open_for_project(&database, &root)?;
10271        let reopened = store.repository_graph_identity_rejections(
10272            store
10273                .project_instance_id()?
10274                .ok_or("reopened relation ordinal project identity is missing")?,
10275            std::slice::from_ref(&path_key),
10276            16,
10277            None,
10278        )?;
10279        require_eq(
10280            &reopened
10281                .iter()
10282                .filter(|row| {
10283                    matches!(
10284                        row.field,
10285                        GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
10286                    )
10287                })
10288                .map(|row| row.fact_index)
10289                .collect::<Vec<_>>(),
10290            &fact_indices,
10291            "reopened same-line relation parser ordinals",
10292        )?;
10293
10294        let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
10295        let incremental_policy =
10296            RootScanPolicy::discover(&root, &ScanOptions::default(), &incremental_control)?;
10297        let fault_graph = graph.clone();
10298        let incremental_symbols = symbol_build_stage_for_graphs(vec![graph]);
10299        let incremental = stage_incremental_repository_graph(
10300            &store,
10301            &root,
10302            first_generation,
10303            &nodes,
10304            std::slice::from_ref(&path),
10305            &incremental_policy,
10306            &incremental_symbols,
10307            &incremental_control,
10308        )?;
10309        let incremental_rejections = incremental
10310            .identity_rejections
10311            .iter()
10312            .filter(|row| {
10313                matches!(
10314                    row.field,
10315                    GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
10316                )
10317            })
10318            .map(|row| row.fact_index)
10319            .collect::<Vec<_>>();
10320        require_eq(
10321            &incremental_rejections,
10322            &fact_indices,
10323            "incremental same-line relation parser ordinals",
10324        )?;
10325        let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
10326        canceled.cancel();
10327        {
10328            let mut publication = store.begin_index_publication("relation-ordinal-cancel")?;
10329            require(
10330                incremental.apply(&mut publication, &canceled).is_err(),
10331                "relation ordinal cancellation did not fail publication",
10332            )?;
10333        }
10334        require_eq(
10335            &store
10336                .index_publication()?
10337                .map(|publication| publication.generation),
10338            &Some(first_generation),
10339            "generation after canceled relation ordinal publication",
10340        )?;
10341        {
10342            let mut publication = store.begin_index_publication("relation-ordinal-incremental")?;
10343            incremental.apply(&mut publication, &incremental_control)?;
10344            publication.complete()?;
10345        }
10346        let second_generation = store
10347            .index_publication()?
10348            .ok_or("relation ordinal incremental publication is missing")?
10349            .generation;
10350        drop(store);
10351        let mut store = AtlasStore::open_for_project(&database, &root)?;
10352        let reopened_incremental = store.repository_graph_identity_rejections(
10353            store
10354                .project_instance_id()?
10355                .ok_or("reopened incremental project identity is missing")?,
10356            std::slice::from_ref(&path_key),
10357            16,
10358            None,
10359        )?;
10360        require(
10361            reopened_incremental.iter().any(|row| {
10362                matches!(
10363                    row.field,
10364                    GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
10365                )
10366            }),
10367            "reopened incremental relation rejection is missing",
10368        )?;
10369        let mut fault = stage_incremental_repository_graph(
10370            &store,
10371            &root,
10372            second_generation,
10373            &nodes,
10374            std::slice::from_ref(&path),
10375            &incremental_policy,
10376            &symbol_build_stage_for_graphs(vec![fault_graph]),
10377            &incremental_control,
10378        )?;
10379        fault.identity_rejections.resize(
10380            usize::try_from(GraphLimits::MAX_ROWS)
10381                .unwrap_or(usize::MAX)
10382                .saturating_add(1),
10383            fault.identity_rejections[0].clone(),
10384        );
10385        {
10386            let mut publication = store.begin_index_publication("relation-ordinal-fault")?;
10387            require(
10388                fault.apply(&mut publication, &incremental_control).is_err(),
10389                "relation ordinal late fault did not fail publication",
10390            )?;
10391        }
10392        require_eq(
10393            &store
10394                .index_publication()?
10395                .map(|publication| publication.generation),
10396            &Some(second_generation),
10397            "generation after relation ordinal late fault",
10398        )?;
10399        Ok(())
10400    }
10401
10402    #[test]
10403    fn sqlite_recovered_invalid_manifest_context_uses_shared_admission()
10404    -> Result<(), Box<dyn Error>> {
10405        let temp = tempfile::tempdir()?;
10406        let root = fs::canonicalize(temp.path())?;
10407        let database = root.join(".projectatlas/projectatlas.db");
10408        fs::create_dir_all(root.join(".projectatlas"))?;
10409        let invalid_manifest = package_graph("Cargo.toml", "bad\0package");
10410        let direct_graph = function_graph("src/lib.rs", 1);
10411        let mut store = AtlasStore::open_for_project(&database, &root)?;
10412        store.replace_symbol_graph(&invalid_manifest)?;
10413        store.replace_symbol_graph(&direct_graph)?;
10414        drop(store);
10415
10416        let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
10417        let recovered = reopened
10418            .load_symbol_graphs_for_paths(&["Cargo.toml".to_string(), "src/lib.rs".to_string()])?;
10419        let control = IndexWorkControl::new(IndexCancellation::new(), None);
10420        let (admitted, report) =
10421            super::admit_symbol_graphs(recovered.into_iter().map(Cow::Owned).collect(), &control)?;
10422        require_eq(
10423            &report.rejected_facts_for("Cargo.toml"),
10424            &1,
10425            "recovered invalid manifest rejection count",
10426        )?;
10427        let manifest = admitted
10428            .iter()
10429            .find(|graph| graph.as_ref().path == "Cargo.toml")
10430            .ok_or("recovered manifest graph is missing")?;
10431        require(
10432            manifest.as_ref().symbols.is_empty(),
10433            "invalid recovered package identity reached package context",
10434        )?;
10435        let admitted_graphs = admitted.iter().map(SourceGraph::graph).collect::<Vec<_>>();
10436        let packages = PackageIndex::from_graphs(&admitted_graphs)?;
10437        require_eq(
10438            &packages.package_name("src/lib.rs"),
10439            &None,
10440            "invalid recovered manifest silently supplied package ownership",
10441        )?;
10442        Ok(())
10443    }
10444
10445    /// Downgrade a current fixture to the released schema-19 worktree shape.
10446    fn drop_native_worktree_identity_schema(connection: &Connection) -> rusqlite::Result<()> {
10447        connection.execute_batch(
10448            "DROP INDEX IF EXISTS idx_worktree_registrations_active_native_administrative_directory;
10449             DROP INDEX IF EXISTS idx_worktree_registrations_active_native_root;
10450             ALTER TABLE worktree_registrations DROP COLUMN git_common_directory_identity;
10451             ALTER TABLE worktree_registrations DROP COLUMN git_administrative_directory_identity;
10452             ALTER TABLE worktree_registrations DROP COLUMN last_root_identity;",
10453        )
10454    }
10455    #[cfg(unix)]
10456    fn create_directory_link(target: &Path, link: &Path) -> io::Result<()> {
10457        std::os::unix::fs::symlink(target, link)
10458    }
10459
10460    #[cfg(windows)]
10461    fn create_directory_link(target: &Path, link: &Path) -> io::Result<()> {
10462        match std::os::windows::fs::symlink_dir(target, link) {
10463            Ok(()) => Ok(()),
10464            Err(source) if source.raw_os_error() == Some(1314) => {
10465                let status = std::process::Command::new("cmd")
10466                    .arg("/C")
10467                    .arg("mklink")
10468                    .arg("/J")
10469                    .arg(link)
10470                    .arg(target)
10471                    .status()?;
10472                if status.success() {
10473                    Ok(())
10474                } else {
10475                    Err(source)
10476                }
10477            }
10478            Err(source) => Err(source),
10479        }
10480    }
10481
10482    #[cfg(unix)]
10483    fn create_file_link(target: &Path, link: &Path) -> io::Result<()> {
10484        std::os::unix::fs::symlink(target, link)
10485    }
10486
10487    #[cfg(windows)]
10488    fn create_file_link(target: &Path, link: &Path) -> io::Result<()> {
10489        std::os::windows::fs::symlink_file(target, link)
10490    }
10491
10492    #[test]
10493    fn cargo_lock_dependencies_do_not_own_repository_sources() -> Result<(), Box<dyn Error>> {
10494        let manifest = extract_symbol_graph(
10495            "Cargo.toml",
10496            Some("cargo-manifest"),
10497            "[package]\nname='root-package'\nversion='0.1.0'\nedition='2024'\n",
10498        );
10499        let lock = extract_symbol_graph(
10500            "Cargo.lock",
10501            Some("cargo-lock"),
10502            "version=4\n[[package]]\nname='aaa-dependency'\nversion='1.0.0'\n[[package]]\nname='root-package'\nversion='0.1.0'\n",
10503        );
10504        let packages = PackageIndex::from_graphs(&[manifest, lock.clone()])?;
10505        for path in ["src/lib.rs", "src/controller.ts", "src/module.py"] {
10506            require_eq(
10507                &packages.package_name(path),
10508                &Some("root-package"),
10509                "manifest-only package authority",
10510            )?;
10511        }
10512        let lock_only = PackageIndex::from_graphs(&[lock])?;
10513        require_eq(
10514            &lock_only.package_name("src/lib.rs"),
10515            &None,
10516            "lockfile-only ownership abstention",
10517        )?;
10518        Ok(())
10519    }
10520
10521    #[test]
10522    fn cargo_package_ownership_uses_the_longest_repository_prefix() -> Result<(), Box<dyn Error>> {
10523        let graphs = vec![
10524            package_graph("Cargo.toml", "workspace"),
10525            package_graph("crates/member/Cargo.toml", "member"),
10526        ];
10527        let packages = PackageIndex::from_graphs(&graphs)?;
10528        require_eq(
10529            &packages.package_name("src/lib.rs"),
10530            &Some("workspace"),
10531            "root package ownership",
10532        )?;
10533        require_eq(
10534            &packages.package_name("crates/member/src/lib.rs"),
10535            &Some("member"),
10536            "nested package ownership",
10537        )?;
10538        require(
10539            repository_path_belongs_to("crates/member/src/lib.rs", "crates/member"),
10540            "member path was not owned by its package",
10541        )?;
10542        require(
10543            !repository_path_belongs_to("crates/membership/src/lib.rs", "crates/member"),
10544            "package prefix matched a partial segment",
10545        )?;
10546        require(is_cargo_manifest_path("Cargo.toml"), "root manifest")?;
10547        require(
10548            is_cargo_manifest_path("crates/member/Cargo.toml"),
10549            "nested manifest",
10550        )?;
10551        require(
10552            !is_cargo_manifest_path("docs/Cargo.toml.example"),
10553            "manifest suffix lookalike",
10554        )?;
10555        Ok(())
10556    }
10557
10558    #[test]
10559    fn incremental_projection_budget_requests_one_complete_refresh() -> Result<(), Box<dyn Error>> {
10560        let root = Path::new("repository");
10561        let affected_paths = BTreeSet::from(["src/lib.rs".to_string()]);
10562        for (rows, retained_bytes) in [
10563            (MAX_INCREMENTAL_GRAPH_ROWS + 1, 0),
10564            (0, MAX_INCREMENTAL_GRAPH_BYTES + 1),
10565        ] {
10566            let error =
10567                enforce_incremental_projection_budget(root, &affected_paths, rows, retained_bytes)
10568                    .err()
10569                    .ok_or_else(|| io::Error::other("oversized closure reached publication"))?;
10570            let CliError::RefreshRequired(report) = error else {
10571                return Err(io::Error::other(format!(
10572                    "expected typed full-refresh guidance, found {error:?}"
10573                ))
10574                .into());
10575            };
10576            require_eq(
10577                &report.reason,
10578                &IndexRefreshReason::DependencyClosureLimit,
10579                "incremental budget reason",
10580            )?;
10581            require_eq(
10582                &report.scope,
10583                &IndexRefreshScope::Full,
10584                "incremental budget scope",
10585            )?;
10586            require_eq(&report.changed, &1, "incremental changed paths")?;
10587            require_eq(
10588                &report.sample_paths,
10589                &vec!["src/lib.rs".to_string()],
10590                "incremental sample paths",
10591            )?;
10592        }
10593        Ok(())
10594    }
10595
10596    #[test]
10597    fn admitted_resolution_keys_move_once_and_count_at_budget_boundary()
10598    -> Result<(), Box<dyn Error>> {
10599        let graph = function_graph("src/lib.rs", 1);
10600        let packages = PackageIndex::from_graphs(std::slice::from_ref(&graph))?;
10601        let project = ProjectInstanceId::from_bytes([31; 16])?;
10602        let admitted_projection = projectatlas_symbols::derive_resolution_keys(
10603            project,
10604            packages.package_name(&graph.path),
10605            &graph,
10606        )?;
10607        let admitted_key_bytes = super::resolution_retained_bytes(&admitted_projection);
10608        let mut admitted = BTreeMap::from([(graph.path.clone(), admitted_projection)]);
10609        let control = IndexWorkControl::new(IndexCancellation::new(), None);
10610        let entities = build_entity_projection_with_config(
10611            project,
10612            IndexGeneration::new(1),
10613            &[],
10614            std::slice::from_ref(&graph),
10615            &packages,
10616            &ConfiguredModuleResolution::default(),
10617            Some(&mut admitted),
10618            true,
10619            &control,
10620        )?;
10621        require(
10622            admitted.is_empty(),
10623            "admitted resolution map retained moved keys",
10624        )?;
10625        require(
10626            entities.retained_bytes >= admitted_key_bytes,
10627            "entity projection budget omitted live resolution-key bytes",
10628        )?;
10629
10630        let mut registry = ProjectResolutionRegistry {
10631            retained_bytes: super::super::MAX_PUBLICATION_STAGING_BYTES
10632                .saturating_sub(entities.retained_bytes),
10633            ..ProjectResolutionRegistry::default()
10634        };
10635        require(
10636            enforce_resolution_staging_budget(&entities, &registry).is_ok(),
10637            "exact staging budget boundary was rejected",
10638        )?;
10639        registry.retained_bytes = registry.retained_bytes.saturating_add(1);
10640        require(
10641            enforce_resolution_staging_budget(&entities, &registry).is_err(),
10642            "staging budget ignored live resolution-key bytes at the boundary",
10643        )?;
10644        Ok(())
10645    }
10646
10647    #[test]
10648    fn multi_graph_resolution_peak_moves_each_entry_before_entity_allocation()
10649    -> Result<(), Box<dyn Error>> {
10650        let project = ProjectInstanceId::from_bytes([32; 16])?;
10651        let generation = IndexGeneration::new(1);
10652        let control = IndexWorkControl::new(IndexCancellation::new(), None);
10653        let graphs = vec![
10654            package_graph("Cargo.toml", "peak-workspace"),
10655            function_graph("src/target.rs", 64),
10656            function_graph("src/caller.rs", 64),
10657        ];
10658        let packages = PackageIndex::from_graphs(&graphs)?;
10659        let fresh_admitted = || {
10660            graphs
10661                .iter()
10662                .map(|graph| {
10663                    Ok::<_, Box<dyn Error>>((
10664                        graph.path.clone(),
10665                        projectatlas_symbols::derive_resolution_keys(
10666                            project,
10667                            packages.package_name(&graph.path),
10668                            graph,
10669                        )?,
10670                    ))
10671                })
10672                .collect::<Result<BTreeMap<_, _>, _>>()
10673        };
10674        let mut admitted = fresh_admitted()?;
10675        require_eq(
10676            &admitted.len(),
10677            &graphs.len(),
10678            "multi-graph admitted resolution projection count",
10679        )?;
10680        let map_bytes = resolution_projection_map_retained_bytes(&admitted);
10681        require(
10682            map_bytes > 0,
10683            "multi-graph projection map had no retained bytes",
10684        )?;
10685        let first = build_entity_projection_with_config_limit(
10686            project,
10687            generation,
10688            &[],
10689            &graphs,
10690            &packages,
10691            &ConfiguredModuleResolution::default(),
10692            Some(&mut admitted),
10693            true,
10694            &control,
10695            super::super::MAX_PUBLICATION_STAGING_BYTES,
10696        )?;
10697        require(
10698            admitted.is_empty(),
10699            "full multi-graph projection retained admitted map entries",
10700        )?;
10701        require_eq(
10702            &first.projection_removals_before_entities,
10703            &graphs
10704                .iter()
10705                .map(|graph| graph.path.clone())
10706                .collect::<Vec<_>>(),
10707            "full projection removal order",
10708        )?;
10709        require(
10710            first.peak_retained_bytes >= map_bytes,
10711            "full peak accounting omitted the admitted projection map",
10712        )?;
10713        let peak = first.peak_retained_bytes;
10714        let mut at_peak_admitted = fresh_admitted()?;
10715        let at_peak = build_entity_projection_with_config_limit(
10716            project,
10717            generation.checked_next().ok_or("generation overflow")?,
10718            &[],
10719            &graphs,
10720            &packages,
10721            &ConfiguredModuleResolution::default(),
10722            Some(&mut at_peak_admitted),
10723            true,
10724            &control,
10725            peak,
10726        )?;
10727        require(
10728            at_peak_admitted.is_empty(),
10729            "exact-peak multi-graph projection retained admitted map entries",
10730        )?;
10731        require_eq(
10732            &at_peak.projection_removals_before_entities,
10733            &graphs
10734                .iter()
10735                .map(|graph| graph.path.clone())
10736                .collect::<Vec<_>>(),
10737            "exact-peak projection removal order",
10738        )?;
10739        require(
10740            at_peak.peak_retained_bytes <= peak,
10741            "exact-peak projection exceeded the measured full peak unexpectedly",
10742        )?;
10743        let below_peak = peak.checked_sub(1).ok_or("multi-graph peak was zero")?;
10744        require_eq(
10745            &peak.saturating_sub(below_peak),
10746            &1,
10747            "multi-graph below-peak budget was not exactly one byte lower",
10748        )?;
10749        let mut below_peak_admitted = fresh_admitted()?;
10750        let below_peak_result = build_entity_projection_with_config_limit(
10751            project,
10752            generation.checked_next().ok_or("generation overflow")?,
10753            &[],
10754            &graphs,
10755            &packages,
10756            &ConfiguredModuleResolution::default(),
10757            Some(&mut below_peak_admitted),
10758            true,
10759            &control,
10760            below_peak,
10761        );
10762        require(
10763            matches!(
10764                &below_peak_result,
10765                Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
10766                    resource: IndexWorkResource::OutputBytes,
10767                    limit,
10768                    observed,
10769                    ..
10770                })) if *limit == below_peak && *observed == peak
10771            ),
10772            "one byte below the measured full peak did not fail at the measured peak",
10773        )?;
10774        Ok(())
10775    }
10776
10777    #[test]
10778    fn incremental_direct_and_inbound_keys_are_counted_once_at_budget_boundary()
10779    -> Result<(), Box<dyn Error>> {
10780        let temp = tempfile::tempdir()?;
10781        let root = temp.path().join("incremental-resolution-budget");
10782        fs::create_dir_all(root.join(".projectatlas"))?;
10783        let database = root.join(".projectatlas/projectatlas.db");
10784        let mut store = AtlasStore::open_for_project(&database, &root)?;
10785        let manifest_path = "Cargo.toml";
10786        let root_module_path = "src/lib.rs";
10787        let direct_path = "src/target.rs";
10788        let inbound_path = "src/caller.rs";
10789        let manifest_source =
10790            "[package]\nname = \"dependency-refresh\"\nversion = \"0.1.0\"\nedition = \"2021\"\n";
10791        let root_module_source = "mod caller;\nmod target;\n";
10792        let direct_source = "pub fn target() {}\n";
10793        let inbound_source = "pub fn caller() { target(); }\n";
10794        fs::create_dir_all(root.join("src"))?;
10795        fs::write(root.join(manifest_path), manifest_source)?;
10796        fs::write(root.join(root_module_path), root_module_source)?;
10797        fs::write(root.join(direct_path), direct_source)?;
10798        fs::write(root.join(inbound_path), inbound_source)?;
10799        let mut nodes = vec![
10800            test_file_node(manifest_path, "cargo-manifest"),
10801            test_file_node(root_module_path, "rust"),
10802            test_file_node(direct_path, "rust"),
10803            test_file_node(inbound_path, "rust"),
10804        ];
10805        capture_test_file_hashes(&root, &mut nodes)?;
10806        let graphs = vec![
10807            extract_symbol_graph(manifest_path, Some("cargo-manifest"), manifest_source),
10808            extract_symbol_graph(root_module_path, Some("rust"), root_module_source),
10809            extract_symbol_graph(direct_path, Some("rust"), direct_source),
10810            extract_symbol_graph(inbound_path, Some("rust"), inbound_source),
10811        ];
10812        let control = IndexWorkControl::new(IndexCancellation::new(), None);
10813        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
10814        let full = stage_full_repository_graph(
10815            &store,
10816            &root,
10817            IndexGeneration::ZERO,
10818            &nodes,
10819            &scan_policy,
10820            &symbol_build_stage_for_graphs(graphs.clone()),
10821            &control,
10822        )?;
10823        publish_full_staged_graph(
10824            &mut store,
10825            &nodes,
10826            &full,
10827            &control,
10828            "incremental-budget-full",
10829        )?;
10830        for graph in &graphs {
10831            store.replace_symbol_graph(graph)?;
10832        }
10833        drop(store);
10834        let store = AtlasStore::open_for_project(&database, &root)?;
10835        let base_generation = store
10836            .index_publication()?
10837            .ok_or("incremental budget full publication is missing")?
10838            .generation;
10839        let incremental = stage_incremental_repository_graph(
10840            &store,
10841            &root,
10842            base_generation,
10843            &nodes,
10844            &[direct_path.to_string()],
10845            &scan_policy,
10846            &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
10847            &control,
10848        )?;
10849        let affected_paths = BTreeSet::from([direct_path.to_string(), inbound_path.to_string()]);
10850        require(
10851            matches!(
10852                &incremental.mutation,
10853                RepositoryGraphMutation::AffectedPaths(paths)
10854                    if paths.iter().cloned().collect::<BTreeSet<_>>() == affected_paths
10855            ),
10856            "incremental budget fixture did not include its inbound graph",
10857        )?;
10858        let expected_generation = base_generation
10859            .checked_next()
10860            .ok_or("incremental budget generation overflowed")?;
10861        for path in [&direct_path, &inbound_path] {
10862            let derivations = incremental
10863                .resolution_derivations
10864                .get(&(path.to_string(), expected_generation))
10865                .copied();
10866            require_eq(
10867                &derivations,
10868                &Some(1),
10869                "incremental direct/inbound resolution derivation count",
10870            )?;
10871        }
10872        let entity_peak = incremental.peak_retained_bytes;
10873        require(
10874            entity_peak > 0,
10875            "incremental projection measured no retained peak",
10876        )?;
10877        let expected_removals = vec![direct_path.to_string(), inbound_path.to_string()];
10878        require_eq(
10879            &incremental.projection_removals_before_entities,
10880            &expected_removals,
10881            "incremental projection removal order",
10882        )?;
10883        let unrelated_manifest_derivations = incremental
10884            .resolution_derivations
10885            .get(&(manifest_path.to_string(), expected_generation))
10886            .copied();
10887        require_eq(
10888            &unrelated_manifest_derivations,
10889            &None,
10890            "incremental unrelated manifest resolution derivation count",
10891        )?;
10892        let unrelated_root_derivations = incremental
10893            .resolution_derivations
10894            .get(&(root_module_path.to_string(), expected_generation))
10895            .copied();
10896        require_eq(
10897            &unrelated_root_derivations,
10898            &None,
10899            "incremental unrelated root-module resolution derivation count",
10900        )?;
10901        let rows = u64::try_from(incremental.entities.len())?;
10902        let retained_bytes = incremental.retained_bytes;
10903        drop(incremental);
10904        // The injected ceiling now covers sources, symbols, identities, producer
10905        // scratch and candidate maps as well as entities. Find the actual public
10906        // stage boundary without copying its accounting formula into the test.
10907        let mut refused = 0_u64;
10908        let mut peak = super::super::MAX_PUBLICATION_STAGING_BYTES;
10909        while peak - refused > 1 {
10910            let middle = refused + (peak - refused) / 2;
10911            match stage_incremental_repository_graph_with_test_limit(
10912                &store,
10913                &root,
10914                base_generation,
10915                &nodes,
10916                &[direct_path.to_string()],
10917                &scan_policy,
10918                &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
10919                &control,
10920                middle,
10921            ) {
10922                Ok(_) => peak = middle,
10923                Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
10924                    resource: IndexWorkResource::OutputBytes,
10925                    ..
10926                })) => refused = middle,
10927                Err(error) => return Err(error.into()),
10928            }
10929        }
10930        require(
10931            peak >= entity_peak,
10932            "operation budget omitted its entity owner",
10933        )?;
10934        let at_peak = stage_incremental_repository_graph_with_test_limit(
10935            &store,
10936            &root,
10937            base_generation,
10938            &nodes,
10939            &[direct_path.to_string()],
10940            &scan_policy,
10941            &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
10942            &control,
10943            peak,
10944        )?;
10945        require_eq(
10946            &at_peak.projection_removals_before_entities,
10947            &expected_removals,
10948            "exact-peak incremental projection removal order",
10949        )?;
10950        require(
10951            at_peak.peak_retained_bytes <= peak,
10952            "exact-peak incremental projection exceeded its measured peak",
10953        )?;
10954        for path in [&direct_path, &inbound_path] {
10955            require_eq(
10956                &at_peak
10957                    .resolution_derivations
10958                    .get(&(path.to_string(), expected_generation))
10959                    .copied(),
10960                &Some(1),
10961                "exact-peak incremental derivation count",
10962            )?;
10963        }
10964        require_eq(
10965            &at_peak
10966                .resolution_derivations
10967                .get(&(manifest_path.to_string(), expected_generation))
10968                .copied(),
10969            &None,
10970            "exact-peak unrelated manifest derivation count",
10971        )?;
10972        require_eq(
10973            &at_peak
10974                .resolution_derivations
10975                .get(&(root_module_path.to_string(), expected_generation))
10976                .copied(),
10977            &None,
10978            "exact-peak unrelated root-module derivation count",
10979        )?;
10980        drop(at_peak);
10981        let below_peak = peak.checked_sub(1).ok_or("incremental peak was zero")?;
10982        require_eq(
10983            &peak.saturating_sub(below_peak),
10984            &1,
10985            "incremental below-peak budget was not exactly one byte lower",
10986        )?;
10987        let below_peak_result = stage_incremental_repository_graph_with_test_limit(
10988            &store,
10989            &root,
10990            base_generation,
10991            &nodes,
10992            &[direct_path.to_string()],
10993            &scan_policy,
10994            &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
10995            &control,
10996            below_peak,
10997        );
10998        require(
10999            matches!(
11000                &below_peak_result,
11001                Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
11002                    resource: IndexWorkResource::OutputBytes,
11003                    limit,
11004                    observed,
11005                    ..
11006                })) if *observed > *limit
11007            ),
11008            "one byte below the operation boundary did not return a typed byte refusal",
11009        )?;
11010        require(
11011            retained_bytes > 0,
11012            "incremental budget fixture did not retain projected key bytes",
11013        )?;
11014        require(
11015            enforce_incremental_projection_budget(&root, &affected_paths, rows, retained_bytes)
11016                .is_ok(),
11017            "incremental budget rejected actual direct and inbound projection",
11018        )?;
11019        let available = MAX_INCREMENTAL_GRAPH_BYTES.saturating_sub(retained_bytes);
11020        require(
11021            enforce_incremental_projection_budget(
11022                &root,
11023                &affected_paths,
11024                rows,
11025                retained_bytes.saturating_add(available),
11026            )
11027            .is_ok(),
11028            "incremental exact byte boundary was rejected",
11029        )?;
11030        require(
11031            enforce_incremental_projection_budget(
11032                &root,
11033                &affected_paths,
11034                rows,
11035                retained_bytes.saturating_add(available).saturating_add(1),
11036            )
11037            .is_err(),
11038            "incremental byte boundary ignored one additional byte",
11039        )?;
11040        Ok(())
11041    }
11042
11043    #[test]
11044    fn incremental_projection_budget_combines_old_and_new_work() -> Result<(), Box<dyn Error>> {
11045        let temp = tempfile::tempdir()?;
11046        let root = temp.path();
11047        let affected_paths = BTreeSet::from(["src/lib.rs".to_string()]);
11048        let control = IndexWorkControl::new(IndexCancellation::new(), None);
11049        let persisted = RepositoryAffectedSourceFootprint {
11050            rows: MAX_INCREMENTAL_GRAPH_ROWS,
11051            retained_bytes: 0,
11052            truncated: false,
11053        };
11054        let staged = StagedRepositoryGraph {
11055            project: ProjectInstanceId::from_bytes([1; 16])?,
11056            mutation: RepositoryGraphMutation::AffectedPaths(vec!["src/lib.rs".to_string()]),
11057            entities: Vec::new(),
11058            relations: Vec::new(),
11059            occurrences: Vec::new(),
11060            coverage: Vec::new(),
11061            entity_exports: Vec::new(),
11062            relation_dependencies: Vec::new(),
11063            document_unresolved_reasons: Vec::new(),
11064            identity_rejections: Vec::new(),
11065            resolution_derivations: BTreeMap::new(),
11066            peak_retained_bytes: 0,
11067            projection_removals_before_entities: Vec::new(),
11068            scan_policy: RootScanPolicy::discover(root, &ScanOptions::default(), &control)?,
11069            document_target_states: Vec::new(),
11070            database: None,
11071            source_recovery: SourceRecoveryWork::default(),
11072            retained_bytes: 0,
11073        };
11074        let error =
11075            enforce_incremental_projection_limits(root, &affected_paths, persisted, &staged)
11076                .err()
11077                .ok_or_else(|| io::Error::other("combined old and new work was admitted"))?;
11078        let CliError::RefreshRequired(report) = error else {
11079            return Err(io::Error::other(format!(
11080                "expected typed full-refresh guidance, found {error:?}"
11081            ))
11082            .into());
11083        };
11084        require_eq(
11085            &report.reason,
11086            &IndexRefreshReason::DependencyClosureLimit,
11087            "combined budget reason",
11088        )?;
11089        require_eq(
11090            &report.scope,
11091            &IndexRefreshScope::Full,
11092            "combined budget scope",
11093        )?;
11094        Ok(())
11095    }
11096
11097    #[test]
11098    fn document_projection_memory_budget_includes_reason_validation_state()
11099    -> Result<(), Box<dyn Error>> {
11100        let control = IndexWorkControl::new(IndexCancellation::new(), None);
11101        let facts: BTreeMap<String, Cow<'static, projectatlas_symbols::MarkdownFacts>> = (0..12)
11102            .map(|document| {
11103                let source = (0..1_024)
11104                    .map(|link| format!("[missing]({document}-{link}.md)"))
11105                    .collect::<Vec<_>>()
11106                    .join("\n");
11107                (
11108                    format!("docs/source-{document}.md"),
11109                    Cow::Owned(projectatlas_symbols::extract_markdown_facts(&source)),
11110                )
11111            })
11112            .collect::<BTreeMap<_, _>>();
11113        let candidate_count = facts
11114            .values()
11115            .map(|document| document.link_candidates.len())
11116            .sum::<usize>();
11117        require_eq(&candidate_count, &12_288, "document candidate fixture size")?;
11118        let fact_bytes = document_fact_map_retained_bytes(&facts);
11119        let projection_bytes = document_projection_retained_bytes(&facts, &control)?;
11120        require(
11121            projection_bytes >= u64::try_from(candidate_count)? * DOCUMENT_PROJECTION_ROW_BYTES,
11122            "generated document projection state was under-accounted",
11123        )?;
11124        require(
11125            fact_bytes.saturating_add(projection_bytes) < 512 * 1_024 * 1_024,
11126            "bounded document projection fixture exceeded the in-memory envelope",
11127        )?;
11128        Ok(())
11129    }
11130
11131    #[test]
11132    fn document_projection_estimator_deadline_preserves_generation_and_allows_retry()
11133    -> Result<(), Box<dyn Error>> {
11134        const DOCUMENT_COUNT: usize = 513;
11135        const CANDIDATES_PER_DOCUMENT: usize = 1_024;
11136        let temp = tempfile::tempdir()?;
11137        let root = fs::canonicalize(temp.path())?;
11138        let database = root.join("projectatlas.db");
11139        let mut store = AtlasStore::open_for_project(&database, &root)?;
11140        let project = store
11141            .project_instance_id()?
11142            .ok_or("estimator cancellation project identity is missing")?;
11143        let publication_control = IndexWorkControl::new(IndexCancellation::new(), None);
11144        let baseline_graph = function_graph("src/lib.rs", 1);
11145        let baseline_packages = PackageIndex::from_graphs(std::slice::from_ref(&baseline_graph))?;
11146        let baseline_entities = build_entity_projection(
11147            project,
11148            IndexGeneration::new(1),
11149            &[],
11150            std::slice::from_ref(&baseline_graph),
11151            &baseline_packages,
11152            true,
11153            &publication_control,
11154        )?;
11155        let baseline_candidates =
11156            resolution_registry_from_exports(&baseline_entities, &publication_control)?;
11157        let baseline_staged = finish_projection(
11158            project,
11159            IndexGeneration::new(1),
11160            RepositoryGraphMutation::Full,
11161            std::slice::from_ref(&baseline_graph),
11162            baseline_entities,
11163            &baseline_candidates,
11164            &publication_control,
11165        )?;
11166        let baseline_node = test_file_node("src/lib.rs", "rust");
11167        {
11168            let mut publication = store.begin_index_publication("estimator-cancellation")?;
11169            publication.begin_scan_replacement()?;
11170            publication.upsert_scan_node_batch(std::slice::from_ref(&baseline_node))?;
11171            publication.finish_scan_replacement()?;
11172            baseline_staged.apply(&mut publication, &publication_control)?;
11173            publication.complete()?;
11174        }
11175        let publication_before = store
11176            .index_publication()?
11177            .ok_or("estimator cancellation baseline publication is missing")?;
11178
11179        let document_facts: BTreeMap<String, Cow<'_, projectatlas_symbols::MarkdownFacts>> = (0
11180            ..DOCUMENT_COUNT)
11181            .map(|document| {
11182                let path = format!("content/source-{document:03}.md");
11183                let file_name = path
11184                    .rsplit_once('/')
11185                    .map_or(path.as_str(), |(_parent, file_name)| file_name);
11186                let source = (0..CANDIDATES_PER_DOCUMENT)
11187                    .map(|index| format!("[self-{index:04}]({file_name})"))
11188                    .collect::<Vec<_>>()
11189                    .join("\n");
11190                (
11191                    path,
11192                    Cow::Owned(projectatlas_symbols::extract_markdown_facts(&source)),
11193                )
11194            })
11195            .collect::<BTreeMap<_, _>>();
11196        let candidate_count = document_facts
11197            .values()
11198            .map(|facts| facts.link_candidates.len())
11199            .sum::<usize>();
11200        require_eq(
11201            &candidate_count,
11202            &(DOCUMENT_COUNT * CANDIDATES_PER_DOCUMENT),
11203            "estimator cancellation candidate count",
11204        )?;
11205
11206        let expired_control =
11207            IndexWorkControl::with_deadline(IndexCancellation::new(), Instant::now());
11208        let error = document_projection_retained_bytes(&document_facts, &expired_control)
11209            .err()
11210            .ok_or("expired estimator unexpectedly traversed all candidates")?;
11211        require(
11212            matches!(
11213                error,
11214                CliError::IndexWork(IndexWorkFailure::DeadlineExceeded {
11215                    stage: IndexWorkStage::SymbolParsing,
11216                })
11217            ),
11218            "estimator deadline did not return its typed graph-work failure",
11219        )?;
11220        require_eq(
11221            &store.index_publication()?,
11222            &Some(publication_before.clone()),
11223            "estimator deadline changed the current generation",
11224        )?;
11225
11226        let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
11227        let retained_bytes = document_projection_retained_bytes(&document_facts, &retry_control)?;
11228        require_eq(
11229            &retained_bytes,
11230            &0,
11231            "same-file candidates entered the retained projection estimate",
11232        )?;
11233        let retry_graphs = document_facts
11234            .iter()
11235            .map(|(path, facts)| facts.symbol_graph(path, Some("markdown")))
11236            .collect::<Vec<_>>();
11237        let retry_nodes = document_facts
11238            .keys()
11239            .map(|path| test_file_node(path, "markdown"))
11240            .collect::<Vec<_>>();
11241        let retry_scan_policy =
11242            RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
11243        let retry_packages = PackageIndex::from_graphs(&retry_graphs)?;
11244        let retry_entities = build_entity_projection(
11245            project,
11246            IndexGeneration::new(2),
11247            &retry_nodes,
11248            &retry_graphs,
11249            &retry_packages,
11250            false,
11251            &retry_control,
11252        )?;
11253        let retry_candidates = resolution_registry_from_exports(&retry_entities, &retry_control)?;
11254        let retried_projection = finish_projection_with_documents(
11255            project,
11256            IndexGeneration::new(2),
11257            RepositoryGraphMutation::Full,
11258            &retry_graphs,
11259            &root,
11260            &retry_nodes,
11261            &document_facts,
11262            &GraphIdentityAdmission::default(),
11263            retry_entities,
11264            &retry_candidates,
11265            &retry_scan_policy,
11266            &retry_control,
11267        )?;
11268        require(
11269            retried_projection.relations.is_empty()
11270                && retried_projection.document_unresolved_reasons.is_empty(),
11271            "same-file retry emitted document projection rows",
11272        )?;
11273        require_eq(
11274            &store.index_publication()?,
11275            &Some(publication_before),
11276            "estimator retry changed the current generation without publication",
11277        )?;
11278        Ok(())
11279    }
11280
11281    #[test]
11282    fn incremental_document_projection_overflow_requests_full_refresh_staging()
11283    -> Result<(), Box<dyn Error>> {
11284        const DOCUMENT_COUNT: usize = 513;
11285        let temp = tempfile::tempdir()?;
11286        let root = fs::canonicalize(temp.path())?;
11287        let database = root.join("projectatlas.db");
11288        let mut store = AtlasStore::open_for_project(&database, &root)?;
11289        let project = store
11290            .project_instance_id()?
11291            .ok_or("incremental document budget project identity is missing")?;
11292        let control = IndexWorkControl::new(IndexCancellation::new(), None);
11293        let template = projectatlas_symbols::extract_markdown_facts("[missing](shared.md)");
11294        let candidate = template
11295            .link_candidates
11296            .first()
11297            .cloned()
11298            .ok_or("document budget candidate fixture is missing")?;
11299        let mut template = template;
11300        while template.link_candidates.len() < MAX_DOCUMENT_LINK_CANDIDATES {
11301            template.link_candidates.push(candidate.clone());
11302        }
11303
11304        let mut nodes = Vec::with_capacity(DOCUMENT_COUNT);
11305        let mut graphs = Vec::with_capacity(DOCUMENT_COUNT);
11306        let mut changes = Vec::with_capacity(DOCUMENT_COUNT);
11307        let mut paths = Vec::with_capacity(DOCUMENT_COUNT);
11308        for document in 0..DOCUMENT_COUNT {
11309            let path = format!("content/source-{document:03}.md");
11310            let graph = template.symbol_graph(&path, Some("markdown"));
11311            nodes.push(test_file_node(&path, "markdown"));
11312            paths.push(path.clone());
11313            graphs.push(graph.clone());
11314            changes.push(SymbolProjectionChange::Parsed(SymbolParseSuccess {
11315                path,
11316                graph,
11317                markdown_facts: Some(Box::new(template.clone())),
11318                source_semantics: None,
11319                source_parser: ParserKind::Structural,
11320                summary: String::new(),
11321                summary_is_structural: true,
11322                purpose_suggestion: None,
11323            }));
11324        }
11325        store.replace_scan(&nodes)?;
11326        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
11327        let packages = PackageIndex::from_graphs(&graphs)?;
11328        let entities = build_entity_projection(
11329            project,
11330            IndexGeneration::new(1),
11331            &nodes,
11332            &graphs,
11333            &packages,
11334            false,
11335            &control,
11336        )?;
11337        let candidates = resolution_registry_from_exports(&entities, &control)?;
11338        let projected_facts = paths
11339            .iter()
11340            .map(|path| (path.clone(), Cow::Borrowed(&template)))
11341            .collect::<BTreeMap<_, _>>();
11342        let pre_document_bytes = entities
11343            .retained_bytes
11344            .saturating_add(candidates.retained_bytes)
11345            .saturating_add(document_fact_map_retained_bytes(&projected_facts));
11346        let document_projection_bytes =
11347            document_projection_retained_bytes(&projected_facts, &control)?;
11348        require(
11349            pre_document_bytes < MAX_IN_MEMORY_GRAPH_WORK_BYTES,
11350            "pre-document incremental state already exceeded the staging budget",
11351        )?;
11352        require(
11353            pre_document_bytes.saturating_add(document_projection_bytes)
11354                > MAX_IN_MEMORY_GRAPH_WORK_BYTES,
11355            "document projection did not cross the aggregate staging budget",
11356        )?;
11357
11358        let mut symbols = empty_symbol_build_stage();
11359        symbols.changes = changes;
11360        let direct_paths = paths.clone();
11361        let error = stage_incremental_repository_graph(
11362            &store,
11363            &root,
11364            IndexGeneration::new(0),
11365            &nodes,
11366            &direct_paths,
11367            &scan_policy,
11368            &symbols,
11369            &control,
11370        )
11371        .err()
11372        .ok_or("document projection overflow was admitted incrementally")?;
11373        let CliError::RefreshRequired(report) = error else {
11374            return Err(io::Error::other(format!(
11375                "expected typed full-refresh guidance, found {error:?}"
11376            ))
11377            .into());
11378        };
11379        require_eq(
11380            &report.reason,
11381            &IndexRefreshReason::DependencyClosureLimit,
11382            "document projection overflow reason",
11383        )?;
11384        require_eq(
11385            &report.scope,
11386            &IndexRefreshScope::Full,
11387            "document projection overflow scope",
11388        )?;
11389
11390        let staged = stage_full_repository_graph(
11391            &store,
11392            &root,
11393            IndexGeneration::new(0),
11394            &nodes,
11395            &scan_policy,
11396            &symbols,
11397            &control,
11398        )?;
11399        require(
11400            staged.database.is_some(),
11401            "full-refresh guidance did not select disposable SQLite staging",
11402        )?;
11403        Ok(())
11404    }
11405
11406    #[test]
11407    fn resolution_registry_reuses_staged_export_entities() -> Result<(), Box<dyn Error>> {
11408        let project = ProjectInstanceId::from_bytes([2; 16])?;
11409        let generation = IndexGeneration::new(1);
11410        let control = IndexWorkControl::new(IndexCancellation::new(), None);
11411        let graphs = vec![
11412            extract_symbol_graph(
11413                "Cargo.toml",
11414                Some("cargo-manifest"),
11415                "[package]\nname = \"atlas\"\n",
11416            ),
11417            extract_symbol_graph("src/lib.rs", Some("rust"), "pub fn run() {}\n"),
11418        ];
11419        let packages = PackageIndex::from_graphs(&graphs)?;
11420        let projection =
11421            build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
11422        let registry = resolution_registry_from_exports(&projection, &control)?;
11423        let registered_bindings = registry
11424            .candidate_digests_by_key
11425            .values()
11426            .map(BTreeSet::len)
11427            .sum::<usize>();
11428
11429        require_eq(
11430            &registry.supplemental_entities_by_digest.len(),
11431            &0,
11432            "duplicate registry-owned entity count",
11433        )?;
11434        require_eq(
11435            &registered_bindings,
11436            &projection.entity_exports.len(),
11437            "registry key binding count",
11438        )?;
11439        require(
11440            registry
11441                .candidate_digests_by_key
11442                .values()
11443                .flatten()
11444                .all(|digest| projection.entity_by_digest.contains_key(digest)),
11445            "resolution key referenced an unstaged entity digest",
11446        )?;
11447        let mut bindings_per_entity = BTreeMap::<&str, usize>::new();
11448        for digest in registry.candidate_digests_by_key.values().flatten() {
11449            *bindings_per_entity.entry(digest).or_default() += 1;
11450        }
11451        require(
11452            bindings_per_entity.values().any(|count| *count > 1),
11453            "fixture did not exercise one entity exported under multiple canonical keys",
11454        )?;
11455        Ok(())
11456    }
11457
11458    #[test]
11459    fn restart_cleanup_removes_only_inactive_owned_graph_stages() -> Result<(), Box<dyn Error>> {
11460        let temp = tempfile::tempdir()?;
11461        let root = temp.path().join("restart-cleanup");
11462        let atlas_dir = root.join(".projectatlas");
11463        fs::create_dir_all(&atlas_dir)?;
11464        let database = atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11465        let store = AtlasStore::open_for_project(&database, &root)?;
11466        let project = store
11467            .project_instance_id()?
11468            .ok_or("bound project identity is missing")?;
11469
11470        let owned = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}owned"));
11471        fs::create_dir(&owned)?;
11472        let owned_database = owned.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11473        drop(AtlasStore::create_repository_graph_staging(
11474            &owned_database,
11475            &root,
11476            project,
11477        )?);
11478        let owned_payload = owned.join("payload");
11479        fs::create_dir(&owned_payload)?;
11480        fs::write(owned_payload.join("row"), "discard")?;
11481        let owned_link_target = temp.path().join("owned-link-target");
11482        fs::create_dir(&owned_link_target)?;
11483        fs::write(owned_link_target.join("sentinel"), "preserve")?;
11484        let owned_payload_link = owned.join("linked-payload");
11485        create_directory_link(&owned_link_target, &owned_payload_link)?;
11486        let interrupted_shell =
11487            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}interrupted-shell"));
11488        fs::create_dir(&interrupted_shell)?;
11489        let unvalidated_nonempty = atlas_dir.join(format!(
11490            "{GRAPH_STAGE_DIRECTORY_PREFIX}unvalidated-nonempty"
11491        ));
11492        fs::create_dir(&unvalidated_nonempty)?;
11493        fs::write(unvalidated_nonempty.join("sentinel"), "preserve")?;
11494        let lookalike = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}lookalike"));
11495        fs::create_dir(&lookalike)?;
11496        drop(AtlasStore::open_for_project(
11497            &lookalike.join(GRAPH_STAGE_DATABASE_FILE_NAME),
11498            &root,
11499        )?);
11500        let foreign_root = temp.path().join("foreign-project");
11501        fs::create_dir(&foreign_root)?;
11502        let foreign_store =
11503            AtlasStore::open_for_project(&foreign_root.join("projectatlas.db"), &foreign_root)?;
11504        let foreign_project = foreign_store
11505            .project_instance_id()?
11506            .ok_or("foreign project identity is missing")?;
11507        let foreign_project_stage =
11508            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}foreign-project"));
11509        fs::create_dir(&foreign_project_stage)?;
11510        drop(AtlasStore::create_repository_graph_staging(
11511            &foreign_project_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
11512            &root,
11513            foreign_project,
11514        )?);
11515        let foreign_root_stage =
11516            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}foreign-root"));
11517        fs::create_dir(&foreign_root_stage)?;
11518        drop(AtlasStore::create_repository_graph_staging(
11519            &foreign_root_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
11520            &foreign_root,
11521            project,
11522        )?);
11523        let linked_stage_target = temp.path().join("linked-stage-target");
11524        fs::create_dir(&linked_stage_target)?;
11525        fs::write(linked_stage_target.join("sentinel"), "preserve")?;
11526        drop(AtlasStore::create_repository_graph_staging(
11527            &linked_stage_target.join(GRAPH_STAGE_DATABASE_FILE_NAME),
11528            &root,
11529            project,
11530        )?);
11531        let linked_stage = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}linked"));
11532        create_directory_link(&linked_stage_target, &linked_stage)?;
11533
11534        let linked_database_target = temp.path().join("linked-stage-database.db");
11535        drop(AtlasStore::create_repository_graph_staging(
11536            &linked_database_target,
11537            &root,
11538            project,
11539        )?);
11540        let linked_database_stage =
11541            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}linked-database"));
11542        fs::create_dir(&linked_database_stage)?;
11543        let linked_database = linked_database_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11544        let linked_database_created =
11545            match create_file_link(&linked_database_target, &linked_database) {
11546                Ok(()) => true,
11547                #[cfg(windows)]
11548                Err(source) if source.raw_os_error() == Some(1314) => false,
11549                Err(source) => return Err(source.into()),
11550            };
11551        let control = IndexWorkControl::new(IndexCancellation::new(), None);
11552
11553        let lease = try_graph_stage_lease(&atlas_dir)?
11554            .ok_or("test could not acquire graph staging lease")?;
11555        remove_owned_graph_stage_payload(&owned, &owned_database, Some(&control))?;
11556        require(
11557            owned_database.is_file() && !owned_payload.exists(),
11558            "payload cleanup did not retain the ownership database until last",
11559        )?;
11560        require(
11561            fs::symlink_metadata(&owned_payload_link).is_err()
11562                && owned_link_target.join("sentinel").is_file(),
11563            "payload cleanup followed or retained a linked child",
11564        )?;
11565        cleanup_abandoned_graph_staging(&root, project, &control)?;
11566        require(
11567            owned.exists(),
11568            "restart cleanup removed an actively leased stage",
11569        )?;
11570        drop(lease);
11571
11572        let canceled_control = IndexWorkControl::new(IndexCancellation::new(), None);
11573        canceled_control.cancel();
11574        let canceled = cleanup_abandoned_graph_staging(&root, project, &canceled_control)
11575            .err()
11576            .ok_or("canceled restart cleanup unexpectedly succeeded")?;
11577        require(
11578            matches!(
11579                canceled,
11580                CliError::IndexWork(IndexWorkFailure::Cancelled {
11581                    stage: IndexWorkStage::Publication
11582                })
11583            ),
11584            "restart cleanup did not preserve typed cancellation",
11585        )?;
11586        require(
11587            owned.exists(),
11588            "canceled restart cleanup removed an owned stage",
11589        )?;
11590
11591        cleanup_abandoned_graph_staging(&root, project, &control)?;
11592        require(
11593            !owned.exists(),
11594            "restart cleanup retained an inactive owned stage",
11595        )?;
11596        require(
11597            !interrupted_shell.exists(),
11598            "restart cleanup retained an empty interrupted stage shell",
11599        )?;
11600        require(
11601            unvalidated_nonempty.join("sentinel").is_file(),
11602            "restart cleanup removed a non-empty unvalidated stage",
11603        )?;
11604        require(
11605            lookalike.exists(),
11606            "restart cleanup removed an unvalidated lookalike stage",
11607        )?;
11608        require(
11609            foreign_project_stage.exists(),
11610            "restart cleanup removed a valid stage owned by another project",
11611        )?;
11612        require(
11613            foreign_root_stage.exists(),
11614            "restart cleanup removed a valid stage bound to another root",
11615        )?;
11616        require(
11617            fs::symlink_metadata(&linked_stage).is_ok()
11618                && linked_stage_target.join("sentinel").is_file(),
11619            "restart cleanup followed a linked stage directory",
11620        )?;
11621        if linked_database_created {
11622            require(
11623                fs::symlink_metadata(&linked_database).is_ok() && linked_database_target.is_file(),
11624                "restart cleanup followed a linked staging database",
11625            )?;
11626        }
11627        Ok(())
11628    }
11629
11630    #[test]
11631    fn restart_cleanup_reclaims_schema_nineteen_owned_graph_stage() -> Result<(), Box<dyn Error>> {
11632        let temp = tempfile::tempdir()?;
11633        let root = temp.path().join("schema-19-restart-cleanup");
11634        let atlas_dir = root.join(".projectatlas");
11635        fs::create_dir_all(&atlas_dir)?;
11636        let main_database = atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11637        let store = AtlasStore::open_for_project(&main_database, &root)?;
11638        let project = store
11639            .project_instance_id()?
11640            .ok_or("bound project identity is missing")?;
11641        let prepare_schema_nineteen =
11642            |stage: &Path, stage_root: &Path, stage_project: ProjectInstanceId| {
11643                fs::create_dir(stage)?;
11644                let database = stage.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11645                drop(AtlasStore::create_repository_graph_staging(
11646                    &database,
11647                    stage_root,
11648                    stage_project,
11649                )?);
11650                let connection = Connection::open(database)?;
11651                drop_native_worktree_identity_schema(&connection)?;
11652                connection.execute_batch(
11653                    "DROP TABLE project_root_identity;
11654                 DROP TABLE IF EXISTS graph_identity_rejections;
11655                 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
11656                )?;
11657                Ok::<(), Box<dyn Error>>(())
11658            };
11659
11660        let owned = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}schema19-owned"));
11661        prepare_schema_nineteen(&owned, &root, project)?;
11662        let owned_database = owned.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11663        let owned_matches =
11664            AtlasStore::repository_graph_staging_belongs_to(&owned_database, &root, project)?;
11665        #[cfg(windows)]
11666        require(
11667            owned_matches,
11668            "schema-19 owned staging database was not admitted",
11669        )?;
11670        #[cfg(unix)]
11671        require(
11672            owned_matches,
11673            "schema-19 staging database was not admitted by its durable staging ownership",
11674        )?;
11675        fs::write(owned.join("large-graph-payload"), b"stale graph payload")?;
11676
11677        let foreign_root = temp.path().join("schema-19-foreign-root");
11678        fs::create_dir(&foreign_root)?;
11679        let foreign = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}schema19-foreign"));
11680        let foreign_root_project = ProjectInstanceId::from_bytes([9; 16])?;
11681        prepare_schema_nineteen(&foreign, &foreign_root, foreign_root_project)?;
11682        let foreign_database = foreign.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11683        require(
11684            !AtlasStore::repository_graph_staging_belongs_to(&foreign_database, &root, project)?,
11685            "schema-19 unrelated-root staging database was admitted",
11686        )?;
11687
11688        let schema_eighteen = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}schema18"));
11689        prepare_schema_nineteen(&schema_eighteen, &root, project)?;
11690        let schema_eighteen_database = schema_eighteen.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11691        let connection = Connection::open(&schema_eighteen_database)?;
11692        connection.execute(
11693            "UPDATE metadata SET value = '18' WHERE key = 'schema_version'",
11694            [],
11695        )?;
11696        require(
11697            !AtlasStore::repository_graph_staging_belongs_to(
11698                &schema_eighteen_database,
11699                &root,
11700                project,
11701            )?,
11702            "schema-18 staging database was admitted as a schema-19 predecessor",
11703        )?;
11704
11705        let incomplete_current =
11706            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}current-incomplete"));
11707        fs::create_dir(&incomplete_current)?;
11708        let incomplete_current_database = incomplete_current.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11709        drop(AtlasStore::create_repository_graph_staging(
11710            &incomplete_current_database,
11711            &root,
11712            project,
11713        )?);
11714        let connection = Connection::open(&incomplete_current_database)?;
11715        connection.execute_batch(
11716            "DROP TABLE project_root_identity; DROP TABLE IF EXISTS graph_identity_rejections;",
11717        )?;
11718        require(
11719            !AtlasStore::repository_graph_staging_belongs_to(
11720                &incomplete_current_database,
11721                &root,
11722                project,
11723            )
11724            .unwrap_or(false),
11725            "current staging database without native identity was admitted",
11726        )?;
11727
11728        let control = IndexWorkControl::new(IndexCancellation::new(), None);
11729        cleanup_abandoned_graph_staging(&root, project, &control)?;
11730        #[cfg(windows)]
11731        require(
11732            !owned.exists(),
11733            "restart cleanup retained an owned schema-19 staging database",
11734        )?;
11735        #[cfg(unix)]
11736        require(
11737            !owned.exists(),
11738            "restart cleanup retained an owned schema-19 staging database",
11739        )?;
11740        require(
11741            foreign.exists(),
11742            "restart cleanup removed an unrelated schema-19 staging database",
11743        )?;
11744        require(
11745            schema_eighteen.exists(),
11746            "restart cleanup removed a non-predecessor schema-18 staging database",
11747        )?;
11748        require(
11749            incomplete_current.exists(),
11750            "restart cleanup removed a current staging database without native identity",
11751        )?;
11752        Ok(())
11753    }
11754
11755    #[test]
11756    fn restart_cleanup_observes_cancellation_between_owned_stages() -> Result<(), Box<dyn Error>> {
11757        const STAGE_COUNT: usize = 64;
11758        const FILES_PER_STAGE: usize = 64;
11759
11760        let temp = tempfile::tempdir()?;
11761        let root = temp.path().join("restart-cleanup-cancellation");
11762        let atlas_dir = root.join(".projectatlas");
11763        fs::create_dir_all(&atlas_dir)?;
11764        let store =
11765            AtlasStore::open_for_project(&atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME), &root)?;
11766        let project = store
11767            .project_instance_id()?
11768            .ok_or("bound project identity is missing")?;
11769        let mut stages = Vec::with_capacity(STAGE_COUNT);
11770        for stage_index in 0..STAGE_COUNT {
11771            let stage = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}{stage_index:03}"));
11772            fs::create_dir(&stage)?;
11773            drop(AtlasStore::create_repository_graph_staging(
11774                &stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
11775                &root,
11776                project,
11777            )?);
11778            let payload = stage.join("payload");
11779            fs::create_dir(&payload)?;
11780            for file_index in 0..FILES_PER_STAGE {
11781                fs::write(payload.join(format!("{file_index:03}")), b"x")?;
11782            }
11783            stages.push(stage);
11784        }
11785
11786        let cancellation = IndexCancellation::new();
11787        let control = IndexWorkControl::new(cancellation.clone(), None);
11788        let worker_root = root;
11789        let worker =
11790            thread::spawn(move || cleanup_abandoned_graph_staging(&worker_root, project, &control));
11791        let observation_deadline = Instant::now() + Duration::from_secs(30);
11792        loop {
11793            let remaining = stages.iter().filter(|stage| stage.exists()).count();
11794            if remaining < STAGE_COUNT {
11795                cancellation.cancel();
11796                break;
11797            }
11798            if worker.is_finished() {
11799                return Err(io::Error::other(
11800                    "restart cleanup completed before in-flight cancellation was observed",
11801                )
11802                .into());
11803            }
11804            if Instant::now() >= observation_deadline {
11805                cancellation.cancel();
11806                return Err(io::Error::other(
11807                    "restart cleanup removed no stage within the test deadline",
11808                )
11809                .into());
11810            }
11811            thread::yield_now();
11812        }
11813        let result = worker
11814            .join()
11815            .map_err(|_panic| io::Error::other("restart cleanup worker panicked"))?;
11816        require(
11817            matches!(
11818                result,
11819                Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
11820                    stage: IndexWorkStage::Publication
11821                }))
11822            ),
11823            "restart cleanup did not return typed in-flight cancellation",
11824        )?;
11825        let remaining = stages.iter().filter(|stage| stage.exists()).count();
11826        require(
11827            remaining > 0 && remaining < STAGE_COUNT,
11828            "in-flight cancellation did not preserve a partial cleanup boundary",
11829        )
11830    }
11831
11832    #[test]
11833    fn staged_database_owner_retains_incomplete_creation() -> Result<(), Box<dyn Error>> {
11834        let temp = tempfile::tempdir()?;
11835        let atlas_dir = temp.path().join(".projectatlas");
11836        fs::create_dir(&atlas_dir)?;
11837        let directory = tempfile::Builder::new()
11838            .prefix(GRAPH_STAGE_DIRECTORY_PREFIX)
11839            .tempdir_in(&atlas_dir)?;
11840        let staging_path = directory.path().to_path_buf();
11841        fs::write(
11842            staging_path.join(GRAPH_STAGE_DATABASE_FILE_NAME),
11843            "incomplete",
11844        )?;
11845        fs::write(staging_path.join("payload"), "preserve")?;
11846        let lease = try_graph_stage_lease(&atlas_dir)?
11847            .ok_or("test could not acquire graph staging lease")?;
11848        let owner = super::StagedGraphDatabase {
11849            store: None,
11850            directory: Some(directory),
11851            _lease: lease,
11852        };
11853
11854        drop(owner);
11855
11856        require(
11857            staging_path.join(GRAPH_STAGE_DATABASE_FILE_NAME).is_file()
11858                && staging_path.join("payload").is_file(),
11859            "incomplete staging creation was recursively deleted",
11860        )
11861    }
11862
11863    #[test]
11864    fn database_staging_publishes_and_removes_its_disposable_store() -> Result<(), Box<dyn Error>> {
11865        let temp = tempfile::tempdir()?;
11866        let root = temp.path().join("database-staging");
11867        let atlas_dir = root.join(".projectatlas");
11868        fs::create_dir_all(&atlas_dir)?;
11869        let database = atlas_dir.join("projectatlas.db");
11870        let mut store = AtlasStore::open_for_project(&database, &root)?;
11871        let project = store
11872            .project_instance_id()?
11873            .ok_or("bound project identity is missing")?;
11874        let generation = IndexGeneration::new(1);
11875        let control = IndexWorkControl::new(IndexCancellation::new(), None);
11876        let graphs = vec![extract_symbol_graph(
11877            "src/lib.rs",
11878            Some("rust"),
11879            "pub fn caller() { helper(); }\nfn helper() {}\n",
11880        )];
11881        let nodes = vec![test_file_node("src/lib.rs", "rust")];
11882        let packages = PackageIndex::from_graphs(&graphs)?;
11883        let projection = build_entity_projection(
11884            project, generation, &nodes, &graphs, &packages, true, &control,
11885        )?;
11886        let candidates = resolution_registry_from_exports(&projection, &control)?;
11887        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
11888        let staged = finish_projection_in_database(
11889            &root,
11890            &nodes,
11891            project,
11892            generation,
11893            &graphs,
11894            projection,
11895            &candidates,
11896            &scan_policy,
11897            &control,
11898        )?;
11899        let staging_path = staged
11900            .database
11901            .as_ref()
11902            .ok_or("database staging was not selected")?
11903            .directory()?
11904            .path()
11905            .to_path_buf();
11906        let drop_payload = staging_path.join("drop-payload");
11907        fs::create_dir(&drop_payload)?;
11908        fs::write(drop_payload.join("row"), "discard")?;
11909        let drop_link_target = temp.path().join("drop-link-target");
11910        fs::create_dir(&drop_link_target)?;
11911        fs::write(drop_link_target.join("sentinel"), "preserve")?;
11912        create_directory_link(&drop_link_target, &staging_path.join("drop-linked-payload"))?;
11913        require(
11914            staging_path.exists(),
11915            "database staging directory is missing",
11916        )?;
11917        {
11918            let mut publication = store.begin_index_publication("database-staging")?;
11919            publication.begin_scan_replacement()?;
11920            publication.upsert_scan_node_batch(&nodes)?;
11921            publication.finish_scan_replacement()?;
11922            staged.apply(&mut publication, &control)?;
11923            publication.complete()?;
11924        }
11925        drop(staged);
11926        require(
11927            !staging_path.exists(),
11928            "database staging directory survived publication",
11929        )?;
11930        require(
11931            drop_link_target.join("sentinel").is_file(),
11932            "database staging drop followed a linked payload",
11933        )?;
11934        drop(store);
11935
11936        let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
11937        let coverage = reader.repository_graph_coverage(
11938            project,
11939            &CoverageScope::Path {
11940                path: RepositoryNodePath::new(Path::new("src/lib.rs"))?,
11941            },
11942            8,
11943        )?;
11944        require(
11945            !coverage.truncated,
11946            "database-staged coverage was truncated",
11947        )?;
11948        require(
11949            !coverage.rows.is_empty(),
11950            "database-staged graph rows were not published",
11951        )?;
11952        Ok(())
11953    }
11954
11955    #[test]
11956    fn staged_identity_rejections_count_toward_normal_and_database_bytes()
11957    -> Result<(), Box<dyn Error>> {
11958        let temp = tempfile::tempdir()?;
11959        let root = temp.path().join("identity-rejection-bytes");
11960        fs::create_dir_all(root.join("src"))?;
11961        fs::write(root.join("src/lib.rs"), "pub fn indexed() {}\n")?;
11962        let database = root.join("projectatlas.db");
11963        let store = AtlasStore::open_for_project(&database, &root)?;
11964        let project = store
11965            .project_instance_id()?
11966            .ok_or("identity rejection fixture project identity is missing")?;
11967        let generation = IndexGeneration::new(1);
11968        let control = IndexWorkControl::new(IndexCancellation::new(), None);
11969        let graphs = vec![extract_symbol_graph(
11970            "src/lib.rs",
11971            Some("rust"),
11972            "pub fn indexed() {}\n",
11973        )];
11974        let nodes = vec![test_file_node("src/lib.rs", "rust")];
11975        let packages = PackageIndex::from_graphs(&graphs)?;
11976        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
11977        let mut admission = GraphIdentityAdmission::default();
11978        let rejection_path = format!("src/{}.rs", "long-rejection-path".repeat(64));
11979        admission.record(
11980            &rejection_path,
11981            super::IdentitySpan {
11982                start_line: 4,
11983                start_column: 0,
11984                end_line: 4,
11985                end_column: 12,
11986            },
11987            ParserKind::TreeSitter,
11988            7,
11989            &[(
11990                GraphIdentityField::Symbol,
11991                GraphIdentityRejectionReason::Oversized,
11992            )],
11993            &control,
11994        )?;
11995        let rejection_bytes = identity_rejection_keys_retained_bytes(&admission.rejection_keys)?;
11996        require(
11997            rejection_bytes > 1,
11998            "identity rejection fixture did not retain a bounded detail payload",
11999        )?;
12000        let build_projection = || {
12001            let projection = build_entity_projection(
12002                project, generation, &nodes, &graphs, &packages, true, &control,
12003            )?;
12004            let candidates = resolution_registry_from_exports(&projection, &control)?;
12005            Result::<_, Box<dyn Error>>::Ok((projection, candidates))
12006        };
12007
12008        let (projection, candidates) = build_projection()?;
12009        let baseline = finish_projection_with_documents(
12010            project,
12011            generation,
12012            RepositoryGraphMutation::Full,
12013            &graphs,
12014            &root,
12015            &nodes,
12016            &BTreeMap::new(),
12017            &GraphIdentityAdmission::default(),
12018            projection,
12019            &candidates,
12020            &scan_policy,
12021            &control,
12022        )?;
12023        let (projection, candidates) = build_projection()?;
12024        let in_memory = finish_projection_with_documents(
12025            project,
12026            generation,
12027            RepositoryGraphMutation::Full,
12028            &graphs,
12029            &root,
12030            &nodes,
12031            &BTreeMap::new(),
12032            &admission,
12033            projection,
12034            &candidates,
12035            &scan_policy,
12036            &control,
12037        )?;
12038        require_eq(
12039            &in_memory.retained_bytes(),
12040            &baseline.retained_bytes().saturating_add(rejection_bytes),
12041            "in-memory identity rejection retained bytes",
12042        )?;
12043        require_eq(
12044            &in_memory.identity_rejections.len(),
12045            &1,
12046            "in-memory identity rejection detail count",
12047        )?;
12048        let parent_prefix = super::super::MAX_PUBLICATION_STAGING_BYTES
12049            .checked_sub(in_memory.retained_bytes())
12050            .ok_or("identity rejection fixture exceeded the publication budget")?;
12051        require(
12052            super::super::enforce_publication_staging_budget(
12053                parent_prefix.saturating_add(in_memory.retained_bytes()),
12054            )
12055            .is_ok(),
12056            "parent publication budget rejected exact staged identity bytes",
12057        )?;
12058        require(
12059            super::super::enforce_publication_staging_budget(
12060                parent_prefix
12061                    .saturating_add(in_memory.retained_bytes())
12062                    .saturating_add(1),
12063            )
12064            .is_err(),
12065            "parent publication budget accepted one byte over staged identity bytes",
12066        )?;
12067        drop(in_memory);
12068        drop(baseline);
12069
12070        let (projection, candidates) = build_projection()?;
12071        let baseline_database = finish_projection_in_database_with_documents(
12072            &root,
12073            &nodes,
12074            project,
12075            generation,
12076            &graphs,
12077            &BTreeMap::new(),
12078            &GraphIdentityAdmission::default(),
12079            projection,
12080            &candidates,
12081            &scan_policy,
12082            &control,
12083        )?;
12084        let baseline_database_path_bytes = baseline_database
12085            .database
12086            .as_ref()
12087            .ok_or("database staging baseline was not selected")?
12088            .directory()?
12089            .path()
12090            .join(GRAPH_STAGE_DATABASE_FILE_NAME)
12091            .as_os_str()
12092            .as_encoded_bytes()
12093            .len() as u64;
12094        require_eq(
12095            &baseline_database.retained_bytes(),
12096            &baseline_database_path_bytes,
12097            "database staging baseline retained bytes",
12098        )?;
12099        drop(baseline_database);
12100
12101        let (projection, candidates) = build_projection()?;
12102        let database_staged = finish_projection_in_database_with_documents(
12103            &root,
12104            &nodes,
12105            project,
12106            generation,
12107            &graphs,
12108            &BTreeMap::new(),
12109            &admission,
12110            projection,
12111            &candidates,
12112            &scan_policy,
12113            &control,
12114        )?;
12115        let database_path_bytes = database_staged
12116            .database
12117            .as_ref()
12118            .ok_or("database staging was not selected")?
12119            .directory()?
12120            .path()
12121            .join(GRAPH_STAGE_DATABASE_FILE_NAME)
12122            .as_os_str()
12123            .as_encoded_bytes()
12124            .len() as u64;
12125        require_eq(
12126            &database_staged.retained_bytes(),
12127            &database_path_bytes.saturating_add(rejection_bytes),
12128            "database staging identity rejection retained bytes",
12129        )?;
12130        Ok(())
12131    }
12132
12133    #[test]
12134    fn accepted_relation_families_publish_and_reopen() -> Result<(), Box<dyn Error>> {
12135        let temp = tempfile::tempdir()?;
12136        let root = temp.path().join("accepted-relation-families");
12137        for directory in ["src", "tests", "config", "infra", "data"] {
12138            fs::create_dir_all(root.join(directory))?;
12139        }
12140        let database = root.join("projectatlas.db");
12141        let mut store = AtlasStore::open_for_project(&database, &root)?;
12142        let project = store
12143            .project_instance_id()?
12144            .ok_or_else(|| io::Error::other("relation inventory identity is missing"))?;
12145        let generation = IndexGeneration::new(1);
12146        let control = IndexWorkControl::new(IndexCancellation::new(), None);
12147        let graphs = vec![
12148            extract_symbol_graph(
12149                "Cargo.toml",
12150                Some("cargo-manifest"),
12151                concat!(
12152                    "[package]\nname = \"relation-fixture\"\nversion = \"0.1.0\"\n",
12153                    "\n[dependencies]\nserde = \"1\"\n",
12154                ),
12155            ),
12156            extract_symbol_graph(
12157                "src/lib.rs",
12158                Some("rust"),
12159                concat!(
12160                    "use std::fs;\n",
12161                    "pub struct Router { pub enabled: bool }\n",
12162                    "impl Router { pub fn install(&self) {} }\n",
12163                    "pub fn handler() {}\n",
12164                    "pub fn register() {\n",
12165                    "    route(\"/health\", handler);\n",
12166                    "    let _ = std::env::var(\"ATLAS_MODE\").unwrap_or_else(|_| \"super-secret\".into());\n",
12167                    "    let _ = fs::read_to_string(\"data/input.txt\");\n",
12168                    "    let _ = fs::write(\"data/output.txt\", \"ok\");\n",
12169                    "}\n",
12170                    "fn route(_path: &str, _handler: fn()) {}\n",
12171                ),
12172            ),
12173            extract_symbol_graph(
12174                "tests/feature_test.rs",
12175                Some("rust"),
12176                "fn subject() {}\nfn verifies_subject() { subject(); }\n",
12177            ),
12178            extract_symbol_graph(
12179                "config/appsettings.json",
12180                Some("json"),
12181                "{\"token\":\"super-secret\"}\n",
12182            ),
12183            extract_symbol_graph(
12184                "infra/main.tf",
12185                Some("terraform"),
12186                "resource \"null_resource\" \"fixture\" {}\n",
12187            ),
12188            extract_symbol_graph("data/input.txt", None, "input\n"),
12189            extract_symbol_graph("data/output.txt", None, ""),
12190        ];
12191        let nodes = graphs
12192            .iter()
12193            .map(|graph| {
12194                test_file_node(&graph.path, graph.language.as_deref().unwrap_or("unknown"))
12195            })
12196            .collect::<Vec<_>>();
12197        let packages = PackageIndex::from_graphs(&graphs)?;
12198        let projection = build_entity_projection(
12199            project, generation, &nodes, &graphs, &packages, true, &control,
12200        )?;
12201        let candidates = resolution_registry_from_exports(&projection, &control)?;
12202        let staged = finish_projection(
12203            project,
12204            generation,
12205            RepositoryGraphMutation::Full,
12206            &graphs,
12207            projection,
12208            &candidates,
12209            &control,
12210        )?;
12211        {
12212            let mut publication = store.begin_index_publication("accepted-relation-families")?;
12213            publication.begin_scan_replacement()?;
12214            publication.upsert_scan_node_batch(&nodes)?;
12215            publication.finish_scan_replacement()?;
12216            staged.apply(&mut publication, &control)?;
12217            publication.complete()?;
12218        }
12219        drop(store);
12220
12221        let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
12222        for capability in RELATION_FAMILY_CAPABILITIES
12223            .iter()
12224            .filter(|capability| capability.state == RelationFamilyState::Active)
12225        {
12226            for &family in capability.graph_relations {
12227                let page = reader.repository_graph_relations(
12228                    RepositoryGraphRelationQuery::Family { relation: family },
12229                    128,
12230                )?;
12231                require(!page.truncated, &format!("{family:?} page was truncated"))?;
12232                require(
12233                    !page.rows.is_empty(),
12234                    &format!("{family:?} had no reopened persisted relation"),
12235                )?;
12236                for relation in page.rows {
12237                    let occurrences = reader.repository_graph_occurrences(&relation, 32)?;
12238                    require(
12239                        !occurrences.rows.is_empty() && !occurrences.truncated,
12240                        &format!("{family:?} lost exact source occurrences"),
12241                    )?;
12242                    require(
12243                        !format!("{relation:?}").contains("super-secret"),
12244                        &format!("secret value escaped into persisted {family:?} relation"),
12245                    )?;
12246                }
12247            }
12248        }
12249        Ok(())
12250    }
12251
12252    #[test]
12253    fn accepted_relation_families_abstain_without_static_evidence() -> Result<(), Box<dyn Error>> {
12254        let project = ProjectInstanceId::from_bytes([13; 16])?;
12255        let generation = IndexGeneration::new(1);
12256        let control = IndexWorkControl::new(IndexCancellation::new(), None);
12257        let graphs = vec![
12258            extract_symbol_graph(
12259                "src/dynamic.rs",
12260                Some("rust"),
12261                concat!(
12262                    "use std::fs;\n",
12263                    "struct Client;\n",
12264                    "impl Client { fn get(&self, _path: &str, _handler: fn()) {} }\n",
12265                    "fn handler() {}\n",
12266                    "fn dynamic(client: &Client, route_path: &str, key: &str, file: &str) {\n",
12267                    "    client.get(\"/health\", handler);\n",
12268                    "    route(route_path, handler);\n",
12269                    "    let _ = std::env::var(key);\n",
12270                    "    let _ = fs::read_to_string(file);\n",
12271                    "    let _ = fs::write(file, \"super-secret\");\n",
12272                    "}\n",
12273                    "fn route(_path: &str, _handler: fn()) {}\n",
12274                ),
12275            ),
12276            extract_symbol_graph(
12277                "src/escaping.rs",
12278                Some("rust"),
12279                concat!(
12280                    "use std::fs;\n",
12281                    "fn unsafe_paths() {\n",
12282                    "    let _ = fs::read_to_string(\"../secret.txt\");\n",
12283                    "    let _ = fs::write(\"C:/secret.txt\", \"super-secret\");\n",
12284                    "}\n",
12285                ),
12286            ),
12287            extract_symbol_graph(
12288                "src/dynamic.js",
12289                Some("javascript"),
12290                concat!(
12291                    "function handler() {}\n",
12292                    "function middleware() {}\n",
12293                    "function route(...args) {}\n",
12294                    "route(\"/health\", handler, middleware);\n",
12295                ),
12296            ),
12297            extract_symbol_graph("docs/main.tf.example", None, "not infrastructure\n"),
12298            extract_symbol_graph("docs/k8s/README.md", None, "not infrastructure\n"),
12299            extract_symbol_graph("docs/cloudformation-notes.md", None, "not infrastructure\n"),
12300            extract_symbol_graph("config/settings.json.bak", None, "super-secret\n"),
12301        ];
12302        let packages = PackageIndex::from_graphs(&graphs)?;
12303        let projection =
12304            build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
12305        let candidates = resolution_registry_from_exports(&projection, &control)?;
12306        let staged = finish_projection(
12307            project,
12308            generation,
12309            RepositoryGraphMutation::Full,
12310            &graphs,
12311            projection,
12312            &candidates,
12313            &control,
12314        )?;
12315        for family in [
12316            ExtendedRelationKind::Tests,
12317            ExtendedRelationKind::RoutesTo,
12318            ExtendedRelationKind::Configures,
12319            ExtendedRelationKind::Deploys,
12320            ExtendedRelationKind::Reads,
12321            ExtendedRelationKind::Writes,
12322        ] {
12323            require(
12324                staged
12325                    .relations
12326                    .iter()
12327                    .all(|relation| relation.kind() != GraphRelationKind::Extended(family)),
12328                &format!("dynamic or lookalike input fabricated {family:?}"),
12329            )?;
12330        }
12331        require(
12332            staged
12333                .entities
12334                .iter()
12335                .all(|entity| !entity.key().canonical_identity().contains("super-secret")),
12336            "negative fixture leaked a secret into graph identity",
12337        )?;
12338        Ok(())
12339    }
12340
12341    #[test]
12342    fn qualified_symbol_identity_preserves_boundaries_and_compacts_stably()
12343    -> Result<(), Box<dyn Error>> {
12344        let name = GraphIdentityText::new("leaf")?;
12345        require_eq(
12346            &qualified_symbol_identity(&GraphIdentityText::new("outer")?, &name)?,
12347            &GraphIdentityText::new("outer::leaf")?,
12348            "shallow qualified identity",
12349        )?;
12350
12351        let exact_parent = GraphIdentityText::new(
12352            "x".repeat(MAX_GRAPH_IDENTITY_BYTES - "::".len() - name.as_str().len()),
12353        )?;
12354        let exact = qualified_symbol_identity(&exact_parent, &name)?;
12355        require_eq(
12356            &exact.as_str().len(),
12357            &MAX_GRAPH_IDENTITY_BYTES,
12358            "exact-boundary qualified identity bytes",
12359        )?;
12360        require(
12361            exact.as_str().ends_with("::leaf"),
12362            "exact-boundary qualified identity changed its readable suffix",
12363        )?;
12364
12365        let first_overbound_parent = GraphIdentityText::new(
12366            "x".repeat(MAX_GRAPH_IDENTITY_BYTES - "::".len() - name.as_str().len() + 1),
12367        )?;
12368        let compact = qualified_symbol_identity(&first_overbound_parent, &name)?;
12369        require(
12370            compact.as_str().len() <= MAX_GRAPH_IDENTITY_BYTES,
12371            "first overbound qualified identity remained oversized",
12372        )?;
12373        require(
12374            compact.as_str().ends_with("::leaf"),
12375            "compacted qualified identity lost its nearest symbol name",
12376        )?;
12377        require_eq(
12378            &qualified_symbol_identity(&first_overbound_parent, &name)?,
12379            &compact,
12380            "repeated compact qualified identity",
12381        )?;
12382
12383        let multibyte_parent = GraphIdentityText::new(format!(
12384            "{}x",
12385            "é".repeat((MAX_GRAPH_IDENTITY_BYTES - "::".len() - "界".len() - 1) / "é".len())
12386        ))?;
12387        let multibyte =
12388            qualified_symbol_identity(&multibyte_parent, &GraphIdentityText::new("界")?)?;
12389        require_eq(
12390            &multibyte.as_str().len(),
12391            &MAX_GRAPH_IDENTITY_BYTES,
12392            "multibyte exact-boundary qualified identity bytes",
12393        )?;
12394        require(
12395            multibyte.as_str().ends_with("::界"),
12396            "multibyte qualified identity changed its readable suffix",
12397        )?;
12398
12399        let parent_a = GraphIdentityText::new("a".repeat(MAX_GRAPH_IDENTITY_BYTES))?;
12400        let parent_b = GraphIdentityText::new("b".repeat(MAX_GRAPH_IDENTITY_BYTES))?;
12401        let scoped_a = qualified_symbol_identity(&parent_a, &name)?;
12402        let scoped_b = qualified_symbol_identity(&parent_b, &name)?;
12403        require(
12404            scoped_a != scoped_b,
12405            "distinct deep ancestors with an equal suffix shared one identity",
12406        )?;
12407        require(
12408            scoped_a != qualified_symbol_identity(&parent_a, &GraphIdentityText::new("other")?)?,
12409            "distinct overbound candidates shared one compact identity",
12410        )?;
12411        let (literal_parent, _) = scoped_a
12412            .as_str()
12413            .rsplit_once("::")
12414            .ok_or_else(|| io::Error::other("compact scope omitted its readable suffix"))?;
12415        require(
12416            source_symbol_identity(literal_parent.to_string()).is_err(),
12417            "compact scope namespace remained admissible as an exact source parent",
12418        )?;
12419        Ok(())
12420    }
12421
12422    #[test]
12423    fn qualified_symbol_parents_reject_invalid_raw_components() -> Result<(), Box<dyn Error>> {
12424        for (name, parent) in [
12425            ("bad\nname".to_string(), None),
12426            ("valid".to_string(), Some("bad\nparent".to_string())),
12427            (
12428                format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}literal"),
12429                Some("parent".to_string()),
12430            ),
12431            (
12432                "valid".to_string(),
12433                Some(format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}literal")),
12434            ),
12435            (
12436                "x".repeat(MAX_GRAPH_IDENTITY_BYTES + 1),
12437                Some("parent".to_string()),
12438            ),
12439        ] {
12440            let graph = SymbolGraph {
12441                path: "src/invalid.rs".to_string(),
12442                language: Some("rust".to_string()),
12443                parser: ParserKind::TreeSitter,
12444                symbols: vec![CodeSymbol {
12445                    path: "src/invalid.rs".to_string(),
12446                    language: Some("rust".to_string()),
12447                    name,
12448                    kind: SymbolKind::Function,
12449                    signature: "fn valid()".to_string(),
12450                    exported: false,
12451                    documentation: None,
12452                    line_start: 1,
12453                    line_end: 1,
12454                    source_selector: None,
12455                    parent,
12456                    parser: ParserKind::TreeSitter,
12457                    detail: Some("function_item".to_string()),
12458                }],
12459                relations: Vec::new(),
12460            };
12461            require(
12462                qualified_symbol_parents(&graph).is_err(),
12463                "invalid raw symbol identity reached qualified derivation",
12464            )?;
12465        }
12466        Ok(())
12467    }
12468
12469    #[test]
12470    fn qualified_symbol_parents_bound_four_thousand_deep_scopes() -> Result<(), Box<dyn Error>> {
12471        const DEPTH: usize = 4_000;
12472        let names = (0..DEPTH)
12473            .map(|index| format!("scope_{index:04}_{}", "x".repeat(229)))
12474            .collect::<Vec<_>>();
12475        let graph = SymbolGraph {
12476            path: "src/deep.rs".to_string(),
12477            language: Some("rust".to_string()),
12478            parser: ParserKind::TreeSitter,
12479            symbols: names
12480                .iter()
12481                .enumerate()
12482                .map(|(index, name)| CodeSymbol {
12483                    path: "src/deep.rs".to_string(),
12484                    language: Some("rust".to_string()),
12485                    name: name.clone(),
12486                    kind: SymbolKind::Module,
12487                    signature: name.clone(),
12488                    exported: false,
12489                    documentation: None,
12490                    line_start: index + 1,
12491                    line_end: DEPTH * 2 - index,
12492                    source_selector: None,
12493                    parent: index.checked_sub(1).map(|parent| names[parent].clone()),
12494                    parser: ParserKind::TreeSitter,
12495                    detail: Some("mod_item".to_string()),
12496                })
12497                .collect(),
12498            relations: Vec::new(),
12499        };
12500        let first = qualified_symbol_parents(&graph)?;
12501        require_eq(&first.len(), &DEPTH, "deep qualified parent count")?;
12502        require(
12503            first.first().is_some_and(Option::is_none),
12504            "deep root unexpectedly gained a parent",
12505        )?;
12506        require(
12507            first
12508                .iter()
12509                .flatten()
12510                .all(|parent| parent.as_str().len() <= MAX_GRAPH_IDENTITY_BYTES),
12511            "deep qualification retained an oversized parent",
12512        )?;
12513        require(
12514            first
12515                .iter()
12516                .flatten()
12517                .any(|parent| parent.as_str().starts_with("@projectatlas.scope.v1:")),
12518            "deep qualification never exercised compact scope identity",
12519        )?;
12520        require_eq(
12521            &qualified_symbol_parents(&graph)?,
12522            &first,
12523            "repeated deep qualified parents",
12524        )?;
12525        Ok(())
12526    }
12527
12528    #[test]
12529    fn qualified_symbol_scopes_produce_distinct_graph_entity_keys() -> Result<(), Box<dyn Error>> {
12530        let project = ProjectInstanceId::from_bytes([3; 16])?;
12531        let generation = IndexGeneration::new(1);
12532        let control = IndexWorkControl::new(IndexCancellation::new(), None);
12533        for (path, language, source, expected_parents) in [
12534            (
12535                "src/lib.rs",
12536                "rust",
12537                concat!(
12538                    "mod first { struct Runner; impl Runner { fn run(&self) {} } }\n",
12539                    "mod second { struct Runner; impl Runner { fn run(&self) {} } }\n",
12540                ),
12541                ["first::Runner", "second::Runner"],
12542            ),
12543            (
12544                "src/Runner.java",
12545                "java",
12546                concat!(
12547                    "class First { class Runner { void run() {} } }\n",
12548                    "class Second { class Runner { void run() {} } }\n",
12549                ),
12550                ["First::Runner", "Second::Runner"],
12551            ),
12552        ] {
12553            let graph = extract_symbol_graph(path, Some(language), source);
12554            let method_indices = graph
12555                .symbols
12556                .iter()
12557                .enumerate()
12558                .filter_map(|(index, symbol)| {
12559                    (symbol.kind == SymbolKind::Method && symbol.name == "run").then_some(index)
12560                })
12561                .collect::<Vec<_>>();
12562            require_eq(&method_indices.len(), &2, "scoped method count")?;
12563            let first_symbol = &graph.symbols[method_indices[0]];
12564            let second_symbol = &graph.symbols[method_indices[1]];
12565            require_eq(
12566                &first_symbol.parent.as_deref(),
12567                &Some("Runner"),
12568                "legacy leaf parent",
12569            )?;
12570            require_eq(
12571                &first_symbol.parent,
12572                &second_symbol.parent,
12573                "same legacy leaf parent",
12574            )?;
12575            require_eq(
12576                &first_symbol.signature,
12577                &second_symbol.signature,
12578                "same declaration signature",
12579            )?;
12580
12581            let graphs = vec![graph];
12582            let packages = PackageIndex::from_graphs(&graphs)?;
12583            let projection = build_entity_projection(
12584                project,
12585                generation,
12586                &[],
12587                &graphs,
12588                &packages,
12589                true,
12590                &control,
12591            )?;
12592            let owners = projection
12593                .owners_by_graph
12594                .get(path)
12595                .ok_or("scoped graph owners are missing")?;
12596            let first = owners.symbol_digests[method_indices[0]]
12597                .as_ref()
12598                .and_then(|digest| projection.entity_by_digest.get(digest))
12599                .ok_or("first scoped method entity is missing")?;
12600            let second = owners.symbol_digests[method_indices[1]]
12601                .as_ref()
12602                .and_then(|digest| projection.entity_by_digest.get(digest))
12603                .ok_or("second scoped method entity is missing")?;
12604            let EntitySelector::Symbol {
12605                symbol: first_selector,
12606            } = first.selector()
12607            else {
12608                return Err("first scoped entity is not a symbol".into());
12609            };
12610            let EntitySelector::Symbol {
12611                symbol: second_selector,
12612            } = second.selector()
12613            else {
12614                return Err("second scoped entity is not a symbol".into());
12615            };
12616            require_eq(
12617                &first_selector
12618                    .parent
12619                    .as_ref()
12620                    .map(GraphIdentityText::as_str),
12621                &Some(expected_parents[0]),
12622                "first graph identity parent",
12623            )?;
12624            require_eq(
12625                &second_selector
12626                    .parent
12627                    .as_ref()
12628                    .map(GraphIdentityText::as_str),
12629                &Some(expected_parents[1]),
12630                "second graph identity parent",
12631            )?;
12632            require(
12633                first.key() != second.key(),
12634                "independent semantic scopes shared one graph entity key",
12635            )?;
12636        }
12637        Ok(())
12638    }
12639
12640    #[test]
12641    fn external_classification_follows_effective_semantic_provider() -> Result<(), Box<dyn Error>> {
12642        for (language, target, system, identity) in [
12643            ("html", "import fs from \"node:fs\";", "node", "fs"),
12644            ("svelte", "import url from \"node:url\";", "node", "url"),
12645            ("vue", "import path from \"node:path\";", "node", "path"),
12646        ] {
12647            let case = resolution_case(language, RelationKind::Imports, target, &[]);
12648            let external = explicit_external_selector(&case.graph, &case.relation)?
12649                .ok_or("embedded ECMAScript external classification is missing")?;
12650            require_eq(&external.system.as_str(), &system, "external system")?;
12651            require_eq(&external.identity.as_str(), &identity, "external identity")?;
12652        }
12653
12654        let lock = resolution_case("cargo-lock", RelationKind::DependsOn, "serde-lock", &[]);
12655        require(
12656            explicit_external_selector(&lock.graph, &lock.relation)?.is_none(),
12657            "Cargo.lock was misclassified as an explicit external dependency",
12658        )?;
12659        Ok(())
12660    }
12661
12662    #[test]
12663    fn native_rust_external_identity_follows_the_binding_instead_of_its_spelling()
12664    -> Result<(), Box<dyn Error>> {
12665        let control = IndexWorkControl::new(IndexCancellation::new(), None);
12666        let project = ProjectInstanceId::from_bytes([40; 16])?;
12667        for (source, expected) in [
12668            ("mod std {} fn caller(){std::fs::read(\"x\");}", None),
12669            (
12670                "mod fake {} use crate::fake as std; fn caller(){std::fs::read(\"x\");}",
12671                None,
12672            ),
12673            (
12674                "use std; fn caller(){std::fs::read(\"x\");}",
12675                Some("std::fs::read"),
12676            ),
12677            (
12678                "use std::mem::drop as dispose; fn caller(){dispose(1);}",
12679                Some("std::mem::drop"),
12680            ),
12681            (
12682                "use core::mem as std; fn caller(){std::drop(1);}",
12683                Some("core::mem::drop"),
12684            ),
12685            (
12686                "use std::mem::drop as dispose; fn caller(dispose:fn(u8)){dispose(1);}",
12687                None,
12688            ),
12689        ] {
12690            let extracted = projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
12691                "src/lib.rs",
12692                Some("rust"),
12693                source,
12694                &control,
12695            )?;
12696            let graph = extracted.graph();
12697            let facts = extracted.semantics();
12698            let projection = projectatlas_symbols::derive_resolution_keys_with_context(
12699                project,
12700                None,
12701                graph,
12702                projectatlas_symbols::ResolutionProjectionContext::default()
12703                    .with_source_semantics(facts),
12704            )?;
12705            let index = super::GraphSymbolIndex::new(graph, &control)?
12706                .with_source_calls(projection.source_calls(), &control)?
12707                .with_rust_toolchain_imports(projection.rust_toolchain_imports());
12708            let call = facts.calls().last().ok_or("fixture call missing")?;
12709            let external = super::source_external_selector(
12710                graph,
12711                &graph.relations[call.relation],
12712                Some(call.relation),
12713                &index,
12714            )?;
12715            require_eq(
12716                &external.as_ref().map(|external| external.identity.as_str()),
12717                &expected,
12718                source,
12719            )?;
12720        }
12721        Ok(())
12722    }
12723
12724    #[test]
12725    fn grouped_rust_imports_use_only_their_common_external_module() -> Result<(), Box<dyn Error>> {
12726        let grouped = resolution_case("rust", RelationKind::Imports, "use std::{fs, io};", &[]);
12727        require_eq(
12728            &rust_toolchain_identity(&grouped.relation),
12729            &Some("std".to_string()),
12730            "grouped Rust external root",
12731        )?;
12732        let nested = resolution_case(
12733            "rust",
12734            RelationKind::Imports,
12735            "use std::fs::{read, write};",
12736            &[],
12737        );
12738        require_eq(
12739            &rust_toolchain_identity(&nested.relation),
12740            &Some("std::fs".to_string()),
12741            "nested grouped Rust external module",
12742        )?;
12743        let ordinary = resolution_case("rust", RelationKind::Imports, "use std::fs;", &[]);
12744        require_eq(
12745            &rust_toolchain_identity(&ordinary.relation),
12746            &Some("std::fs".to_string()),
12747            "ordinary Rust external module",
12748        )?;
12749        Ok(())
12750    }
12751
12752    #[test]
12753    fn explicit_export_forwarding_tracks_actual_targets_and_intermediate_changes()
12754    -> Result<(), Box<dyn Error>> {
12755        let temp = tempfile::tempdir()?;
12756        let root = temp.path();
12757        let sources = [
12758            ("worker.js", "export function work() {}"),
12759            ("other.js", "export function other() {}"),
12760            ("inner.js", "export {work as launch} from './worker.js';"),
12761            (
12762                "barrel.js",
12763                "export {launch as entry} from './inner.js'; export * as tools from './worker.js'; export {work as collision} from './worker.js'; export {other as collision} from './other.js';",
12764            ),
12765            ("loopA.js", "export {cycle} from './loopB.js';"),
12766            ("loopB.js", "export {cycle} from './loopC.js';"),
12767            ("loopC.js", "export {cycle} from './loopA.js';"),
12768            ("route1.js", "export {cycle} from './route2.js';"),
12769            ("route2.js", "export {cycle} from './loopC.js';"),
12770            (
12771                "second.js",
12772                "import {cycle} from './route1.js'; function second() {cycle();}",
12773            ),
12774            ("wild.js", "export * from './worker.js';"),
12775            (
12776                "caller.js",
12777                "import {entry, tools, collision} from './barrel.js'; import {cycle} from './loopA.js'; import {work} from './wild.js'; function clean() {entry(); tools.work();} function blocked() {collision(); cycle(); work();} function shadow(entry) {entry();}",
12778            ),
12779        ];
12780        let mut nodes = Vec::new();
12781        let control = IndexWorkControl::new(IndexCancellation::new(), None);
12782        let make_parsed =
12783            |path: &str, content: &str| -> Result<SymbolParseSuccess, Box<dyn Error>> {
12784                let (_, graph, semantics) =
12785                    projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
12786                        path,
12787                        Some("javascript"),
12788                        content,
12789                        &control,
12790                    )?
12791                    .into_parts();
12792                Ok(SymbolParseSuccess {
12793                    path: path.to_string(),
12794                    source_parser: graph.parser,
12795                    graph,
12796                    source_semantics: Some(Box::new(semantics)),
12797                    markdown_facts: None,
12798                    summary: "static export source".to_string(),
12799                    summary_is_structural: false,
12800                    purpose_suggestion: None,
12801                })
12802            };
12803        let mut symbols = empty_symbol_build_stage();
12804        for (path, content) in sources {
12805            fs::write(root.join(path), content)?;
12806            nodes.push(test_file_node(path, "javascript"));
12807            symbols
12808                .changes
12809                .push(SymbolProjectionChange::Parsed(make_parsed(path, content)?));
12810        }
12811        capture_test_file_hashes(root, &mut nodes)?;
12812        symbols.report.candidates = nodes.len();
12813        symbols.report.parsed = nodes.len();
12814        symbols.identity_admission = super::admit_symbol_build_stage(&mut symbols, &control)?;
12815        fs::create_dir_all(root.join(".projectatlas"))?;
12816        let mut store =
12817            AtlasStore::open_for_project(&root.join(".projectatlas/projectatlas.db"), root)?;
12818        let project = store.project_instance_id()?.ok_or("missing project")?;
12819        let policy = RootScanPolicy::discover(root, &ScanOptions::default(), &control)?;
12820        let full = stage_full_repository_graph(
12821            &store,
12822            root,
12823            IndexGeneration::ZERO,
12824            &nodes,
12825            &policy,
12826            &symbols,
12827            &control,
12828        )?;
12829        let resolved_targets = |stage: &StagedRepositoryGraph| {
12830            stage
12831                .relations
12832                .iter()
12833                .filter_map(|relation| {
12834                    if relation.kind() != GraphRelationKind::Legacy(RelationKind::Calls) {
12835                        return None;
12836                    }
12837                    match relation.resolution() {
12838                        RelationResolution::Resolved {
12839                            selector: ReusableTargetSelector::Symbol { symbol },
12840                            ..
12841                        } => Some((
12842                            symbol.file.as_str().to_string(),
12843                            symbol.name.as_str().to_string(),
12844                        )),
12845                        _ => None,
12846                    }
12847                })
12848                .collect::<BTreeSet<_>>()
12849        };
12850        require_eq(
12851            &resolved_targets(&full),
12852            &BTreeSet::from([("worker.js".to_string(), "work".to_string())]),
12853            "forwarded source target",
12854        )?;
12855        let unresolved_calls = |stage: &StagedRepositoryGraph| {
12856            stage
12857                .relations
12858                .iter()
12859                .filter(|relation| {
12860                    relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12861                        && matches!(relation.resolution(), RelationResolution::Unresolved { .. })
12862                })
12863                .count()
12864        };
12865        require_eq(
12866            &unresolved_calls(&full),
12867            &5,
12868            "duplicate, cycle, wildcard and shadow calls must abstain",
12869        )?;
12870        require_eq(
12871            &full.source_recovery.previously_unchanged,
12872            &0,
12873            "fresh source was reparsed during forwarding",
12874        )?;
12875        let inner_cause = super::document_file_resolution_key(project, "inner.js")?;
12876        require(
12877            full.relation_dependencies
12878                .iter()
12879                .any(|binding| binding.key() == &inner_cause),
12880            "call lost intermediate file invalidation cause",
12881        )?;
12882        publish_full_staged_graph(&mut store, &nodes, &full, &control, "forwarding initial")?;
12883        for change in &symbols.changes {
12884            if let SymbolProjectionChange::Parsed(parsed) = change {
12885                store.replace_symbol_graph(&parsed.graph)?;
12886            }
12887        }
12888        let base = store
12889            .repository_graph_generation()?
12890            .ok_or("missing graph generation")?;
12891        let content = "export {other as launch} from './other.js';";
12892        fs::write(root.join("inner.js"), content)?;
12893        capture_test_file_hashes(root, &mut nodes)?;
12894        let mut changed = empty_symbol_build_stage();
12895        changed
12896            .changes
12897            .push(SymbolProjectionChange::Parsed(make_parsed(
12898                "inner.js", content,
12899            )?));
12900        changed.identity_admission = super::admit_symbol_build_stage(&mut changed, &control)?;
12901        let incremental = stage_incremental_repository_graph(
12902            &store,
12903            root,
12904            base,
12905            &nodes,
12906            &["inner.js".to_string()],
12907            &policy,
12908            &changed,
12909            &control,
12910        )?;
12911        require_eq(
12912            &resolved_targets(&incremental),
12913            &BTreeSet::from([
12914                ("worker.js".to_string(), "work".to_string()),
12915                ("other.js".to_string(), "other".to_string()),
12916            ]),
12917            "unchanged caller after forwarding source change",
12918        )?;
12919        require(
12920            incremental.source_recovery.additional_candidates > 0,
12921            "extra unchanged context source parsing was not accounted",
12922        )?;
12923        publish_full_staged_graph(
12924            &mut store,
12925            &nodes,
12926            &incremental,
12927            &control,
12928            "forwarding changed intermediate",
12929        )?;
12930        store.replace_symbol_graph(&make_parsed("inner.js", content)?.graph)?;
12931        let base = store
12932            .repository_graph_generation()?
12933            .ok_or("missing changed generation")?;
12934        let content = "export {work as cycle} from './worker.js';";
12935        fs::write(root.join("loopB.js"), content)?;
12936        capture_test_file_hashes(root, &mut nodes)?;
12937        let mut changed = empty_symbol_build_stage();
12938        changed
12939            .changes
12940            .push(SymbolProjectionChange::Parsed(make_parsed(
12941                "loopB.js", content,
12942            )?));
12943        changed.identity_admission = super::admit_symbol_build_stage(&mut changed, &control)?;
12944        let repaired = stage_incremental_repository_graph(
12945            &store,
12946            root,
12947            base,
12948            &nodes,
12949            &["loopB.js".to_string()],
12950            &policy,
12951            &changed,
12952            &control,
12953        )?;
12954        require(
12955            matches!(&repaired.mutation, RepositoryGraphMutation::AffectedPaths(paths) if paths.iter().any(|path| path == "second.js")),
12956            "cached cycle suffix lost the file whose edit breaks the cycle",
12957        )?;
12958        require_eq(
12959            &unresolved_calls(&repaired),
12960            &3,
12961            "broken cycle did not repair both unchanged consumers",
12962        )?;
12963        Ok(())
12964    }
12965
12966    #[test]
12967    fn forwarding_query_cache_preserves_the_remaining_depth_budget() -> Result<(), Box<dyn Error>> {
12968        let temp = tempfile::tempdir()?;
12969        let root = temp.path();
12970        let mut files = vec![(
12971            "worker.js".to_string(),
12972            "export function work() {}".to_string(),
12973        )];
12974        for index in 0..70 {
12975            files.push((
12976                format!("long{index:02}.js"),
12977                if index == 69 {
12978                    "export {work as entry} from './worker.js';".to_string()
12979                } else {
12980                    format!("export {{entry}} from './long{:02}.js';", index + 1)
12981                },
12982            ));
12983        }
12984        files.push(("caller.js".to_string(), "import {entry as short} from './long10.js'; import {entry as long} from './long00.js'; function short_call() {short();} function long_call() {long();}".to_string()));
12985        let control = IndexWorkControl::new(IndexCancellation::new(), None);
12986        let mut nodes = Vec::new();
12987        let mut symbols = empty_symbol_build_stage();
12988        for (path, content) in files {
12989            fs::write(root.join(&path), &content)?;
12990            nodes.push(test_file_node(&path, "javascript"));
12991            let (_, graph, semantics) =
12992                projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
12993                    &path,
12994                    Some("javascript"),
12995                    &content,
12996                    &control,
12997                )?
12998                .into_parts();
12999            symbols
13000                .changes
13001                .push(SymbolProjectionChange::Parsed(SymbolParseSuccess {
13002                    path,
13003                    source_parser: graph.parser,
13004                    graph,
13005                    source_semantics: Some(Box::new(semantics)),
13006                    markdown_facts: None,
13007                    summary: "bounded forwarding source".to_string(),
13008                    summary_is_structural: false,
13009                    purpose_suggestion: None,
13010                }));
13011        }
13012        capture_test_file_hashes(root, &mut nodes)?;
13013        symbols.identity_admission = super::admit_symbol_build_stage(&mut symbols, &control)?;
13014        fs::create_dir_all(root.join(".projectatlas"))?;
13015        let store =
13016            AtlasStore::open_for_project(&root.join(".projectatlas/projectatlas.db"), root)?;
13017        let policy = RootScanPolicy::discover(root, &ScanOptions::default(), &control)?;
13018        let full = stage_full_repository_graph(
13019            &store,
13020            root,
13021            IndexGeneration::ZERO,
13022            &nodes,
13023            &policy,
13024            &symbols,
13025            &control,
13026        )?;
13027        let owners = full
13028            .entities
13029            .iter()
13030            .map(|entity| (entity.key().digest(), entity))
13031            .collect::<BTreeMap<_, _>>();
13032        let mut outcomes = BTreeMap::new();
13033        for relation in &full.relations {
13034            if relation.kind() != GraphRelationKind::Legacy(RelationKind::Calls) {
13035                continue;
13036            }
13037            let owner = owners
13038                .get(relation.source().digest())
13039                .ok_or("missing depth fixture owner")?;
13040            let EntitySelector::Symbol { symbol } = owner.selector() else {
13041                return Err("depth call lost symbol owner".into());
13042            };
13043            outcomes.insert(symbol.name.as_str(), relation.resolution());
13044        }
13045        require(
13046            matches!(
13047                outcomes.get("short_call"),
13048                Some(RelationResolution::Resolved { .. })
13049            ),
13050            "supported shorter chain did not resolve",
13051        )?;
13052        require(
13053            matches!(
13054                outcomes.get("long_call"),
13055                Some(RelationResolution::Unresolved { .. })
13056            ),
13057            "cached target bypassed the longer query's depth bound",
13058        )?;
13059        require_eq(
13060            &full.source_recovery.previously_unchanged,
13061            &0,
13062            "forwarding parsed fresh targets again",
13063        )?;
13064        Ok(())
13065    }
13066
13067    #[test]
13068    fn no_forwarding_preflight_preserves_fast_return_and_observes_cancellation()
13069    -> Result<(), Box<dyn Error>> {
13070        let temp = tempfile::tempdir()?;
13071        let root = temp.path();
13072        let content = "function local() {} function caller() {local();}";
13073        fs::write(root.join("caller.js"), content)?;
13074        let mut nodes = vec![test_file_node("caller.js", "javascript")];
13075        capture_test_file_hashes(root, &mut nodes)?;
13076        let control = IndexWorkControl::new(IndexCancellation::new(), None);
13077        let (_, graph, facts) =
13078            projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
13079                "caller.js",
13080                Some("javascript"),
13081                content,
13082                &control,
13083            )?
13084            .into_parts();
13085        let source = super::SourceGraph::Staged {
13086            graph: &graph,
13087            semantics: Some(&facts),
13088        };
13089        fs::create_dir_all(root.join(".projectatlas"))?;
13090        let store =
13091            AtlasStore::open_for_project(&root.join(".projectatlas/projectatlas.db"), root)?;
13092        let project = store
13093            .project_instance_id()?
13094            .ok_or("missing preflight project")?;
13095        let packages = PackageIndex::from_graphs(&[&graph])?;
13096        let configured = projectatlas_symbols::ConfiguredModuleResolution::default();
13097        let entities = super::build_entity_projection_with_config(
13098            project,
13099            IndexGeneration::new(1),
13100            &nodes,
13101            &[&graph],
13102            &packages,
13103            &configured,
13104            None,
13105            true,
13106            &control,
13107        )?;
13108        let mut candidates = super::resolution_registry_from_exports(&entities, &control)?;
13109        let mut cache = super::SourceBindingCache::new(root, &nodes, None);
13110        let (bindings, bytes) = super::binding_resolution::resolve_source_bindings(
13111            &store,
13112            project,
13113            IndexGeneration::new(1),
13114            &[&source],
13115            &packages,
13116            &configured,
13117            &mut cache,
13118            &entities,
13119            &mut candidates,
13120            None,
13121            &BTreeSet::new(),
13122            &control,
13123            super::super::MAX_PUBLICATION_STAGING_BYTES,
13124        )?;
13125        require(
13126            bindings.is_empty() && bytes == 0,
13127            "ordinary calls allocated forwarding state",
13128        )?;
13129        let cancellation = IndexCancellation::new();
13130        cancellation.cancel();
13131        let canceled = IndexWorkControl::new(cancellation, None);
13132        let result = super::binding_resolution::resolve_source_bindings(
13133            &store,
13134            project,
13135            IndexGeneration::new(1),
13136            &[&source],
13137            &packages,
13138            &configured,
13139            &mut cache,
13140            &entities,
13141            &mut candidates,
13142            None,
13143            &BTreeSet::new(),
13144            &canceled,
13145            super::super::MAX_PUBLICATION_STAGING_BYTES,
13146        );
13147        require(
13148            matches!(
13149                result,
13150                Err(CliError::IndexWork(IndexWorkFailure::Cancelled { .. }))
13151            ),
13152            "no-forwarding preflight ignored cancellation",
13153        )?;
13154        Ok(())
13155    }
13156
13157    #[test]
13158    fn forwarding_file_markers_never_become_or_narrow_callable_targets()
13159    -> Result<(), Box<dyn Error>> {
13160        let project = ProjectInstanceId::from_bytes([46; 16])?;
13161        let generation = IndexGeneration::new(1);
13162        let key = test_resolution_key(project, "forwarding-slot")?;
13163        let file = GraphEntity::new(
13164            project,
13165            EntitySelector::File {
13166                path: RepositoryFilePath::new(Path::new("src/barrel.ts"))?,
13167            },
13168            generation,
13169        )?;
13170        let declaration = test_symbol_entity(project, generation, "src/direct.ts", "work")?;
13171        let mut registry = ProjectResolutionRegistry::default();
13172        registry.insert_candidate(&key, &file)?;
13173        let control = IndexWorkControl::new(IndexCancellation::new(), None);
13174        let staged = BTreeMap::new();
13175        let calls = registry_resolution_matches(
13176            std::slice::from_ref(&key),
13177            super::ResolutionCandidateKind::Declaration,
13178            &registry,
13179            &staged,
13180            &control,
13181        )?;
13182        require(
13183            calls.count == 0 && calls.first.is_none(),
13184            "file marker became a callable target",
13185        )?;
13186        registry.insert_candidate(&key, &declaration)?;
13187        let calls = registry_resolution_matches(
13188            std::slice::from_ref(&key),
13189            super::ResolutionCandidateKind::Declaration,
13190            &registry,
13191            &staged,
13192            &control,
13193        )?;
13194        require(
13195            calls.count == 0 && calls.first.is_none(),
13196            "dropping a competing forwarding marker narrowed an unrelated declaration",
13197        )?;
13198        let ordinary = registry_resolution_matches(
13199            std::slice::from_ref(&key),
13200            super::ResolutionCandidateKind::Any,
13201            &registry,
13202            &staged,
13203            &control,
13204        )?;
13205        require_eq(&ordinary.count, &2, "non-call lookup lost file evidence")?;
13206        Ok(())
13207    }
13208
13209    #[test]
13210    fn registry_candidate_merge_deduplicates_exactly_and_observes_cancellation()
13211    -> Result<(), Box<dyn Error>> {
13212        let project = ProjectInstanceId::from_bytes([6; 16])?;
13213        let generation = IndexGeneration::new(1);
13214        let first_key = test_resolution_key(project, "first-key")?;
13215        let second_key = test_resolution_key(project, "second-key")?;
13216        let first = test_symbol_entity(project, generation, "src/first.rs", "first")?;
13217        let second = test_symbol_entity(project, generation, "src/second.rs", "second")?;
13218        let mut registry = ProjectResolutionRegistry::default();
13219        registry.insert_candidate(&first_key, &first)?;
13220        registry.insert_candidate(&second_key, &first)?;
13221        registry.insert_candidate(&second_key, &second)?;
13222        let control = IndexWorkControl::new(IndexCancellation::new(), None);
13223        let staged_entities = BTreeMap::new();
13224        let matches = registry_resolution_matches(
13225            &[first_key.clone(), second_key.clone()],
13226            super::ResolutionCandidateKind::Any,
13227            &registry,
13228            &staged_entities,
13229            &control,
13230        )?;
13231        require_eq(&matches.count, &2, "distinct merged candidate count")?;
13232        let expected_first = [&first, &second]
13233            .into_iter()
13234            .min_by_key(|entity| entity.key().digest())
13235            .ok_or("candidate fixture is empty")?;
13236        require_eq(
13237            &matches.first.map(|entity| entity.key().digest()),
13238            &Some(expected_first.key().digest()),
13239            "stable first candidate",
13240        )?;
13241
13242        let cancellation = IndexCancellation::new();
13243        let canceled_control = IndexWorkControl::new(cancellation.clone(), None);
13244        cancellation.cancel();
13245        require(
13246            registry_resolution_matches(
13247                &[first_key, second_key],
13248                super::ResolutionCandidateKind::Any,
13249                &registry,
13250                &staged_entities,
13251                &canceled_control,
13252            )
13253            .is_err(),
13254            "candidate merge ignored cancellation",
13255        )?;
13256        Ok(())
13257    }
13258
13259    #[test]
13260    fn duplicate_ambiguous_relations_keep_the_largest_candidate_count() -> Result<(), Box<dyn Error>>
13261    {
13262        let project = ProjectInstanceId::from_bytes([15; 16])?;
13263        let generation = IndexGeneration::new(1);
13264        let source = test_file_entity(project, generation, "src/duplicate.ts")?;
13265        let relation = |candidates| -> Result<LogicalRelation, Box<dyn Error>> {
13266            Ok(LogicalRelation::new(
13267                &source,
13268                GraphRelationKind::from_legacy(RelationKind::Contains),
13269                RelationResolution::Ambiguous {
13270                    reference: GraphIdentityText::new("declarations")?,
13271                    candidates: NonZeroU32::new(candidates).ok_or("candidate count was zero")?,
13272                },
13273                ConfidenceClass::Exact,
13274                Completeness::Complete,
13275                generation,
13276            )?)
13277        };
13278        let mut relations = BTreeMap::new();
13279        insert_relation(
13280            &mut relations,
13281            relation(2)?,
13282            "src/duplicate.ts",
13283            "logical",
13284            0,
13285        )?;
13286        insert_relation(
13287            &mut relations,
13288            relation(3)?,
13289            "src/duplicate.ts",
13290            "logical",
13291            1,
13292        )?;
13293        insert_relation(
13294            &mut relations,
13295            relation(2)?,
13296            "src/duplicate.ts",
13297            "logical",
13298            2,
13299        )?;
13300        let retained = relations
13301            .into_values()
13302            .next()
13303            .ok_or("deduplicated relation was not retained")?;
13304        require(
13305            matches!(
13306                retained.resolution(),
13307                RelationResolution::Ambiguous { candidates, .. } if candidates.get() == 3
13308            ),
13309            "deduplicated ambiguity did not retain the largest candidate count",
13310        )?;
13311
13312        let mut conflicting = BTreeMap::new();
13313        insert_relation(
13314            &mut conflicting,
13315            relation(2)?,
13316            "src/duplicate.ts",
13317            "logical",
13318            0,
13319        )?;
13320        let different_confidence = LogicalRelation::new(
13321            &source,
13322            GraphRelationKind::from_legacy(RelationKind::Contains),
13323            RelationResolution::Ambiguous {
13324                reference: GraphIdentityText::new("declarations")?,
13325                candidates: NonZeroU32::new(2).ok_or("candidate count was zero")?,
13326            },
13327            ConfidenceClass::High,
13328            Completeness::Complete,
13329            generation,
13330        )?;
13331        require(
13332            insert_relation(
13333                &mut conflicting,
13334                different_confidence,
13335                "src/duplicate.ts",
13336                "logical",
13337                1,
13338            )
13339            .is_err(),
13340            "a non-ambiguity conflict was merged",
13341        )?;
13342        Ok(())
13343    }
13344
13345    #[test]
13346    fn same_file_private_calls_resolve_and_duplicate_declarations_stay_ambiguous()
13347    -> Result<(), Box<dyn Error>> {
13348        let project = ProjectInstanceId::from_bytes([5; 16])?;
13349        let generation = IndexGeneration::new(1);
13350        let control = IndexWorkControl::new(IndexCancellation::new(), None);
13351        let private_graph = extract_symbol_graph(
13352            "src/private.rs",
13353            Some("rust"),
13354            "pub fn caller() { helper(); }\nfn helper() {}\n",
13355        );
13356        require(
13357            private_graph
13358                .symbols
13359                .iter()
13360                .any(|symbol| symbol.name == "helper" && !symbol.exported),
13361            "fixture helper was not private",
13362        )?;
13363        let packages = PackageIndex::from_graphs(std::slice::from_ref(&private_graph))?;
13364        let projection = build_entity_projection(
13365            project,
13366            generation,
13367            &[],
13368            std::slice::from_ref(&private_graph),
13369            &packages,
13370            true,
13371            &control,
13372        )?;
13373        let candidates = resolution_registry_from_exports(&projection, &control)?;
13374        let staged = finish_projection(
13375            project,
13376            generation,
13377            RepositoryGraphMutation::Full,
13378            std::slice::from_ref(&private_graph),
13379            projection,
13380            &candidates,
13381            &control,
13382        )?;
13383        let helper_call = staged
13384            .relations
13385            .iter()
13386            .find(|relation| {
13387                matches!(
13388                    relation.resolution(),
13389                    RelationResolution::Resolved {
13390                        selector: ReusableTargetSelector::Symbol { symbol },
13391                        ..
13392                    } if symbol.name.as_str() == "helper"
13393                        && symbol.file.as_str() == "src/private.rs"
13394                )
13395            })
13396            .ok_or("private same-file helper call did not resolve")?;
13397        require_eq(
13398            &helper_call.kind(),
13399            &GraphRelationKind::from_legacy(RelationKind::Calls),
13400            "private same-file relation kind",
13401        )?;
13402
13403        let duplicate_graph = SymbolGraph {
13404            path: "src/duplicate.rs".to_string(),
13405            language: Some("rust".to_string()),
13406            parser: ParserKind::TreeSitter,
13407            symbols: vec![
13408                test_code_symbol("src/duplicate.rs", "caller", Some("Owner"), "fn caller()"),
13409                test_code_symbol(
13410                    "src/duplicate.rs",
13411                    "helper",
13412                    Some("Owner"),
13413                    "fn helper(first: u8)",
13414                ),
13415                test_code_symbol(
13416                    "src/duplicate.rs",
13417                    "helper",
13418                    Some("Owner"),
13419                    "fn helper(second: u16)",
13420                ),
13421                test_code_symbol(
13422                    "src/duplicate.rs",
13423                    "helper",
13424                    Some("Unrelated"),
13425                    "fn helper()",
13426                ),
13427            ],
13428            relations: vec![SymbolRelation {
13429                path: "src/duplicate.rs".to_string(),
13430                source_name: "caller".to_string(),
13431                target_name: "helper".to_string(),
13432                kind: RelationKind::Calls,
13433                line: 1,
13434                context: "helper()".to_string(),
13435                parser: ParserKind::TreeSitter,
13436            }],
13437        };
13438        let packages = PackageIndex::from_graphs(std::slice::from_ref(&duplicate_graph))?;
13439        let projection = build_entity_projection(
13440            project,
13441            generation,
13442            &[],
13443            std::slice::from_ref(&duplicate_graph),
13444            &packages,
13445            true,
13446            &control,
13447        )?;
13448        let candidates = resolution_registry_from_exports(&projection, &control)?;
13449        let staged = finish_projection(
13450            project,
13451            generation,
13452            RepositoryGraphMutation::Full,
13453            std::slice::from_ref(&duplicate_graph),
13454            projection,
13455            &candidates,
13456            &control,
13457        )?;
13458        require(
13459            staged.relations.iter().any(|relation| {
13460                matches!(
13461                    relation.resolution(),
13462                    RelationResolution::Ambiguous {
13463                        reference,
13464                        candidates,
13465                    } if reference.as_str() == "helper" && candidates.get() == 2
13466                )
13467            }),
13468            "duplicate same-file declarations did not remain ambiguous",
13469        )?;
13470        Ok(())
13471    }
13472
13473    #[test]
13474    fn php_scoped_and_case_insensitive_calls_resolve_without_member_edges()
13475    -> Result<(), Box<dyn Error>> {
13476        let project = ProjectInstanceId::from_bytes([31; 16])?;
13477        let generation = IndexGeneration::new(1);
13478        let control = IndexWorkControl::new(IndexCancellation::new(), None);
13479        let php_graph = extract_symbol_graph(
13480            "src/service.php",
13481            Some("php"),
13482            r"<?php
13483class Service {
13484    public string $boot;
13485    public static function prepare(): void {}
13486    public static function finish(): void {}
13487    public static function boot(): void {}
13488    public function run(): void {
13489        self::prepare();
13490        static::finish();
13491        Service::boot();
13492        SERVICE::BOOT();
13493        parent::inherited();
13494        $this->boot();
13495    }
13496}
13497function helper(): void {}
13498function caller(): void { HELPER(); }
13499namespace Atlas\Domain;
13500class QualifiedService {
13501    public static function boot(): void {}
13502    public static function qualified_run(): void {
13503        \Atlas\Domain\QualifiedService::boot();
13504        \Missing\Domain\QualifiedService::boot();
13505        QualifiedService::boot();
13506    }
13507}
13508namespace Other\Domain;
13509class QualifiedService {
13510    public static function boot(): void {}
13511}
13512function relative_run(): void {
13513    Atlas\Domain\QualifiedService::boot();
13514    Alias\QualifiedService::boot();
13515    namespace\QualifiedService::boot();
13516}
13517namespace TopLevel;
13518function top_level_helper(): void {}
13519top_level_helper();
13520namespace Foo;
13521function helper(): void {}
13522function qualified_helper_run(): void {
13523    \Foo\helper();
13524}
13525function rooted_global_helper_run(): void {
13526    \helper();
13527}
13528function missing_helper_run(): void {
13529    \Missing\helper();
13530}
13531class NamespacedService {
13532    public function namespaced_run(): void {
13533        helper();
13534    }
13535}
13536class DuplicateA {
13537    public static function prepare(): void {}
13538    public static function collision_run(): void {
13539        self::prepare();
13540    }
13541}
13542class DuplicateB {
13543    public static function prepare(): void {}
13544    public static function collision_run(): void {
13545        self::prepare();
13546    }
13547}
13548",
13549        );
13550        require(
13551            php_graph
13552                .relations
13553                .iter()
13554                .filter(|relation| relation.kind == RelationKind::Calls)
13555                .all(|relation| relation.target_name != "boot"),
13556            "dynamic member calls must not become unscoped PHP calls",
13557        )?;
13558        let finish_graph = |graph: &SymbolGraph| {
13559            let packages = PackageIndex::from_graphs(std::slice::from_ref(graph))?;
13560            let projection = build_entity_projection(
13561                project,
13562                generation,
13563                &[],
13564                std::slice::from_ref(graph),
13565                &packages,
13566                true,
13567                &control,
13568            )?;
13569            let candidates = resolution_registry_from_exports(&projection, &control)?;
13570            finish_projection(
13571                project,
13572                generation,
13573                RepositoryGraphMutation::Full,
13574                std::slice::from_ref(graph),
13575                projection,
13576                &candidates,
13577                &control,
13578            )
13579        };
13580        let staged = finish_graph(&php_graph)?;
13581        for declaration in [
13582            "class Service {\nconst FLAG = 1;\npublic $value;\nfunction run() {}\n}",
13583            "interface Service {\nconst FLAG = 1;\nfunction run();\n}",
13584            "trait Service {\nconst FLAG = 1;\npublic $value;\nfunction run() {}\n}",
13585            "enum Service {\ncase Ready;\nconst FLAG = 1;\nfunction run() {}\n}",
13586        ] {
13587            for (source, namespaces) in [
13588                (
13589                    format!(
13590                        "<?php namespace A {{\n{declaration}\n}}\nnamespace B {{\n{declaration}\n}}"
13591                    ),
13592                    ["A", "B"],
13593                ),
13594                (
13595                    format!("<?php namespace A;\n{declaration}\nnamespace B;\n{declaration}"),
13596                    ["A", "B"],
13597                ),
13598                (
13599                    format!(
13600                        "<?php namespace Service {{\n{declaration}\n}}\nnamespace B {{\n{declaration}\n}}"
13601                    ),
13602                    ["Service", "B"],
13603                ),
13604                (
13605                    format!("<?php namespace Service;\n{declaration}\nnamespace B;\n{declaration}"),
13606                    ["Service", "B"],
13607                ),
13608            ] {
13609                let graph = extract_symbol_graph("src/containment.php", Some("php"), &source);
13610                require_eq(
13611                    &graph
13612                        .symbols
13613                        .iter()
13614                        .filter(|symbol| {
13615                            symbol.name == "run" && symbol.parent.as_deref() == Some("Service")
13616                        })
13617                        .count(),
13618                    &2,
13619                    "PHP parser retains unqualified member parents",
13620                )?;
13621                let contained = finish_graph(&graph)?;
13622                for namespace in namespaces {
13623                    let parent = format!("{namespace}::Service");
13624                    for name in ["run", "FLAG"] {
13625                        require(
13626                            contained.relations.iter().any(|relation| {
13627                                relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
13628                                    && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
13629                                        if symbol.name.as_str() == name && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(parent.as_str()))
13630                                    && contained.entities.iter().any(|entity| entity.key() == relation.source()
13631                                        && matches!(entity.selector(), EntitySelector::Symbol { symbol }
13632                                            if symbol.name.as_str() == "Service" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(namespace)))
13633                            }),
13634                            &format!("PHP containment must keep its exact namespace owner: {parent}::{name}"),
13635                        )?;
13636                    }
13637                    require(
13638                        contained.relations.iter().any(|relation| {
13639                            relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
13640                                && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
13641                                    if symbol.name.as_str() == "Service" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(namespace))
13642                                && contained.entities.iter().any(|entity| entity.key() == relation.source()
13643                                    && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == namespace))
13644                        }),
13645                        "PHP namespace containment retains its declared type",
13646                    )?;
13647                }
13648            }
13649        }
13650        for declaration in [
13651            "class N { function caller() { helper(); } }",
13652            "trait N { function caller() { helper(); } }",
13653            "enum N { case Ready; function caller() { helper(); } }",
13654        ] {
13655            for source in [
13656                format!(
13657                    "<?php namespace N {{ function helper() {{}} }}\nnamespace M {{ function helper() {{}} {declaration} }}"
13658                ),
13659                format!(
13660                    "<?php namespace M {{ function helper() {{}} {declaration} }}\nnamespace N {{ function helper() {{}} }}"
13661                ),
13662                format!(
13663                    "<?php namespace N; function helper() {{}}\nnamespace M; function helper() {{}} {declaration}"
13664                ),
13665                format!(
13666                    "<?php namespace M; function helper() {{}} {declaration}\nnamespace N; function helper() {{}}"
13667                ),
13668            ] {
13669                let graph = extract_symbol_graph("src/type-namespace.php", Some("php"), &source);
13670                let projected = finish_graph(&graph)?;
13671                let calls = projected
13672                    .relations
13673                    .iter()
13674                    .filter(|relation| {
13675                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
13676                    })
13677                    .collect::<Vec<_>>();
13678                require_eq(
13679                    &calls.len(),
13680                    &1,
13681                    "PHP type/namespace collision retains its call",
13682                )?;
13683                require(
13684                    matches!(calls[0].resolution(), RelationResolution::Resolved {
13685                        selector: ReusableTargetSelector::Symbol { symbol }, ..
13686                    } if symbol.name.as_str() == "helper"
13687                        && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("M")),
13688                    "PHP containing type must retain its namespace despite a same-named module",
13689                )?;
13690            }
13691        }
13692        let overlapping = extract_symbol_graph(
13693            "src/containment.php",
13694            Some("php"),
13695            "<?php namespace A { class Service { function run() {} } } namespace B { class Service { function run() {} } }",
13696        );
13697        let contained = finish_graph(&overlapping)?;
13698        require(
13699            contained.relations.iter().any(|relation| {
13700                relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
13701                    && !matches!(relation.resolution(), RelationResolution::Resolved { .. })
13702            }),
13703            "PHP overlapping line-only owners remain unresolved",
13704        )?;
13705        require(
13706            !contained.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
13707                && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. } if symbol.name.as_str() == "run")),
13708            "PHP overlapping line-only owners must not invent member containment",
13709        )?;
13710        for source in [
13711            "<?php namespace Service { function boot() {} } namespace { class Service {} Service::boot(); }",
13712            "<?php namespace Service {} namespace { class Service { static function boot() {} } \\Service\\boot(); }",
13713        ] {
13714            let graph = extract_symbol_graph("src/call-kind.php", Some("php"), source);
13715            let staged = finish_graph(&graph)?;
13716            let calls: Vec<_> = staged
13717                .relations
13718                .iter()
13719                .filter(|relation| {
13720                    relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
13721                })
13722                .collect();
13723            require_eq(&calls.len(), &1, "PHP callable-kind collision fixture")?;
13724            require(
13725                matches!(calls[0].resolution(), RelationResolution::Unresolved { .. }),
13726                "PHP calls must not resolve across function and method kinds",
13727            )?;
13728        }
13729        for (source, parent) in [
13730            (
13731                "<?php namespace N; function outer() { function inner() {} } function caller() { inner(); }",
13732                Some("N"),
13733            ),
13734            (
13735                "<?php namespace N { class OuterType { function outer() { function inner() {} } } function caller() { inner(); } }",
13736                Some("N"),
13737            ),
13738            (
13739                "<?php function outer() { function inner() {} } function caller() { inner(); }",
13740                None,
13741            ),
13742        ] {
13743            let graph = extract_symbol_graph("src/nested-call.php", Some("php"), source);
13744            let staged = finish_graph(&graph)?;
13745            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}"))?;
13746        }
13747        let calls = staged
13748            .relations
13749            .iter()
13750            .filter(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
13751            .collect::<Vec<_>>();
13752        let staged_call = |target: &str, source: &str| {
13753            let parser_relation = php_graph.relations.iter().find(|relation| {
13754                relation.kind == RelationKind::Calls
13755                    && relation.target_name == target
13756                    && relation.source_name == source
13757            })?;
13758            let line = u32::try_from(parser_relation.line).ok()?;
13759            staged
13760                .occurrences
13761                .iter()
13762                .filter(|occurrence| occurrence.span().start_line() == line)
13763                .filter_map(|occurrence| {
13764                    staged
13765                        .relations
13766                        .iter()
13767                        .find(|relation| relation.key() == occurrence.relation())
13768                })
13769                .find(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
13770        };
13771        require(
13772            matches!(
13773                staged_call("\\Atlas\\Domain\\QualifiedService::boot", "qualified_run")
13774                    .map(projectatlas_core::graph::LogicalRelation::resolution),
13775                Some(RelationResolution::Resolved {
13776                    selector: ReusableTargetSelector::Symbol { symbol },
13777                    ..
13778                }) if symbol.name.as_str() == "boot"
13779                    && symbol.parent.as_ref().map(GraphIdentityText::as_str)
13780                        == Some("Atlas\\Domain::QualifiedService")
13781            ),
13782            "fully qualified PHP call did not resolve to the local class method",
13783        )?;
13784        require(
13785            matches!(
13786                staged_call("\\Missing\\Domain\\QualifiedService::boot", "qualified_run")
13787                    .map(projectatlas_core::graph::LogicalRelation::resolution),
13788                Some(RelationResolution::Unresolved { reference })
13789                    if reference.as_str() == "\\Missing\\Domain\\QualifiedService::boot"
13790            ),
13791            "qualified PHP call with an invalid namespace must remain unresolved",
13792        )?;
13793        require(
13794            matches!(
13795                staged_call("QualifiedService::boot", "qualified_run")
13796                    .map(projectatlas_core::graph::LogicalRelation::resolution),
13797                Some(RelationResolution::Resolved {
13798                    selector: ReusableTargetSelector::Symbol { symbol },
13799                    ..
13800                }) if symbol.name.as_str() == "boot"
13801                    && symbol.parent.as_ref().map(GraphIdentityText::as_str)
13802                        == Some("Atlas\\Domain::QualifiedService")
13803            ),
13804            "unqualified PHP class scope must select its caller namespace",
13805        )?;
13806        for target in [
13807            "Atlas\\Domain\\QualifiedService::boot",
13808            "Alias\\QualifiedService::boot",
13809        ] {
13810            require(
13811                matches!(
13812                    staged_call(target, "relative_run")
13813                        .map(projectatlas_core::graph::LogicalRelation::resolution),
13814                    Some(RelationResolution::Unresolved { reference })
13815                        if reference.as_str() == target
13816                ),
13817                &format!("non-leading qualified PHP scope must remain unresolved: {target}"),
13818            )?;
13819        }
13820        require(
13821            matches!(
13822                staged_call("namespace\\QualifiedService::boot", "relative_run")
13823                    .map(projectatlas_core::graph::LogicalRelation::resolution),
13824                Some(RelationResolution::Resolved {
13825                    selector: ReusableTargetSelector::Symbol { symbol },
13826                    ..
13827                }) if symbol.parent.as_ref().map(GraphIdentityText::as_str)
13828                    == Some("Other\\Domain::QualifiedService")
13829            ),
13830            "explicit namespace-relative PHP static scope must resolve in its caller namespace",
13831        )?;
13832        require(
13833            matches!(
13834                staged_call("top_level_helper", "TopLevel")
13835                    .map(projectatlas_core::graph::LogicalRelation::resolution),
13836                Some(RelationResolution::Resolved {
13837                    selector: ReusableTargetSelector::Symbol { symbol },
13838                    ..
13839                }) if symbol.name.as_str() == "top_level_helper"
13840                    && symbol.parent.as_ref().map(GraphIdentityText::as_str)
13841                        == Some("TopLevel")
13842            ),
13843            "semicolon namespace top-level call did not resolve locally",
13844        )?;
13845        require(
13846            matches!(
13847                staged_call("helper", "namespaced_run")
13848                    .map(projectatlas_core::graph::LogicalRelation::resolution),
13849                Some(RelationResolution::Resolved {
13850                    selector: ReusableTargetSelector::Symbol { symbol },
13851                    ..
13852                }) if symbol.name.as_str() == "helper"
13853                    && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Foo")
13854            ),
13855            "namespaced PHP method call did not prefer its namespace helper",
13856        )?;
13857        require(
13858            matches!(
13859                staged_call("\\Foo\\helper", "qualified_helper_run")
13860                    .map(projectatlas_core::graph::LogicalRelation::resolution),
13861                Some(RelationResolution::Resolved {
13862                    selector: ReusableTargetSelector::Symbol { symbol },
13863                    ..
13864                }) if symbol.name.as_str() == "helper"
13865                    && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Foo")
13866            ),
13867            "fully qualified PHP function call did not resolve to its namespace function",
13868        )?;
13869        require(
13870            matches!(
13871                staged_call("\\helper", "rooted_global_helper_run")
13872                    .map(projectatlas_core::graph::LogicalRelation::resolution),
13873                Some(RelationResolution::Resolved {
13874                    selector: ReusableTargetSelector::Symbol { symbol },
13875                    ..
13876                }) if symbol.name.as_str() == "helper" && symbol.parent.is_none()
13877            ),
13878            "rooted PHP global function call did not resolve explicitly to the global helper",
13879        )?;
13880        require(
13881            matches!(
13882                staged_call("\\Missing\\helper", "missing_helper_run")
13883                    .map(projectatlas_core::graph::LogicalRelation::resolution),
13884                Some(RelationResolution::Unresolved { reference })
13885                    if reference.as_str() == "\\Missing\\helper"
13886            ),
13887            "fully qualified PHP function call must not fall back to a global helper",
13888        )?;
13889        let duplicate_method_calls = php_graph
13890            .relations
13891            .iter()
13892            .filter(|relation| {
13893                relation.kind == RelationKind::Calls
13894                    && relation.target_name == "self::prepare"
13895                    && relation.source_name == "collision_run"
13896            })
13897            .count();
13898        require_eq(
13899            &duplicate_method_calls,
13900            &2,
13901            "duplicate PHP method fixture call count",
13902        )?;
13903        let duplicate_method_parents = calls
13904            .iter()
13905            .filter_map(|relation| match relation.resolution() {
13906                RelationResolution::Resolved {
13907                    selector: ReusableTargetSelector::Symbol { symbol },
13908                    ..
13909                } if symbol.name.as_str() == "prepare" => {
13910                    symbol.parent.as_ref().map(GraphIdentityText::as_str)
13911                }
13912                _ => None,
13913            })
13914            .collect::<BTreeSet<_>>();
13915        require_eq(
13916            &duplicate_method_parents,
13917            &BTreeSet::from(["Foo::DuplicateA", "Foo::DuplicateB", "Service"]),
13918            "PHP method callers retain their distinct source owners",
13919        )?;
13920        let duplicate_sources = calls
13921            .iter()
13922            .filter_map(|relation| {
13923                let entity = staged
13924                    .entities
13925                    .iter()
13926                    .find(|entity| entity.key() == relation.source())?;
13927                match entity.selector() {
13928                    EntitySelector::Symbol { symbol }
13929                        if symbol.name.as_str() == "collision_run" =>
13930                    {
13931                        symbol.parent.as_ref().map(GraphIdentityText::as_str)
13932                    }
13933                    _ => None,
13934                }
13935            })
13936            .collect::<BTreeSet<_>>();
13937        require_eq(
13938            &duplicate_sources,
13939            &BTreeSet::from(["Foo::DuplicateA", "Foo::DuplicateB"]),
13940            "duplicate PHP calls must originate from their actual method entities",
13941        )?;
13942        let namespace_call = staged_call("top_level_helper", "TopLevel");
13943        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")?;
13944        for (source, name, parent) in [
13945            (
13946                "<?php class caller { function caller() { helper(); } } function helper() {}",
13947                "caller",
13948                "caller",
13949            ),
13950            (
13951                "<?php\nnamespace Foo;\nfunction Foo() {\nhelper();\n}\nfunction helper() {}",
13952                "Foo",
13953                "Foo",
13954            ),
13955        ] {
13956            let graph = extract_symbol_graph("src/source-collision.php", Some("php"), source);
13957            let staged = finish_graph(&graph)?;
13958            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")?;
13959        }
13960        let trait_graph = extract_symbol_graph(
13961            "src/trait.php",
13962            Some("php"),
13963            "<?php trait T { function run() { self::target(); } function target() {} } class C { use T; function target() {} }",
13964        );
13965        let trait_staged = finish_graph(&trait_graph)?;
13966        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")?;
13967        let enum_graph = extract_symbol_graph(
13968            "src/enum.php",
13969            Some("php"),
13970            r"<?php
13971namespace {
13972function helper(): void {}
13973}
13974namespace Foo {
13975function helper(): void {}
13976enum NamespacedState {
13977    case Ready;
13978    public function enum_run(): void {
13979        helper();
13980    }
13981}
13982}
13983namespace Bar {
13984function fallback_run(): void {
13985    helper();
13986}
13987}
13988",
13989        );
13990        let enum_staged = finish_graph(&enum_graph)?;
13991        let enum_call = |target: &str, source: &str| {
13992            let parser_relation = enum_graph.relations.iter().find(|relation| {
13993                relation.target_name == target && relation.source_name == source
13994            })?;
13995            let line = u32::try_from(parser_relation.line).ok()?;
13996            let occurrence = enum_staged
13997                .occurrences
13998                .iter()
13999                .find(|occurrence| occurrence.span().start_line() == line)?;
14000            enum_staged
14001                .relations
14002                .iter()
14003                .find(|relation| relation.key() == occurrence.relation())
14004        };
14005        require(
14006            matches!(
14007                enum_call("helper", "enum_run")
14008                    .map(projectatlas_core::graph::LogicalRelation::resolution),
14009                Some(RelationResolution::Resolved {
14010                    selector: ReusableTargetSelector::Symbol { symbol },
14011                    ..
14012                }) if symbol.name.as_str() == "helper"
14013                    && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Foo")
14014            ),
14015            "namespaced PHP enum method call did not prefer its namespace helper",
14016        )?;
14017        require(
14018            matches!(
14019                enum_call("helper", "fallback_run")
14020                    .map(projectatlas_core::graph::LogicalRelation::resolution),
14021                Some(RelationResolution::Resolved {
14022                    selector: ReusableTargetSelector::Symbol { symbol },
14023                    ..
14024                }) if symbol.name.as_str() == "helper" && symbol.parent.is_none()
14025            ),
14026            "namespaced PHP function call did not fall back to the global helper",
14027        )?;
14028        let mixed_call_line = u32::try_from(
14029            php_graph
14030                .relations
14031                .iter()
14032                .find(|relation| relation.target_name == "SERVICE::BOOT")
14033                .ok_or("mixed-case PHP call fixture was missing")?
14034                .line,
14035        )?;
14036        let mixed_call_occurrence = staged
14037            .occurrences
14038            .iter()
14039            .find(|occurrence| occurrence.span().start_line() == mixed_call_line)
14040            .ok_or("mixed-case PHP call occurrence was missing")?;
14041        let mixed_call_relation = staged
14042            .relations
14043            .iter()
14044            .find(|relation| relation.key() == mixed_call_occurrence.relation())
14045            .ok_or("mixed-case PHP logical call was missing")?;
14046        require(
14047            matches!(
14048                mixed_call_relation.resolution(),
14049                RelationResolution::Resolved {
14050                    selector: ReusableTargetSelector::Symbol { symbol },
14051                    ..
14052                } if symbol.name.as_str() == "boot"
14053                    && symbol.kind == SymbolKind::Method
14054                    && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Service")
14055            ),
14056            "mixed-case scoped PHP call did not resolve to Service::boot method",
14057        )?;
14058        for (name, parent) in [
14059            ("prepare", "Service"),
14060            ("boot", "Service"),
14061            ("helper", "<module>"),
14062        ] {
14063            require(
14064                calls.iter().any(|relation| {
14065                    matches!(
14066                        relation.resolution(),
14067                        RelationResolution::Resolved {
14068                            selector: ReusableTargetSelector::Symbol { symbol },
14069                            ..
14070                        } if symbol.name.as_str() == name
14071                            && symbol.parent.as_ref().map(GraphIdentityText::as_str)
14072                                == (parent != "<module>").then_some(parent)
14073                    )
14074                }),
14075                &format!("PHP call did not resolve to {parent}::{name}"),
14076            )?;
14077        }
14078        require(
14079            matches!(
14080                staged_call("static::finish", "run")
14081                    .map(projectatlas_core::graph::LogicalRelation::resolution),
14082                Some(RelationResolution::Unresolved { reference })
14083                    if reference.as_str() == "static::finish"
14084            ),
14085            "late-static PHP calls must remain unresolved without override proof",
14086        )?;
14087        require(
14088            calls.iter().any(|relation| {
14089                matches!(
14090                    relation.resolution(),
14091                    RelationResolution::Unresolved { reference } if reference.as_str() == "parent::inherited"
14092                )
14093            }),
14094            "parent-scoped PHP calls must remain unresolved without a proven base type",
14095        )?;
14096        require(
14097            calls.iter().all(|relation| {
14098                !matches!(
14099                    relation.resolution(),
14100                    RelationResolution::Resolved {
14101                        selector: ReusableTargetSelector::Symbol { symbol },
14102                        ..
14103                    } if symbol.name.as_str() == "boot"
14104                        && symbol.parent.is_none()
14105                )
14106            }),
14107            "PHP scoped calls must not resolve through an unrelated global member",
14108        )?;
14109
14110        let mut ambiguous_graph = php_graph;
14111        let mut duplicate = ambiguous_graph
14112            .symbols
14113            .iter()
14114            .find(|symbol| symbol.name == "helper")
14115            .cloned()
14116            .ok_or("PHP ambiguity fixture helper was missing")?;
14117        duplicate.name = "HELPER".to_string();
14118        ambiguous_graph.symbols.push(duplicate);
14119        let ambiguous = finish_graph(&ambiguous_graph)?;
14120        require(
14121            ambiguous.relations.iter().any(|relation| {
14122                matches!(
14123                    relation.resolution(),
14124                    RelationResolution::Ambiguous {
14125                        reference,
14126                        candidates,
14127                    } if reference.as_str() == "HELPER" && candidates.get() == 2
14128                )
14129            }),
14130            "case-insensitive PHP duplicate declarations must remain ambiguous",
14131        )?;
14132
14133        let mut scope_failures = Vec::new();
14134        for (source, target, expected_parent) in [
14135            (
14136                "<?php\nclass Service { public static function boot() {} }\nService::boot();\n$callable();",
14137                "partial global static call",
14138                Some("Service"),
14139            ),
14140            (
14141                "<?php\nfunction helper() {}\nhelper();\n$callable();",
14142                "partial global function call",
14143                None,
14144            ),
14145            (
14146                "<?php\nrequire 'bootstrap.php';\nfunction helper() {}\nfunction run() { helper(); }",
14147                "require and local helper",
14148                None,
14149            ),
14150            (
14151                "<?php\ninclude 'a.php'; include_once 'b.php'; require_once 'c.php';\nfunction helper() {}\nfunction run() { helper(); }",
14152                "other include forms and local helper",
14153                None,
14154            ),
14155            (
14156                "<?php\nrequire 'bootstrap.php';\nfunction helper() {}\nfunction run() { helper(); $callable(); }",
14157                "partial require and local helper",
14158                None,
14159            ),
14160            (
14161                "<?php\ninclude 'a.php'; include_once 'b.php'; require_once 'c.php';\nfunction helper() {}\nfunction run() { helper(); $callable(); }",
14162                "partial include forms and local helper",
14163                None,
14164            ),
14165            (
14166                "<?php\nrequire/* comment */('bootstrap'); include_once(\"config\"); REQUIRE_ONCE # comment\n'library';\nfunction helper() {}\nfunction run() { helper(); $callable(); }",
14167                "partial commented and parenthesized includes",
14168                None,
14169            ),
14170            (
14171                "<?php\ntrait Shared {}\nclass Service { use Shared; public static function boot() {} }\nfunction run() { Service::boot(); $callable(); }",
14172                "partial trait import and local static call",
14173                Some("Service"),
14174            ),
14175            (
14176                "<?php\ntrait Shared {}\nclass Service { use Shared; public static function boot() {} }\nfunction run() { Service::boot(); }",
14177                "trait import and local static call",
14178                Some("Service"),
14179            ),
14180            (
14181                "<?php\nnamespace Foo;\nfunction run() { Sub\\helper(); }\nnamespace Foo\\Sub;\nfunction helper() {}",
14182                "relative qualified helper",
14183                Some("Foo\\Sub"),
14184            ),
14185            (
14186                "<?php\nnamespace { function run() { Sub\\helper(); } }\nnamespace Sub { function helper() {} }",
14187                "global qualified helper",
14188                Some("Sub"),
14189            ),
14190            (
14191                "<?php\nnamespace Foo;\nfunction run() { Sub\\Service::boot(); }\nnamespace Foo\\Sub;\nclass Service { public static function boot() {} }",
14192                "relative qualified static method",
14193                Some("Foo\\Sub::Service"),
14194            ),
14195            (
14196                "<?php\nclass Service { public static function boot() {} }\nfunction run() { Service::boot(); }",
14197                "Service::boot",
14198                Some("Service"),
14199            ),
14200            (
14201                "<?php\nclass Service { public static function boot() {} }\nService::boot();",
14202                "Service::boot",
14203                Some("Service"),
14204            ),
14205            (
14206                "<?php\nfunction helper() {}\nfunction run() { namespace\\helper(); }",
14207                "namespace\\helper",
14208                None,
14209            ),
14210            (
14211                "<?php\nnamespace Foo;\nfunction helper() {}\nfunction run() { namespace\\helper(); }",
14212                "namespace\\helper",
14213                Some("Foo"),
14214            ),
14215            (
14216                "<?php\nnamespace Foo { use function Other\\helper; function run() { namespace\\helper(); } }\nnamespace Foo { function helper() {} }",
14217                "namespace\\helper",
14218                Some("Foo"),
14219            ),
14220            (
14221                "<?php\nnamespace Foo;\nfunction helper() {}\nNaMeSpAcE\\helper();",
14222                "NaMeSpAcE\\helper",
14223                Some("Foo"),
14224            ),
14225            (
14226                "<?php\nnamespace Foo;\nfunction run() { namespace\\Child\\helper(); }\nnamespace Foo\\Child;\nfunction helper() {}",
14227                "namespace\\Child\\helper",
14228                Some("Foo\\Child"),
14229            ),
14230        ] {
14231            let graph = extract_symbol_graph("src/explicit-scope.php", Some("php"), source);
14232            let staged = finish_graph(&graph)?;
14233            if !staged.relations.iter().any(|relation| {
14234                matches!(
14235                    relation.resolution(),
14236                    RelationResolution::Resolved {
14237                        selector: ReusableTargetSelector::Symbol { symbol },
14238                        ..
14239                    } if symbol.parent.as_ref().map(GraphIdentityText::as_str) == expected_parent
14240                ) && relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14241            }) {
14242                scope_failures.push(target);
14243            }
14244        }
14245        require(
14246            scope_failures.is_empty(),
14247            &format!("proven PHP call scopes did not resolve: {scope_failures:?}"),
14248        )?;
14249
14250        let mut unknown_caller = extract_symbol_graph(
14251            "src/unknown-scope.php",
14252            Some("php"),
14253            "<?php\nclass Service { public static function boot() {} }\nfunction run() { Service::boot(); }",
14254        );
14255        for relation in &mut unknown_caller.relations {
14256            if relation.kind == RelationKind::Calls {
14257                relation.source_name = "unknown_caller".to_string();
14258            }
14259        }
14260        let ambiguous_caller = extract_symbol_graph(
14261            "src/ambiguous-scope.php",
14262            Some("php"),
14263            "<?php namespace { class Service { public static function boot() {} } function run() { Service::boot(); } } namespace Foo { function run() {} }",
14264        );
14265        for graph in [&unknown_caller, &ambiguous_caller] {
14266            let staged = finish_graph(graph)?;
14267            require(
14268                staged
14269                    .relations
14270                    .iter()
14271                    .filter(|relation| {
14272                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14273                    })
14274                    .all(|relation| {
14275                        staged.entities.iter().any(|entity| {
14276                            entity.key() == relation.source()
14277                                && matches!(entity.selector(), EntitySelector::File { .. })
14278                        })
14279                    }),
14280                "unknown and same-line ambiguous PHP callers must retain file ownership",
14281            )?;
14282            require(
14283                staged.relations.iter().any(|relation| {
14284                    matches!(
14285                        relation.resolution(),
14286                        RelationResolution::Unresolved { reference } if reference.as_str() == "Service::boot"
14287                    )
14288                }),
14289                "unknown or ambiguous PHP callers must not be treated as proven global functions",
14290            )?;
14291        }
14292        for source in [
14293            "<?php namespace Foo; function Foo() {} Foo();",
14294            "<?php namespace Foo; function Foo() { helper(); } function helper() {}",
14295        ] {
14296            let graph = extract_symbol_graph("src/namespace-boundary.php", Some("php"), source);
14297            let staged = finish_graph(&graph)?;
14298            require(
14299                staged
14300                    .relations
14301                    .iter()
14302                    .filter(|relation| {
14303                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14304                    })
14305                    .all(|relation| {
14306                        staged.entities.iter().any(|entity| {
14307                            entity.key() == relation.source()
14308                                && matches!(entity.selector(), EntitySelector::File { .. })
14309                        })
14310                    }),
14311                "PHP namespace/callable boundary-line collisions must remain file-owned",
14312            )?;
14313        }
14314
14315        for source in [
14316            r"<?php namespace A { function helper() {} function before() { helper(); } use function Vendor\{helper, other}; function after() { helper(); } }",
14317            r"<?php namespace A; function helper() {} function before() { helper(); } use function Vendor\helper, Vendor\other; function after() { helper(); }",
14318            r"<?php namespace A { use function Vendor\{helper, other}; } namespace B { function helper() {} function before() { helper(); } use function Remote\helper; function after() { helper(); } }",
14319        ] {
14320            let graph = extract_symbol_graph("src/import-groups.php", Some("php"), source);
14321            let grouped = finish_graph(&graph)?;
14322            for caller in ["before", "after"] {
14323                require(
14324                    grouped.relations.iter().any(|relation| {
14325                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14326                            && matches!(relation.resolution(), RelationResolution::Unresolved { .. })
14327                            && grouped.entities.iter().any(|entity| entity.key() == relation.source()
14328                                && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == caller))
14329                    }),
14330                    "PHP grouped imports with unmatched source occurrences must stay conservative",
14331                )?;
14332            }
14333        }
14334
14335        let collision = extract_symbol_graph(
14336            "src/containment-collision.php",
14337            Some("php"),
14338            "<?php namespace Service { class Service { function run() {} } function outside() {} }",
14339        );
14340        let contained = finish_graph(&collision)?;
14341        for (name, parent) in [
14342            ("Service", "Service"),
14343            ("run", "Service::Service"),
14344            ("outside", "Service"),
14345        ] {
14346            require(
14347                contained.relations.iter().any(|relation| {
14348                    relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
14349                        && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
14350                            if symbol.name.as_str() == name && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(parent))
14351                        && contained.entities.iter().any(|entity| entity.key() == relation.source()
14352                            && matches!(entity.selector(), EntitySelector::Symbol { symbol }
14353                                if symbol.name.as_str() == "Service" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == if name == "run" { Some("Service") } else { None }))
14354                }),
14355                &format!("PHP same-line namespace and type collisions must use exact containment spans: {name} {parent}"),
14356            )?;
14357        }
14358
14359        for source in [
14360            "<?php class A { public $run; function run() {} const run = 1; }",
14361            "<?php trait A { public $run; function run() {} const run = 1; }",
14362        ] {
14363            let graph = extract_symbol_graph("src/member-kinds.php", Some("php"), source);
14364            let members = finish_graph(&graph)?;
14365            require_eq(
14366                &graph
14367                    .symbols
14368                    .iter()
14369                    .filter(|symbol| symbol.name == "run")
14370                    .count(),
14371                &3,
14372                "PHP same-name fixture must retain property, method, and constant",
14373            )?;
14374            for expected in graph.symbols.iter().filter(|symbol| symbol.name == "run") {
14375                require(
14376                    members.relations.iter().any(|relation| {
14377                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
14378                            && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
14379                                if symbol.name.as_str() == "run" && symbol.kind == expected.kind && symbol.signature.as_str() == expected.signature)
14380                            && members.entities.iter().any(|entity| entity.key() == relation.source()
14381                                && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == "A"))
14382                    }),
14383                    &format!("same-line PHP {} must retain its distinct containment edge", expected.signature),
14384                )?;
14385            }
14386        }
14387
14388        let imported_graph = extract_symbol_graph(
14389            "src/imports.php",
14390            Some("php"),
14391            r"<?php
14392namespace Local {
14393    use Remote\Service;
14394    use Remote\NamespaceAlias as Sub;
14395    use function Remote\helper;
14396    function run() { Service::boot(); helper(); Sub\helper(); }
14397}
14398namespace Local {
14399    class Service { public static function boot() {} }
14400    function helper() {}
14401}
14402namespace Local\Sub { function helper() {} }
14403",
14404        );
14405        let staged = finish_graph(&imported_graph)?;
14406        for target in ["Service::boot", "helper", "Sub\\helper"] {
14407            require(
14408                staged.relations.iter().any(|relation| {
14409                    matches!(
14410                        relation.resolution(),
14411                        RelationResolution::Unresolved { reference } if reference.as_str() == target
14412                    )
14413                }),
14414                "PHP imports must not be bypassed by same-file unqualified call matching",
14415            )?;
14416        }
14417
14418        for source in [
14419            r"<?php
14420namespace A { use function Vendor\helper; function run_a() { helper(); } }
14421namespace B { function helper() {} function run_b() { helper(); } }
14422",
14423            r"<?php
14424namespace A;
14425use function Vendor\helper;
14426function run_a() { helper(); }
14427namespace B;
14428function helper() {}
14429function run_b() { helper(); }
14430",
14431        ] {
14432            let graph = extract_symbol_graph("src/import-scopes.php", Some("php"), source);
14433            let scoped = finish_graph(&graph)?;
14434            require(
14435                scoped.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14436                    && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
14437                        if symbol.name.as_str() == "helper" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("B"))),
14438                "PHP import in namespace A must not suppress a proven namespace B call",
14439            )?;
14440            require(
14441                scoped.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14442                    && matches!(relation.resolution(), RelationResolution::Unresolved { reference } if reference.as_str() == "helper")),
14443                "PHP imported alias remains unresolved in its owning namespace",
14444            )?;
14445        }
14446
14447        for source in [
14448            r"<?php namespace A { function helper() {} function before() { helper(); } use function Vendor\helper; function after() { helper(); } }",
14449            r"<?php namespace A; function before() { Sub\helper(); } use Vendor as Sub; function after() { Sub\helper(); } namespace A\Sub; function helper() {}",
14450            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(); } }",
14451            r"<?php
14452namespace { function helper() {} }
14453namespace A {
14454function before() { helper(); }
14455use function Vendor\helper;
14456function after() { helper(); }
14457}
14458",
14459            r"<?php
14460namespace A;
14461function before() { Sub\helper(); }
14462use Vendor as Sub;
14463function after() { Sub\helper(); }
14464namespace A\Sub;
14465function helper() {}
14466",
14467        ] {
14468            let graph = extract_symbol_graph("src/import-order.php", Some("php"), source);
14469            let staged = finish_graph(&graph)?;
14470            for (caller, resolved) in [("before", true), ("after", false)] {
14471                require(
14472                    staged.relations.iter().any(|relation| {
14473                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14474                            && matches!(relation.resolution(), RelationResolution::Resolved { .. }) == resolved
14475                            && staged.entities.iter().any(|entity| entity.key() == relation.source()
14476                                && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == caller))
14477                    }),
14478                    &format!("PHP {caller} call must apply only imports already declared"),
14479                )?;
14480            }
14481        }
14482
14483        for source in [
14484            r"<?php
14485namespace A {
14486use function Vendor\helper;
14487function imported() { helper(); }
14488}
14489namespace A {
14490function helper() {}
14491function local() { helper(); }
14492}
14493",
14494            r"<?php
14495namespace A;
14496use Vendor as Sub;
14497function imported() { Sub\helper(); }
14498namespace A;
14499function local() { Sub\helper(); }
14500namespace A\Sub;
14501function helper() {}
14502",
14503        ] {
14504            let graph = extract_symbol_graph("src/reopened-namespace.php", Some("php"), source);
14505            let staged = finish_graph(&graph)?;
14506            for (caller, resolved) in [("imported", false), ("local", true)] {
14507                require(
14508                    staged.relations.iter().any(|relation| {
14509                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14510                            && matches!(relation.resolution(), RelationResolution::Resolved { .. }) == resolved
14511                            && staged.entities.iter().any(|entity| entity.key() == relation.source()
14512                                && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == caller))
14513                    }),
14514                    &format!("PHP {caller} call must use only its namespace declaration block"),
14515                )?;
14516            }
14517        }
14518
14519        for source in [
14520            "<?php namespace Foo { function helper() {} } namespace Foo { helper(); }",
14521            "<?php\nnamespace Foo;\nfunction helper() {}\nnamespace Foo;\nhelper();",
14522        ] {
14523            let graph = extract_symbol_graph("src/reopened-top-level.php", Some("php"), source);
14524            let staged = finish_graph(&graph)?;
14525            require(
14526                staged.relations.iter().any(|relation| {
14527                    relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14528                        && staged.entities.iter().any(|entity| {
14529                            entity.key() == relation.source()
14530                                && matches!(entity.selector(), EntitySelector::Symbol { symbol }
14531                                    if symbol.name.as_str() == "Foo" && symbol.kind == SymbolKind::Module)
14532                        })
14533                }),
14534                "Reopened PHP namespace must retain its top-level call source",
14535            )?;
14536        }
14537
14538        for (source, remove_namespaces) in [
14539            (
14540                "<?php namespace A { use function Vendor\\helper; } namespace A { function helper() {} function local() { helper(); } }",
14541                false,
14542            ),
14543            (
14544                "<?php\nnamespace A {\nuse function Vendor\\helper;\n}\nnamespace A {\nfunction helper() {}\nfunction local() { helper(); }\n}",
14545                true,
14546            ),
14547        ] {
14548            let mut graph =
14549                extract_symbol_graph("src/uncertain-namespace.php", Some("php"), source);
14550            if remove_namespaces {
14551                graph
14552                    .symbols
14553                    .retain(|symbol| symbol.kind != SymbolKind::Module);
14554            }
14555            let staged = finish_graph(&graph)?;
14556            require(
14557                staged.relations.iter().any(|relation| {
14558                    relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14559                        && matches!(relation.resolution(), RelationResolution::Unresolved { .. })
14560                }),
14561                "PHP imports with tied or missing namespace boundaries remain unresolved",
14562            )?;
14563            require(
14564                !staged.relations.iter().any(|relation| {
14565                    relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14566                        && matches!(relation.resolution(), RelationResolution::Resolved { .. })
14567                }),
14568                "PHP uncertain namespace boundaries must not invent call resolution",
14569            )?;
14570        }
14571
14572        let oversized_prefix = "N".repeat(241);
14573        for import in [
14574            format!("use Vendor\\{oversized_prefix} as Sub;"),
14575            format!("use Vendor\\{oversized_prefix}\\Sub;"),
14576            format!("use {oversized_prefix}\\{{Sub}};"),
14577            format!("use {}\\{{Sub}};", "N".repeat(238)),
14578            format!("use Vendor\\Kept, Vendor\\{oversized_prefix} as Sub;"),
14579        ] {
14580            let source = format!(
14581                "<?php namespace Foo {{ {import} function run() {{ Sub\\helper(); namespace\\Sub\\helper(); }} function absolute() {{ \\Foo\\Sub\\helper(); }} }} namespace Foo\\Sub {{ function helper() {{}} }}"
14582            );
14583            let graph = extract_symbol_graph("src/oversized-imports.php", Some("php"), &source);
14584            let staged = finish_graph(&graph)?;
14585            require(
14586                staged.relations.iter().any(|relation| {
14587                    matches!(relation.resolution(), RelationResolution::Unresolved { reference }
14588                        if reference.as_str() == "Sub\\helper")
14589                }),
14590                &format!("omitted PHP import must retain alias uncertainty: {import}"),
14591            )?;
14592            require_eq(
14593                &staged
14594                    .relations
14595                    .iter()
14596                    .filter(|relation| {
14597                        relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14598                            && matches!(relation.resolution(), RelationResolution::Resolved { .. })
14599                    })
14600                    .count(),
14601                &2,
14602                "rooted and namespace-relative PHP calls must remain independently resolvable",
14603            )?;
14604        }
14605
14606        let mut partial_imports = imported_graph;
14607        partial_imports.parser = ParserKind::Fallback;
14608        partial_imports
14609            .symbols
14610            .retain(|symbol| symbol.kind != SymbolKind::Import);
14611        let partial_staged = finish_graph(&partial_imports)?;
14612        require(
14613            partial_staged.relations.iter().any(|relation| {
14614                matches!(relation.resolution(), RelationResolution::Unresolved { reference }
14615                    if reference.as_str() == "Sub\\helper")
14616            }),
14617            "partial PHP import evidence must not prove absence of namespace aliases",
14618        )?;
14619        for context in [
14620            "require\\Sub",
14621            "include_once\\Sub",
14622            "requireé",
14623            "require\u{a0}Alias",
14624            "",
14625            "use function Vendor\\helper;",
14626        ] {
14627            for relation in &mut partial_imports.relations {
14628                if relation.kind == RelationKind::Imports {
14629                    relation.context = context.to_string();
14630                }
14631            }
14632            let staged = finish_graph(&partial_imports)?;
14633            require(
14634                staged.relations.iter().any(|relation| {
14635                    matches!(relation.resolution(), RelationResolution::Unresolved { reference }
14636                        if reference.as_str() == "Sub\\helper")
14637                }),
14638                "unknown, namespace-prefix, and fallback use contexts must retain alias uncertainty",
14639            )?;
14640        }
14641        let missing_relative = extract_symbol_graph(
14642            "src/missing-relative.php",
14643            Some("php"),
14644            "<?php\nnamespace { function helper() {} }\nnamespace Foo { function run() { Missing\\helper(); } }",
14645        );
14646        let staged = finish_graph(&missing_relative)?;
14647        require(
14648            staged.relations.iter().any(|relation| {
14649                matches!(relation.resolution(), RelationResolution::Unresolved { reference }
14650                    if reference.as_str() == "Missing\\helper")
14651            }),
14652            "qualified PHP functions must not fall back to the global function",
14653        )?;
14654
14655        let wrong_namespace_graph = extract_symbol_graph(
14656            "src/scoped.php",
14657            Some("php"),
14658            "<?php\nnamespace Caller { function run(): void { Service::boot(); } }\nnamespace Other { class Service { public static function boot(): void {} } }\n",
14659        );
14660        let staged = finish_graph(&wrong_namespace_graph)?;
14661        require(
14662            staged.relations.iter().any(|relation| {
14663                matches!(
14664                    relation.resolution(),
14665                    RelationResolution::Unresolved { reference } if reference.as_str() == "Service::boot"
14666                )
14667            }),
14668            "unqualified PHP static calls must not resolve to a class in another namespace",
14669        )?;
14670
14671        let case_sensitive_graph = extract_symbol_graph(
14672            "src/case-sensitive.js",
14673            Some("javascript"),
14674            "function helper() {}\nfunction caller() { HELPER(); }\n",
14675        );
14676        let staged = finish_graph(&case_sensitive_graph)?;
14677        require(
14678            staged.relations.iter().any(|relation| {
14679                matches!(
14680                    relation.resolution(),
14681                    RelationResolution::Unresolved { reference } if reference.as_str() == "HELPER"
14682                )
14683            }),
14684            "non-PHP call matching must remain case-sensitive",
14685        )?;
14686        Ok(())
14687    }
14688
14689    #[test]
14690    fn persisted_candidate_pages_share_the_registry_transfer_allowance()
14691    -> Result<(), Box<dyn Error>> {
14692        let temp = tempfile::tempdir()?;
14693        let root = temp.path();
14694        let db = root.join("atlas.db");
14695        let mut store = AtlasStore::open_for_project(&db, root)?;
14696        let project = store
14697            .project_instance_id()?
14698            .ok_or("missing transfer fixture identity")?;
14699        let graph = function_graph("src/lib.rs", 1);
14700        let nodes = vec![test_file_node(&graph.path, "rust")];
14701        let control = IndexWorkControl::new(IndexCancellation::new(), None);
14702        let policy = RootScanPolicy::discover(root, &ScanOptions::default(), &control)?;
14703        let staged = stage_full_repository_graph(
14704            &store,
14705            root,
14706            IndexGeneration::ZERO,
14707            &nodes,
14708            &policy,
14709            &symbol_build_stage_for_graphs(vec![graph.clone()]),
14710            &control,
14711        )?;
14712        publish_full_staged_graph(&mut store, &nodes, &staged, &control, "candidate-transfer")?;
14713        drop(store);
14714        let reader = AtlasStore::open_read_only_for_project(&db, root)?;
14715        let before = reader.index_publication()?;
14716        let keys = reader
14717            .repository_export_keys_for_paths(project, &[graph.path], 100)?
14718            .rows;
14719        let read = || {
14720            reader.repository_resolution_candidates_for_keys_with_byte_limit(
14721                project,
14722                &keys,
14723                100,
14724                1_024 * 1_024,
14725            )
14726        };
14727        let first = read()?;
14728        let page_bytes = first.retained_bytes;
14729        require(
14730            page_bytes > 0 && !first.page.rows.is_empty(),
14731            "read omitted its returned owner",
14732        )?;
14733        let replaced = BTreeSet::new();
14734        let generation = IndexGeneration::new(2);
14735        let registry = super::resolution_registry_from_persisted(
14736            project,
14737            generation,
14738            first,
14739            &replaced,
14740            &control,
14741            u64::MAX,
14742        )?;
14743        let registry_bytes = registry.retained_bytes;
14744        require(registry_bytes > 0, "transfer retained no lookup witnesses")?;
14745        drop(registry);
14746        let combined = page_bytes.saturating_add(registry_bytes);
14747        let refused = super::resolution_registry_from_persisted(
14748            project,
14749            generation,
14750            read()?,
14751            &replaced,
14752            &control,
14753            combined - 1,
14754        );
14755        require(
14756            matches!(
14757                refused,
14758                Err(CliError::IndexWork(
14759                    IndexWorkFailure::ResourceLimitExceeded {
14760                        resource: IndexWorkResource::OutputBytes,
14761                        ..
14762                    }
14763                ))
14764            ),
14765            "registry ignored the simultaneously live returned page",
14766        )?;
14767        let mut exact = super::resolution_registry_from_persisted(
14768            project,
14769            generation,
14770            read()?,
14771            &replaced,
14772            &control,
14773            combined,
14774        )?;
14775        require_eq(
14776            &exact.retained_bytes,
14777            &registry_bytes,
14778            "exact transfer boundary changed lookup ownership",
14779        )?;
14780        super::append_persisted_resolution_candidates(
14781            &mut exact,
14782            project,
14783            generation,
14784            read()?,
14785            &replaced,
14786            &control,
14787        )?;
14788        require_eq(
14789            &exact.retained_bytes,
14790            &registry_bytes,
14791            "duplicate page charged another registry copy",
14792        )?;
14793        exact.retained_limit = combined - 1;
14794        let failed_append = super::append_persisted_resolution_candidates(
14795            &mut exact,
14796            project,
14797            generation,
14798            read()?,
14799            &replaced,
14800            &control,
14801        );
14802        require(
14803            failed_append.is_err()
14804                && exact.retained_limit == combined - 1
14805                && exact.retained_bytes == registry_bytes,
14806            "failed read transfer erased completed cache or leaked its scoped allowance",
14807        )?;
14808        require_eq(
14809            &reader.index_publication()?,
14810            &before,
14811            "read transfer changed the published generation",
14812        )?;
14813        Ok(())
14814    }
14815
14816    #[test]
14817    fn incremental_key_workspaces_admit_clones_and_query_copies_before_growth()
14818    -> Result<(), Box<dyn Error>> {
14819        let project = ProjectInstanceId::from_bytes([22; 16])?;
14820        let key = test_resolution_key(project, &"x".repeat(1_000))?;
14821        let other = test_resolution_key(project, &"y".repeat(1_000))?;
14822        let one_owner = super::resolution_workspace_key_bytes(&key);
14823        let paths = BTreeSet::new();
14824        let root = Path::new(".");
14825        let mut exact = super::ResolutionKeyWorkspace::new(one_owner, root, &paths);
14826        exact.insert(&key)?;
14827        exact.insert(&key)?;
14828        require(
14829            exact.keys.len() == 1 && exact.retained_bytes == one_owner,
14830            "duplicate witness retained another canonical string",
14831        )?;
14832        require(
14833            matches!(
14834                exact.insert(&other),
14835                Err(CliError::IndexWork(
14836                    IndexWorkFailure::ResourceLimitExceeded {
14837                        resource: IndexWorkResource::OutputBytes,
14838                        ..
14839                    }
14840                ))
14841            ) && exact.keys.len() == 1
14842                && exact.retained_bytes == one_owner,
14843            "refused witness mutated the admitted prefix",
14844        )?;
14845        require(
14846            matches!(
14847                exact.snapshot(),
14848                Err(CliError::IndexWork(
14849                    IndexWorkFailure::ResourceLimitExceeded {
14850                        resource: IndexWorkResource::OutputBytes,
14851                        ..
14852                    }
14853                ))
14854            ),
14855            "query Vec copy ignored its coexisting set",
14856        )?;
14857        let mut available =
14858            super::ResolutionKeyWorkspace::new(one_owner.saturating_mul(4), root, &paths);
14859        available.insert(&key)?;
14860        available.insert_owned(other.clone())?;
14861        let mut one_row = super::ResolutionKeyWorkspace::new(u64::MAX, root, &paths);
14862        one_row.row_limit = 1;
14863        one_row.insert(&key)?;
14864        one_row.insert_owned(key.clone())?;
14865        require(
14866            matches!(one_row.insert(&other), Err(CliError::RefreshRequired(_)))
14867                && matches!(
14868                    one_row.insert_owned(other.clone()),
14869                    Err(CliError::RefreshRequired(_))
14870                )
14871                && one_row.keys.len() == 1
14872                && one_row.retained_bytes == one_owner,
14873            "distinct-key row refusal retained another owner or charged a duplicate",
14874        )?;
14875        let mut expected = vec![key, other];
14876        expected.sort();
14877        require_eq(
14878            &available.snapshot()?.0,
14879            &expected,
14880            "admitted query copy changed exact witnesses",
14881        )?;
14882        Ok(())
14883    }
14884
14885    #[test]
14886    fn configured_projection_admission_refuses_before_aggregate_retention()
14887    -> Result<(), Box<dyn Error>> {
14888        let project = ProjectInstanceId::from_bytes([21; 16])?;
14889        let generation = IndexGeneration::new(1);
14890        let control = IndexWorkControl::new(IndexCancellation::new(), None);
14891        let text = "import 'pkg';\n".repeat(16);
14892        let mut extracted = Vec::new();
14893        for index in 0..5 {
14894            let (_, graph, facts) =
14895                projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
14896                    &format!("src/source{index}.ts"),
14897                    Some("typescript"),
14898                    &text,
14899                    &control,
14900                )?
14901                .into_parts();
14902            extracted.push((graph, facts));
14903        }
14904        let raw = extracted.iter().map(|(graph, _)| graph).collect::<Vec<_>>();
14905        let packages = PackageIndex::from_graphs(&raw)?;
14906        let sources = extracted
14907            .iter()
14908            .map(|(graph, facts)| super::SourceGraph::Staged {
14909                graph,
14910                semantics: Some(facts),
14911            })
14912            .collect::<Vec<_>>();
14913        let directory = format!("{}/", "n".repeat(220)).repeat(4);
14914        let configured = ConfiguredModuleResolution::new(vec![EcmaScriptModuleConfig::new(
14915            "tsconfig.json",
14916            EcmaScriptConfigKind::TypeScript,
14917            None,
14918            vec![EcmaScriptPathMapping::new(
14919                "pkg",
14920                (0..64)
14921                    .map(|index| format!("src/target{index}/{directory}target.ts"))
14922                    .collect(),
14923            )?],
14924        )?])?;
14925        let mut report = super::GraphIdentityAdmission::default();
14926        let limit = 2 * 1_024 * 1_024;
14927        let result = super::admit_resolution_key_failures_with_limit(
14928            project,
14929            generation,
14930            &sources,
14931            &packages,
14932            &configured,
14933            &mut report,
14934            &control,
14935            limit,
14936        );
14937        let retained =
14938            super::resolution_projection_map_retained_bytes(&report.resolution_projections);
14939        require(
14940            matches!(
14941                result,
14942                Err(super::CliError::IndexWork(
14943                    IndexWorkFailure::ResourceLimitExceeded {
14944                        resource: projectatlas_core::IndexWorkResource::OutputBytes,
14945                        ..
14946                    }
14947                ))
14948            ),
14949            &format!(
14950                "aggregate admission returned success with {} maps and {retained} bytes under {limit}-byte limit",
14951                report.resolution_projections.len()
14952            ),
14953        )?;
14954        require(
14955            retained <= limit
14956                && !report.resolution_projections.is_empty()
14957                && report.resolution_projections.len() < sources.len(),
14958            "admission must retain only the prefix admitted before aggregate exhaustion",
14959        )?;
14960        let (_, partial_graph, partial_facts) =
14961            projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
14962                "src/partial.js",
14963                Some("javascript"),
14964                &format!("export {{ work as '' }} from 'pkg';\n{text}"),
14965                &control,
14966            )?
14967            .into_parts();
14968        let partial = [super::SourceGraph::Staged {
14969            graph: &partial_graph,
14970            semantics: Some(&partial_facts),
14971        }];
14972        let mut rejected_neighbor = GraphIdentityAdmission::default();
14973        super::admit_resolution_key_failures_with_limit(
14974            project,
14975            generation,
14976            &partial,
14977            &packages,
14978            &configured,
14979            &mut rejected_neighbor,
14980            &control,
14981            limit,
14982        )?;
14983        require(
14984            !rejected_neighbor.rejections.is_empty()
14985                && rejected_neighbor.resolution_projections.len() == 1,
14986            "invalid neighbor did not retain a valid partial projection and rejection facts",
14987        )?;
14988        let partial_bytes =
14989            resolution_projection_map_retained_bytes(&rejected_neighbor.resolution_projections);
14990        let partial_continuation = super::admit_resolution_key_failures_with_limit(
14991            project,
14992            generation,
14993            &sources,
14994            &packages,
14995            &configured,
14996            &mut rejected_neighbor,
14997            &control,
14998            limit,
14999        );
15000        require(
15001            matches!(
15002                partial_continuation,
15003                Err(CliError::IndexWork(
15004                    IndexWorkFailure::ResourceLimitExceeded {
15005                        resource: projectatlas_core::IndexWorkResource::OutputBytes,
15006                        ..
15007                    }
15008                ))
15009            ) && resolution_projection_map_retained_bytes(
15010                &rejected_neighbor.resolution_projections,
15011            ) == partial_bytes,
15012            "partial Contract projection escaped the aggregate allowance",
15013        )?;
15014        let mut split = GraphIdentityAdmission::default();
15015        super::admit_resolution_key_failures_with_limit(
15016            project,
15017            generation,
15018            &sources[..1],
15019            &packages,
15020            &configured,
15021            &mut split,
15022            &control,
15023            limit,
15024        )?;
15025        let first_bytes = resolution_projection_map_retained_bytes(&split.resolution_projections);
15026        require(
15027            first_bytes > 0,
15028            "positive 64-target projection was not admitted",
15029        )?;
15030        let continuation = super::admit_resolution_key_failures_with_limit(
15031            project,
15032            generation,
15033            &sources[1..],
15034            &packages,
15035            &configured,
15036            &mut split,
15037            &control,
15038            limit,
15039        );
15040        require(
15041            matches!(
15042                continuation,
15043                Err(CliError::IndexWork(
15044                    IndexWorkFailure::ResourceLimitExceeded {
15045                        resource: projectatlas_core::IndexWorkResource::OutputBytes,
15046                        ..
15047                    }
15048                ))
15049            ),
15050            "a second admission minted a fresh allowance",
15051        )?;
15052        let mut nonzero_base = GraphIdentityAdmission {
15053            observed_fact_bytes: limit - first_bytes / 2,
15054            ..GraphIdentityAdmission::default()
15055        };
15056        let base_result = super::admit_resolution_key_failures_with_limit(
15057            project,
15058            generation,
15059            &sources[..1],
15060            &packages,
15061            &configured,
15062            &mut nonzero_base,
15063            &control,
15064            limit,
15065        );
15066        require(
15067            matches!(
15068                base_result,
15069                Err(CliError::IndexWork(
15070                    IndexWorkFailure::ResourceLimitExceeded {
15071                        resource: projectatlas_core::IndexWorkResource::OutputBytes,
15072                        ..
15073                    }
15074                ))
15075            ) && nonzero_base.resolution_projections.is_empty(),
15076            "already-live identity state was omitted from producer admission",
15077        )?;
15078        let cancellation = IndexCancellation::new();
15079        cancellation.cancel();
15080        let canceled = IndexWorkControl::new(cancellation, None);
15081        let before = resolution_projection_map_retained_bytes(&split.resolution_projections);
15082        let canceled_result = super::admit_resolution_key_failures_with_limit(
15083            project,
15084            generation,
15085            &sources[1..],
15086            &packages,
15087            &configured,
15088            &mut split,
15089            &canceled,
15090            limit,
15091        );
15092        require(
15093            matches!(
15094                canceled_result,
15095                Err(CliError::IndexWork(IndexWorkFailure::Cancelled { .. }))
15096            ) && resolution_projection_map_retained_bytes(&split.resolution_projections) == before,
15097            "cancellation changed the admitted prefix",
15098        )?;
15099        Ok(())
15100    }
15101
15102    #[test]
15103    fn configured_module_targets_reuse_graph_ambiguity_ownership() -> Result<(), Box<dyn Error>> {
15104        let project = ProjectInstanceId::from_bytes([19; 16])?;
15105        let generation = IndexGeneration::new(1);
15106        let control = IndexWorkControl::new(IndexCancellation::new(), None);
15107        let graphs = vec![
15108            extract_symbol_graph(
15109                "src/first/controller.ts",
15110                Some("typescript"),
15111                "export function useController() { return 'first'; }\n",
15112            ),
15113            extract_symbol_graph(
15114                "src/second/controller.ts",
15115                Some("typescript"),
15116                "export function useController() { return 'second'; }\n",
15117            ),
15118            extract_symbol_graph(
15119                "src/page.ts",
15120                Some("typescript"),
15121                "import { useController } from '@/controller';\nexport const value = useController();\n",
15122            ),
15123        ];
15124        let packages = PackageIndex::from_graphs(&graphs)?;
15125        let configured = ConfiguredModuleResolution::new(vec![EcmaScriptModuleConfig::new(
15126            "tsconfig.json",
15127            EcmaScriptConfigKind::TypeScript,
15128            None,
15129            vec![EcmaScriptPathMapping::new(
15130                "@/*",
15131                vec!["src/first/*".to_string(), "src/second/*".to_string()],
15132            )?],
15133        )?])?;
15134        let projection = build_entity_projection_with_config(
15135            project,
15136            generation,
15137            &[],
15138            &graphs,
15139            &packages,
15140            &configured,
15141            None,
15142            true,
15143            &control,
15144        )?;
15145        let candidates = resolution_registry_from_exports(&projection, &control)?;
15146        let staged = finish_projection(
15147            project,
15148            generation,
15149            RepositoryGraphMutation::Full,
15150            &graphs,
15151            projection,
15152            &candidates,
15153            &control,
15154        )?;
15155        for kind in [RelationKind::Imports, RelationKind::Calls] {
15156            require(
15157                staged.relations.iter().any(|relation| {
15158                    relation.kind() == GraphRelationKind::from_legacy(kind)
15159                        && matches!(
15160                            relation.resolution(),
15161                            RelationResolution::Ambiguous { candidates, .. }
15162                                if candidates.get() == 2
15163                        )
15164                }),
15165                "configured mapping did not retain all ambiguity candidates",
15166            )?;
15167        }
15168        Ok(())
15169    }
15170
15171    #[test]
15172    fn extracted_provider_matrix_survives_sqlite_publication_and_reopen()
15173    -> Result<(), Box<dyn Error>> {
15174        let temp = tempfile::tempdir()?;
15175        let root = temp.path().join("extracted-provider-matrix");
15176        fs::create_dir_all(&root)?;
15177        let database = root.join("projectatlas.db");
15178        let mut store = AtlasStore::open_for_project(&database, &root)?;
15179        let project = store
15180            .project_instance_id()?
15181            .ok_or("bound project identity is missing")?;
15182        let generation = IndexGeneration::new(1);
15183        let control = IndexWorkControl::new(IndexCancellation::new(), None);
15184        let graphs = vec![
15185            extract_symbol_graph(
15186                "src/lib.rs",
15187                Some("rust"),
15188                "use std::{fs, io};\npub fn caller() { private_helper(); }\nfn private_helper() {}\n",
15189            ),
15190            extract_symbol_graph(
15191                "src/config.rs",
15192                Some("rust"),
15193                "pub fn load_timeout_millis() -> u64 { 250 }\n",
15194            ),
15195            extract_symbol_graph(
15196                "src/handler.rs",
15197                Some("rust"),
15198                "use crate::config;\npub fn health_response() { config::load_timeout_millis(); }\n",
15199            ),
15200            extract_symbol_graph(
15201                "src/router.rs",
15202                Some("rust"),
15203                "use crate::handler;\npub fn dispatch(path: &str) -> Option<()> { (path == \"/health\").then(handler::health_response) }\n",
15204            ),
15205            extract_symbol_graph(
15206                "src/app.js",
15207                Some("javascript"),
15208                "import path from \"node:path\";\nexport function run() { return path.join('a', 'b'); }\n",
15209            ),
15210            extract_symbol_graph(
15211                "src/app.py",
15212                Some("python"),
15213                "import requests\n\ndef run():\n    return requests.get('https://example.test')\n",
15214            ),
15215            extract_symbol_graph(
15216                "Cargo.toml",
15217                Some("cargo-manifest"),
15218                "[package]\nname = \"matrix-app\"\nversion = \"0.1.0\"\n\n[dependencies]\nduplicate = \"1\"\n",
15219            ),
15220            extract_symbol_graph(
15221                "vendor/first/Cargo.toml",
15222                Some("cargo-manifest"),
15223                "[package]\nname = \"duplicate\"\nversion = \"1.0.0\"\n",
15224            ),
15225            extract_symbol_graph(
15226                "vendor/second/Cargo.toml",
15227                Some("cargo-manifest"),
15228                "[package]\nname = \"duplicate\"\nversion = \"2.0.0\"\n",
15229            ),
15230            extract_symbol_graph(
15231                "public/index.html",
15232                Some("html"),
15233                "<script type=\"module\">\nimport fs from \"node:fs\";\nexport function boot() { return fs.readFile; }\n</script>\n",
15234            ),
15235            extract_symbol_graph(
15236                "src/Page.svelte",
15237                Some("svelte"),
15238                "<script lang=\"ts\">\nimport url from \"node:url\";\nexport function page() { return url.parse('https://example.test'); }\n</script>\n",
15239            ),
15240        ];
15241        for (language, expected_relation) in [
15242            ("rust", "std"),
15243            ("javascript", "node:path"),
15244            ("python", "requests"),
15245            ("cargo-manifest", "duplicate"),
15246            ("html", "node:fs"),
15247            ("svelte", "node:url"),
15248        ] {
15249            require(
15250                graphs.iter().any(|graph| {
15251                    graph.language.as_deref() == Some(language)
15252                        && graph
15253                            .relations
15254                            .iter()
15255                            .any(|relation| relation.target_name.contains(expected_relation))
15256                }),
15257                &format!("extracted {language} provider relation is missing"),
15258            )?;
15259        }
15260        let nodes = graphs
15261            .iter()
15262            .map(|graph| {
15263                test_file_node(&graph.path, graph.language.as_deref().unwrap_or("unknown"))
15264            })
15265            .collect::<Vec<_>>();
15266        let packages = PackageIndex::from_graphs(&graphs)?;
15267        let projection = build_entity_projection(
15268            project, generation, &nodes, &graphs, &packages, true, &control,
15269        )?;
15270        let candidates = resolution_registry_from_exports(&projection, &control)?;
15271        let staged = finish_projection(
15272            project,
15273            generation,
15274            RepositoryGraphMutation::Full,
15275            &graphs,
15276            projection,
15277            &candidates,
15278            &control,
15279        )?;
15280        let source_keys = staged
15281            .entities
15282            .iter()
15283            .map(|entity| entity.key().clone())
15284            .collect::<Vec<_>>();
15285        {
15286            let mut publication = store.begin_index_publication("extracted-provider-matrix")?;
15287            publication.begin_scan_replacement()?;
15288            publication.upsert_scan_node_batch(&nodes)?;
15289            publication.finish_scan_replacement()?;
15290            staged.apply(&mut publication, &control)?;
15291            publication.complete()?;
15292        }
15293        drop(store);
15294
15295        let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
15296        let mut reopened = Vec::new();
15297        for source in source_keys {
15298            let page = reader.repository_graph_relations(
15299                RepositoryGraphRelationQuery::Outbound { source },
15300                128,
15301            )?;
15302            require(!page.truncated, "extracted provider matrix was truncated")?;
15303            reopened.extend(page.rows);
15304        }
15305        let mut external = BTreeSet::new();
15306        let mut unresolved = BTreeSet::new();
15307        let mut ambiguous = BTreeMap::new();
15308        let mut resolved_symbols = BTreeSet::new();
15309        for relation in reopened {
15310            require_eq(
15311                &relation.generation(),
15312                &generation,
15313                "extracted relation generation",
15314            )?;
15315            match relation.resolution() {
15316                RelationResolution::External {
15317                    external: target, ..
15318                } => {
15319                    external.insert((
15320                        target.system.as_str().to_string(),
15321                        target.identity.as_str().to_string(),
15322                    ));
15323                }
15324                RelationResolution::Unresolved { reference } => {
15325                    unresolved.insert(reference.as_str().to_string());
15326                }
15327                RelationResolution::Ambiguous {
15328                    reference,
15329                    candidates,
15330                } => {
15331                    ambiguous.insert(reference.as_str().to_string(), candidates.get());
15332                }
15333                RelationResolution::Resolved { selector, .. } => {
15334                    if let ReusableTargetSelector::Symbol { symbol } = selector {
15335                        resolved_symbols.insert((
15336                            symbol.file.as_str().to_string(),
15337                            symbol.name.as_str().to_string(),
15338                        ));
15339                    }
15340                }
15341            }
15342        }
15343        for expected in [
15344            ("rust-toolchain".to_string(), "std::fs".to_string()),
15345            ("rust-toolchain".to_string(), "std::io".to_string()),
15346            ("node".to_string(), "path".to_string()),
15347            ("node".to_string(), "fs".to_string()),
15348            ("node".to_string(), "url".to_string()),
15349        ] {
15350            require(
15351                external.contains(&expected),
15352                &format!("reopened external target is missing: {expected:?}"),
15353            )?;
15354        }
15355        require(
15356            unresolved
15357                .iter()
15358                .any(|reference| reference.contains("requests")),
15359            "reopened Python unresolved import is missing",
15360        )?;
15361        require_eq(
15362            &ambiguous.get("duplicate"),
15363            &Some(&2),
15364            "reopened Cargo duplicate ambiguity",
15365        )?;
15366        require(
15367            resolved_symbols.contains(&("src/lib.rs".to_string(), "private_helper".to_string())),
15368            "reopened private Rust helper resolution is missing",
15369        )?;
15370        require(
15371            resolved_symbols.contains(&(
15372                "src/config.rs".to_string(),
15373                "load_timeout_millis".to_string(),
15374            )),
15375            "reopened Rust module-qualified call resolution is missing",
15376        )?;
15377        require(
15378            resolved_symbols
15379                .contains(&("src/handler.rs".to_string(), "health_response".to_string())),
15380            "reopened Rust callback resolution is missing",
15381        )?;
15382        reader.finish_index_read_snapshot()?;
15383        Ok(())
15384    }
15385
15386    #[test]
15387    fn closed_resolution_states_survive_sqlite_publication_and_reopen() -> Result<(), Box<dyn Error>>
15388    {
15389        let temp = tempfile::tempdir()?;
15390        let root = temp.path().join("closed-resolution-states");
15391        fs::create_dir_all(&root)?;
15392        let database = root.join("projectatlas.db");
15393        let mut store = AtlasStore::open_for_project(&database, &root)?;
15394        let project = store
15395            .project_instance_id()?
15396            .ok_or("bound project identity is missing")?;
15397        let generation = IndexGeneration::new(1);
15398        let control = IndexWorkControl::new(IndexCancellation::new(), None);
15399        let source = test_file_entity(project, generation, "src/main.rs")?;
15400        let unique_target = test_symbol_entity(project, generation, "src/unique.rs", "unique")?;
15401        let first_shared = test_symbol_entity(project, generation, "src/first.rs", "shared")?;
15402        let second_shared = test_symbol_entity(project, generation, "src/second.rs", "shared")?;
15403        let local_package = GraphEntity::new(
15404            project,
15405            EntitySelector::Package {
15406                package: PackageSelector {
15407                    manager: GraphIdentityText::new("cargo")?,
15408                    name: GraphIdentityText::new("local-package")?,
15409                    manifest: RepositoryFilePath::new(Path::new("vendor/local/Cargo.toml"))?,
15410                },
15411            },
15412            generation,
15413        )?;
15414        let unique_key = test_resolution_key(project, "unique")?;
15415        let shared_key = test_resolution_key(project, "shared")?;
15416        let local_package_key = test_resolution_key(project, "local-package")?;
15417        let mut registry = ProjectResolutionRegistry::default();
15418        registry.insert_candidate(&unique_key, &unique_target)?;
15419        registry.insert_candidate(&shared_key, &first_shared)?;
15420        registry.insert_candidate(&shared_key, &second_shared)?;
15421        registry.insert_candidate(&local_package_key, &local_package)?;
15422        let source_digest = source.key().digest().to_string();
15423        let owners = GraphOwners {
15424            file_digest: source_digest.clone(),
15425            symbol_digests: Vec::new(),
15426        };
15427        let staged_entities = BTreeMap::from([(source_digest, source.clone())]);
15428        let mut external_entities = BTreeMap::new();
15429        let cases = [
15430            resolution_case("rust", RelationKind::Calls, "unique", &[&unique_key]),
15431            resolution_case("rust", RelationKind::Calls, "shared", &[&shared_key]),
15432            resolution_case("rust", RelationKind::Calls, "missing", &[]),
15433            resolution_case("rust", RelationKind::Imports, "use std::{fs, io};", &[]),
15434            resolution_case("cargo-manifest", RelationKind::DependsOn, "serde", &[]),
15435            resolution_case("cargo-lock", RelationKind::DependsOn, "serde-lock", &[]),
15436            resolution_case(
15437                "javascript",
15438                RelationKind::Imports,
15439                "import path from \"node:path\";",
15440                &[],
15441            ),
15442            resolution_case(
15443                "html",
15444                RelationKind::Imports,
15445                "import fs from \"node:fs\";",
15446                &[],
15447            ),
15448            resolution_case(
15449                "svelte",
15450                RelationKind::Imports,
15451                "import url from \"node:url\";",
15452                &[],
15453            ),
15454            resolution_case(
15455                "javascript",
15456                RelationKind::Imports,
15457                "import value from \"left-pad\";",
15458                &[],
15459            ),
15460            resolution_case("python", RelationKind::Imports, "import requests", &[]),
15461            resolution_case(
15462                "cargo-manifest",
15463                RelationKind::DependsOn,
15464                "local-package",
15465                &[&local_package_key],
15466            ),
15467        ];
15468        let mut relations = Vec::new();
15469        let mut dependencies = Vec::new();
15470        for case in &cases {
15471            let symbol_index = GraphSymbolIndex::new(&case.graph, &control)?;
15472            let resolution = relation_resolution(
15473                project,
15474                generation,
15475                &case.relation,
15476                None,
15477                &owners,
15478                &case.graph,
15479                &symbol_index,
15480                &case.keys,
15481                &registry,
15482                &staged_entities,
15483                &mut external_entities,
15484                &control,
15485            )?;
15486            let relation = LogicalRelation::new(
15487                &source,
15488                GraphRelationKind::from_legacy(case.relation.kind),
15489                resolution,
15490                ConfidenceClass::High,
15491                Completeness::Complete,
15492                generation,
15493            )?;
15494            for key in &case.keys {
15495                dependencies.push(RelationDependencyKey::new(
15496                    relation.key().clone(),
15497                    key.clone(),
15498                )?);
15499            }
15500            relations.push(relation);
15501        }
15502        let external_keys = external_entities
15503            .values()
15504            .map(|entity| entity.key().clone())
15505            .collect::<Vec<_>>();
15506        let entities = [
15507            source.clone(),
15508            unique_target,
15509            first_shared,
15510            second_shared,
15511            local_package,
15512        ]
15513        .into_iter()
15514        .chain(external_entities.into_values())
15515        .collect::<Vec<_>>();
15516        let exports = [
15517            EntityResolutionKey::new(entities[1].key().clone(), unique_key.clone())?,
15518            EntityResolutionKey::new(entities[2].key().clone(), shared_key.clone())?,
15519            EntityResolutionKey::new(entities[3].key().clone(), shared_key.clone())?,
15520            EntityResolutionKey::new(entities[4].key().clone(), local_package_key.clone())?,
15521        ];
15522        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
15523        let staged = StagedRepositoryGraph {
15524            project,
15525            mutation: RepositoryGraphMutation::Full,
15526            entities,
15527            relations,
15528            occurrences: Vec::new(),
15529            coverage: Vec::new(),
15530            entity_exports: exports.into(),
15531            relation_dependencies: dependencies,
15532            document_unresolved_reasons: Vec::new(),
15533            identity_rejections: Vec::new(),
15534            resolution_derivations: BTreeMap::new(),
15535            peak_retained_bytes: 0,
15536            projection_removals_before_entities: Vec::new(),
15537            scan_policy,
15538            document_target_states: Vec::new(),
15539            database: None,
15540            source_recovery: SourceRecoveryWork::default(),
15541            retained_bytes: 0,
15542        };
15543        {
15544            let mut publication = store.begin_index_publication("closed-resolution-states")?;
15545            publication.begin_scan_replacement()?;
15546            publication.upsert_scan_node_batch(&[
15547                test_file_node("src/main.rs", "rust"),
15548                test_file_node("src/unique.rs", "rust"),
15549                test_file_node("src/first.rs", "rust"),
15550                test_file_node("src/second.rs", "rust"),
15551                test_file_node("vendor/local/Cargo.toml", "cargo-manifest"),
15552            ])?;
15553            publication.finish_scan_replacement()?;
15554            staged.apply(&mut publication, &control)?;
15555            publication.complete()?;
15556        }
15557        drop(store);
15558
15559        let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
15560        let page = reader.repository_graph_relations(
15561            RepositoryGraphRelationQuery::Outbound {
15562                source: source.key().clone(),
15563            },
15564            32,
15565        )?;
15566        require(!page.truncated, "closed resolution proof was truncated")?;
15567        require_eq(&page.rows.len(), &cases.len(), "reopened relation count")?;
15568        let mut resolved = BTreeSet::new();
15569        let mut ambiguous = BTreeMap::new();
15570        let mut unresolved = BTreeSet::new();
15571        let mut external = BTreeSet::new();
15572        for relation in &page.rows {
15573            require_eq(
15574                &relation.generation(),
15575                &generation,
15576                "reopened relation generation",
15577            )?;
15578            match relation.resolution() {
15579                RelationResolution::Resolved {
15580                    selector,
15581                    generation: target_generation,
15582                    ..
15583                } => {
15584                    require_eq(target_generation, &generation, "resolved target generation")?;
15585                    match selector {
15586                        ReusableTargetSelector::Symbol { symbol } => {
15587                            resolved.insert(symbol.name.as_str().to_string());
15588                        }
15589                        ReusableTargetSelector::Package { package } => {
15590                            resolved.insert(package.name.as_str().to_string());
15591                        }
15592                        ReusableTargetSelector::Folder { .. }
15593                        | ReusableTargetSelector::File { .. } => {}
15594                    }
15595                }
15596                RelationResolution::Ambiguous {
15597                    reference,
15598                    candidates,
15599                } => {
15600                    ambiguous.insert(reference.as_str().to_string(), candidates.get());
15601                }
15602                RelationResolution::Unresolved { reference } => {
15603                    unresolved.insert(reference.as_str().to_string());
15604                }
15605                RelationResolution::External {
15606                    external: selector,
15607                    generation: target_generation,
15608                    ..
15609                } => {
15610                    require_eq(target_generation, &generation, "external target generation")?;
15611                    external.insert((
15612                        selector.system.as_str().to_string(),
15613                        selector.identity.as_str().to_string(),
15614                    ));
15615                }
15616            }
15617        }
15618        require_eq(
15619            &resolved,
15620            &BTreeSet::from(["local-package".to_string(), "unique".to_string()]),
15621            "resolved targets",
15622        )?;
15623        require_eq(
15624            &ambiguous,
15625            &BTreeMap::from([("shared".to_string(), 2)]),
15626            "ambiguous targets",
15627        )?;
15628        require_eq(
15629            &unresolved,
15630            &BTreeSet::from([
15631                "import requests".to_string(),
15632                "import value from \"left-pad\";".to_string(),
15633                "missing".to_string(),
15634                "serde-lock".to_string(),
15635            ]),
15636            "unresolved targets",
15637        )?;
15638        require_eq(
15639            &external,
15640            &BTreeSet::from([
15641                ("cargo".to_string(), "serde".to_string()),
15642                ("node".to_string(), "fs".to_string()),
15643                ("node".to_string(), "path".to_string()),
15644                ("node".to_string(), "url".to_string()),
15645                ("rust-toolchain".to_string(), "std".to_string()),
15646            ]),
15647            "external targets",
15648        )?;
15649        for key in &external_keys {
15650            let entity = reader
15651                .repository_graph_entity(key)?
15652                .ok_or("reopened external entity is missing")?;
15653            require(
15654                matches!(entity.selector(), EntitySelector::External { .. }),
15655                "external relation target reopened as a local entity",
15656            )?;
15657            require_eq(
15658                &entity.generation(),
15659                &generation,
15660                "external entity generation",
15661            )?;
15662        }
15663        let affected = reader.repository_affected_source_paths(
15664            project,
15665            &[unique_key, shared_key, local_package_key],
15666            32,
15667        )?;
15668        require(!affected.truncated, "dependency source proof was truncated")?;
15669        require_eq(
15670            &affected.rows,
15671            &vec![RepositoryFilePath::new(Path::new("src/main.rs"))?],
15672            "reopened dependency source paths",
15673        )?;
15674        reader.finish_index_read_snapshot()?;
15675        Ok(())
15676    }
15677
15678    struct ResolutionCase {
15679        graph: SymbolGraph,
15680        relation: SymbolRelation,
15681        keys: Vec<CanonicalResolutionKey>,
15682    }
15683
15684    fn resolution_case(
15685        language: &str,
15686        kind: RelationKind,
15687        target: &str,
15688        keys: &[&CanonicalResolutionKey],
15689    ) -> ResolutionCase {
15690        let parser = if language.starts_with("cargo-") {
15691            ParserKind::Manifest
15692        } else {
15693            ParserKind::TreeSitter
15694        };
15695        ResolutionCase {
15696            graph: SymbolGraph {
15697                path: "src/main.rs".to_string(),
15698                language: Some(language.to_string()),
15699                parser,
15700                symbols: Vec::new(),
15701                relations: Vec::new(),
15702            },
15703            relation: SymbolRelation {
15704                path: "src/main.rs".to_string(),
15705                source_name: "src/main.rs".to_string(),
15706                target_name: target.to_string(),
15707                kind,
15708                line: 1,
15709                context: target.to_string(),
15710                parser,
15711            },
15712            keys: keys.iter().map(|key| (*key).clone()).collect(),
15713        }
15714    }
15715
15716    fn test_resolution_key(
15717        project: ProjectInstanceId,
15718        identity: &str,
15719    ) -> Result<CanonicalResolutionKey, Box<dyn Error>> {
15720        let provider = GraphIdentityText::new("test-provider")?;
15721        let language = GraphIdentityText::new("test-language")?;
15722        let identity = GraphIdentityText::new(identity)?;
15723        Ok(CanonicalResolutionKey::new(
15724            project,
15725            ResolutionKeyDomain::Declaration,
15726            &provider,
15727            &language,
15728            None,
15729            None,
15730            None,
15731            &identity,
15732        ))
15733    }
15734
15735    fn test_file_entity(
15736        project: ProjectInstanceId,
15737        generation: IndexGeneration,
15738        path: &str,
15739    ) -> Result<GraphEntity, Box<dyn Error>> {
15740        Ok(GraphEntity::new(
15741            project,
15742            EntitySelector::File {
15743                path: RepositoryFilePath::new(Path::new(path))?,
15744            },
15745            generation,
15746        )?)
15747    }
15748
15749    fn test_symbol_entity(
15750        project: ProjectInstanceId,
15751        generation: IndexGeneration,
15752        path: &str,
15753        name: &str,
15754    ) -> Result<GraphEntity, Box<dyn Error>> {
15755        Ok(GraphEntity::new(
15756            project,
15757            EntitySelector::Symbol {
15758                symbol: SymbolSelector {
15759                    file: RepositoryFilePath::new(Path::new(path))?,
15760                    name: GraphIdentityText::new(name)?,
15761                    kind: SymbolKind::Function,
15762                    parent: None,
15763                    signature: GraphIdentityText::new(format!("fn {name}()"))?,
15764                },
15765            },
15766            generation,
15767        )?)
15768    }
15769
15770    fn test_code_symbol(
15771        path: &str,
15772        name: &str,
15773        parent: Option<&str>,
15774        signature: &str,
15775    ) -> CodeSymbol {
15776        CodeSymbol {
15777            path: path.to_string(),
15778            language: Some("rust".to_string()),
15779            name: name.to_string(),
15780            kind: SymbolKind::Function,
15781            signature: signature.to_string(),
15782            exported: false,
15783            documentation: None,
15784            line_start: 1,
15785            line_end: 1,
15786            source_selector: None,
15787            parent: parent.map(ToString::to_string),
15788            parser: ParserKind::TreeSitter,
15789            detail: Some("function_item".to_string()),
15790        }
15791    }
15792
15793    #[test]
15794    fn source_graph_admission_keeps_valid_rows_and_typed_rejection_coverage()
15795    -> Result<(), Box<dyn Error>> {
15796        let valid = test_code_symbol("src/lib.rs", "valid", None, "fn valid()");
15797        let invalid_name = test_code_symbol("src/lib.rs", "bad\u{0}name", None, "fn bad()");
15798        let invalid_signature = test_code_symbol("src/lib.rs", "padded", None, " fn padded() ");
15799        let invalid_parent = test_code_symbol("src/lib.rs", "child", Some(" parent "), "child()");
15800        let invalid_reserved = test_code_symbol(
15801            "src/lib.rs",
15802            &format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}derived"),
15803            None,
15804            "fn derived()",
15805        );
15806        let invalid_oversized = test_code_symbol(
15807            "src/lib.rs",
15808            &"x".repeat(MAX_GRAPH_IDENTITY_BYTES + 1),
15809            None,
15810            "fn oversized()",
15811        );
15812        let graph = SymbolGraph {
15813            path: "src/lib.rs".to_string(),
15814            language: Some("rust".to_string()),
15815            parser: ParserKind::TreeSitter,
15816            symbols: vec![
15817                valid,
15818                invalid_name,
15819                invalid_signature,
15820                invalid_parent,
15821                invalid_reserved,
15822                invalid_oversized,
15823            ],
15824            relations: vec![
15825                SymbolRelation {
15826                    path: "src/lib.rs".to_string(),
15827                    source_name: "valid".to_string(),
15828                    target_name: "helper".to_string(),
15829                    kind: RelationKind::Calls,
15830                    line: 1,
15831                    context: "helper()".to_string(),
15832                    parser: ParserKind::TreeSitter,
15833                },
15834                SymbolRelation {
15835                    path: "src/lib.rs".to_string(),
15836                    source_name: "valid".to_string(),
15837                    target_name: "bad\u{0}target".to_string(),
15838                    kind: RelationKind::Calls,
15839                    line: 2,
15840                    context: "bad()".to_string(),
15841                    parser: ParserKind::TreeSitter,
15842                },
15843                SymbolRelation {
15844                    path: "src/lib.rs".to_string(),
15845                    source_name: " valid ".to_string(),
15846                    target_name: "helper".to_string(),
15847                    kind: RelationKind::Calls,
15848                    line: 3,
15849                    context: "helper()".to_string(),
15850                    parser: ParserKind::TreeSitter,
15851                },
15852            ],
15853        };
15854        let control = IndexWorkControl::new(IndexCancellation::new(), None);
15855        let (admitted, report) = super::admit_symbol_graph(Cow::Owned(graph), &control)?;
15856        require_eq(&admitted.symbols.len(), &1, "valid symbol admission")?;
15857        require_eq(&admitted.relations.len(), &1, "valid relation admission")?;
15858        require_eq(
15859            &report.rejected_facts_for("src/lib.rs"),
15860            &7,
15861            "rejected fact count",
15862        )?;
15863        require_eq(
15864            &admitted.symbols[0].signature,
15865            &"fn valid()".to_string(),
15866            "valid signature preservation",
15867        )?;
15868
15869        let coverage = super::coverage_for_graph(
15870            &admitted,
15871            IndexGeneration::new(1),
15872            &report,
15873            &super::GraphIdentityAdmission::default(),
15874        )?;
15875        require_eq(
15876            &coverage.state(),
15877            &CoverageState::Partial,
15878            "admission coverage state",
15879        )?;
15880        require_eq(&coverage.covered(), &2, "admission covered count")?;
15881        require_eq(&coverage.omitted(), &7, "admission omitted count")?;
15882        let reason = coverage
15883            .reason()
15884            .ok_or_else(|| io::Error::other("admission coverage omitted its reason"))?
15885            .as_str();
15886        require_eq(
15887            &reason,
15888            &PARTIAL_COVERAGE_REASON,
15889            "admission coverage keeps the stable coarse reason",
15890        )?;
15891        require_eq(&report.rejections.len(), &7, "typed rejection detail count")?;
15892        require(
15893            report.rejections.iter().all(|rejection| {
15894                rejection.path.as_str() == "src/lib.rs"
15895                    && rejection.parser == ParserKind::TreeSitter
15896                    && rejection.span.start_line() >= 1
15897                    && rejection.span.end_line() >= rejection.span.start_line()
15898            }),
15899            "typed rejection details lost path/parser/span ownership",
15900        )?;
15901        require(
15902            report
15903                .rejections
15904                .iter()
15905                .any(|rejection| rejection.field == GraphIdentityField::RelationTarget),
15906            "typed rejection details lost relation-target ownership",
15907        )?;
15908
15909        let temp = tempfile::tempdir()?;
15910        fs::create_dir_all(temp.path().join("src"))?;
15911        let nodes = vec![test_file_node("src/lib.rs", "rust")];
15912        let project = ProjectInstanceId::from_bytes([71; 16])?;
15913        let generation = IndexGeneration::new(1);
15914        let packages = PackageIndex::from_graphs(std::slice::from_ref(&admitted))?;
15915        let projection = build_entity_projection(
15916            project,
15917            generation,
15918            &nodes,
15919            std::slice::from_ref(&admitted),
15920            &packages,
15921            true,
15922            &control,
15923        )?;
15924        let candidates = resolution_registry_from_exports(&projection, &control)?;
15925        let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
15926        let staged = super::finish_projection_with_documents(
15927            project,
15928            generation,
15929            RepositoryGraphMutation::Full,
15930            std::slice::from_ref(&admitted),
15931            temp.path(),
15932            &nodes,
15933            &BTreeMap::new(),
15934            &report,
15935            projection,
15936            &candidates,
15937            &scan_policy,
15938            &control,
15939        )?;
15940        require_eq(&staged.relations.len(), &1, "valid relation publication")?;
15941        require(
15942            staged.entities.iter().any(|entity| {
15943                matches!(
15944                    entity.selector(),
15945                    EntitySelector::Symbol { symbol } if symbol.name.as_str() == "valid"
15946                )
15947            }),
15948            "valid symbol publication was lost with invalid siblings",
15949        )?;
15950        require_eq(
15951            &staged.identity_rejections.len(),
15952            &7,
15953            "typed rejection coverage was not staged with valid rows",
15954        )?;
15955        Ok(())
15956    }
15957
15958    #[test]
15959    fn invalid_sibling_does_not_fail_symbol_only_coverage() -> Result<(), Box<dyn Error>> {
15960        let graph = SymbolGraph {
15961            path: "src/symbol-only.rs".to_string(),
15962            language: Some("rust".to_string()),
15963            parser: ParserKind::TreeSitter,
15964            symbols: vec![
15965                test_code_symbol("src/symbol-only.rs", "valid", None, "fn valid()"),
15966                test_code_symbol("src/symbol-only.rs", "bad\u{0}name", None, "fn bad()"),
15967            ],
15968            relations: Vec::new(),
15969        };
15970        let control = IndexWorkControl::new(IndexCancellation::new(), None);
15971        let (admitted, report) = super::admit_symbol_graph(Cow::Owned(graph), &control)?;
15972        let coverage = super::coverage_for_graph(
15973            &admitted,
15974            IndexGeneration::new(1),
15975            &report,
15976            &super::GraphIdentityAdmission::default(),
15977        )?;
15978        require_eq(
15979            &coverage.state(),
15980            &CoverageState::Partial,
15981            "valid symbols keep mixed identity coverage partial",
15982        )?;
15983        require_eq(
15984            &coverage.covered(),
15985            &1,
15986            "valid symbol is counted as covered",
15987        )?;
15988        require_eq(
15989            &coverage.omitted(),
15990            &1,
15991            "invalid sibling is counted as omitted",
15992        )?;
15993        require_eq(
15994            &coverage.reason().map(GraphIdentityText::as_str),
15995            &Some(PARTIAL_COVERAGE_REASON),
15996            "symbol-only coverage keeps the coarse reason",
15997        )?;
15998        Ok(())
15999    }
16000
16001    #[test]
16002    fn document_target_normalization_is_relative_bounded_and_platform_neutral()
16003    -> Result<(), Box<dyn Error>> {
16004        require_eq(
16005            &normalize_document_target("docs/guide.md", "../src/lib.rs:L12-L20?view=raw#entry")
16006                .map_err(|reason| io::Error::other(reason.to_string()))?,
16007            &DocumentTargetIdentity {
16008                path: "src/lib.rs".to_string(),
16009                fragment: Some("entry".to_string()),
16010            },
16011            "relative document target",
16012        )?;
16013        require_eq(
16014            &normalize_document_target("guide.md", "README")
16015                .map_err(|reason| io::Error::other(reason.to_string()))?,
16016            &DocumentTargetIdentity {
16017                path: "README".to_string(),
16018                fragment: None,
16019            },
16020            "root extensionless target",
16021        )?;
16022        require_eq(
16023            &normalize_document_target("docs/guide.md", "../../../private.txt"),
16024            &Err(DocumentTargetUnresolvedReason::OutsideRoot),
16025            "outside-root selector",
16026        )?;
16027        require_eq(
16028            &normalize_document_target("docs/guide.md", "target.md#runtime value"),
16029            &Err(DocumentTargetUnresolvedReason::NoStaticTarget),
16030            "non-static fragment refusal",
16031        )?;
16032        Ok(())
16033    }
16034
16035    #[test]
16036    fn document_rows_resolve_exact_files_and_headings_with_typed_absence()
16037    -> Result<(), Box<dyn Error>> {
16038        let temp = tempfile::tempdir()?;
16039        let root = temp.path();
16040        fs::create_dir_all(root.join("docs"))?;
16041        fs::write(root.join(".gitignore"), "docs/ignored.md\n")?;
16042        fs::write(root.join("docs/ignored.md"), "ignored")?;
16043        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";
16044        let source_facts = projectatlas_symbols::extract_markdown_facts(source);
16045        let target_facts = projectatlas_symbols::extract_markdown_facts("# API\n");
16046        let source_graph = source_facts.symbol_graph("docs/guide.md", Some("markdown"));
16047        let target_graph = target_facts.symbol_graph("docs/target.md", Some("markdown"));
16048        let source_code_graph = SymbolGraph {
16049            path: "src/lib.rs".to_string(),
16050            language: Some("rust".to_string()),
16051            parser: ParserKind::TreeSitter,
16052            symbols: Vec::new(),
16053            relations: Vec::new(),
16054        };
16055        let graphs = vec![source_graph, target_graph, source_code_graph];
16056        let mut nodes = vec![
16057            test_file_node("docs/guide.md", "markdown"),
16058            test_file_node("docs/target.md", "markdown"),
16059            test_file_node("src/lib.rs", "rust"),
16060        ];
16061        let mut folder = test_file_node("src", "unknown");
16062        folder.kind = NodeKind::Folder;
16063        folder.language = None;
16064        folder.extension = None;
16065        nodes.push(folder);
16066        let project = ProjectInstanceId::from_bytes([31; 16])?;
16067        let generation = IndexGeneration::new(4);
16068        let packages = PackageIndex::from_graphs(&graphs)?;
16069        let control = super::super::standalone_index_work_control();
16070        let mut projection = build_entity_projection(
16071            project, generation, &nodes, &graphs, &packages, true, &control,
16072        )?;
16073        let candidates = resolution_registry_from_exports(&projection, &control)?;
16074        let owners = projection
16075            .owners_by_graph
16076            .remove("docs/guide.md")
16077            .ok_or_else(|| io::Error::other("document owners were not projected"))?;
16078        let scan_policy = RootScanPolicy::discover(root, &ScanOptions::default(), &control)?;
16079        let index = DocumentResolutionIndex::new(root, &nodes, &scan_policy)?;
16080        let rows = project_document_rows(
16081            project,
16082            generation,
16083            &graphs[0],
16084            &source_facts,
16085            &owners,
16086            &index,
16087            &candidates,
16088            &projection.entity_by_digest,
16089            &control,
16090        )?;
16091        require_eq(&rows.relations.len(), &9, "document relation count")?;
16092        require_eq(&rows.occurrences.len(), &9, "document occurrence count")?;
16093        require_eq(
16094            &rows
16095                .relations
16096                .iter()
16097                .filter(|relation| {
16098                    matches!(relation.resolution(), RelationResolution::Resolved { .. })
16099                })
16100                .count(),
16101            &2,
16102            "resolved document targets",
16103        )?;
16104        let reasons = rows
16105            .document_unresolved_reasons
16106            .iter()
16107            .map(|(_key, reason)| *reason)
16108            .collect::<BTreeSet<_>>();
16109        require_eq(
16110            &reasons,
16111            &BTreeSet::from([
16112                DocumentTargetUnresolvedReason::Missing,
16113                DocumentTargetUnresolvedReason::Ignored,
16114                DocumentTargetUnresolvedReason::OutsideRoot,
16115                DocumentTargetUnresolvedReason::CaseConflict,
16116                DocumentTargetUnresolvedReason::Unsupported,
16117            ]),
16118            "typed unresolved reasons",
16119        )?;
16120        require(
16121            rows.relations.iter().any(|relation| {
16122                matches!(
16123                    relation.resolution(),
16124                    RelationResolution::Resolved {
16125                        selector: projectatlas_core::graph::ReusableTargetSelector::Symbol {
16126                            symbol
16127                        },
16128                        ..
16129                    } if symbol.kind == SymbolKind::Heading && symbol.signature.as_str() == "api"
16130                )
16131            }),
16132            "heading fragment did not resolve to its heading entity",
16133        )?;
16134        Ok(())
16135    }
16136
16137    #[test]
16138    fn markdown_identity_admission_keeps_valid_document_siblings() -> Result<(), Box<dyn Error>> {
16139        let temp = tempfile::tempdir()?;
16140        let root = fs::canonicalize(temp.path())?;
16141        fs::create_dir_all(root.join("docs"))?;
16142        let guide_source = "# Guide\n\n[invalid](<target.md\u{1}>)\n[valid](target.md#target)\n";
16143        fs::write(root.join("docs/guide.md"), guide_source)?;
16144        fs::write(root.join("docs/target.md"), "# Target\n")?;
16145        let source_facts = projectatlas_symbols::extract_markdown_facts(guide_source);
16146        require_eq(
16147            &source_facts.link_candidates.len(),
16148            &2,
16149            "parser Markdown sibling candidate count",
16150        )?;
16151        require_eq(
16152            &source_facts.link_candidates[0].selector,
16153            &"target.md\u{1}".to_string(),
16154            "parser Markdown control selector",
16155        )?;
16156        let source_graph = source_facts.symbol_graph("docs/guide.md", Some("markdown"));
16157        let target_facts = projectatlas_symbols::extract_markdown_facts("# Target\n");
16158        let target_graph = target_facts.symbol_graph("docs/target.md", Some("markdown"));
16159        let nodes = vec![
16160            test_file_node("docs/guide.md", "markdown"),
16161            test_file_node("docs/target.md", "markdown"),
16162        ];
16163        let control = super::super::standalone_index_work_control();
16164        let mut symbols = empty_symbol_build_stage();
16165        symbols.report.candidates = 2;
16166        symbols.report.parsed = 2;
16167        symbols.report.summaries = 2;
16168        symbols.changes = vec![
16169            SymbolProjectionChange::Parsed(SymbolParseSuccess {
16170                path: "docs/guide.md".to_string(),
16171                graph: source_graph.clone(),
16172                markdown_facts: Some(Box::new(source_facts)),
16173                source_semantics: None,
16174                source_parser: ParserKind::Structural,
16175                summary: "Guide".to_string(),
16176                summary_is_structural: true,
16177                purpose_suggestion: None,
16178            }),
16179            SymbolProjectionChange::Parsed(SymbolParseSuccess {
16180                path: "docs/target.md".to_string(),
16181                graph: target_graph.clone(),
16182                markdown_facts: Some(Box::new(target_facts)),
16183                source_semantics: None,
16184                source_parser: ParserKind::Structural,
16185                summary: "Target".to_string(),
16186                summary_is_structural: true,
16187                purpose_suggestion: None,
16188            }),
16189        ];
16190        symbols.identity_admission = super::admit_symbol_build_stage(&mut symbols, &control)?;
16191        let admitted_counts = symbols
16192            .changes
16193            .iter()
16194            .filter_map(|change| match change {
16195                SymbolProjectionChange::Parsed(parsed) => {
16196                    Some((parsed.graph.symbols.len(), parsed.graph.relations.len()))
16197                }
16198                SymbolProjectionChange::Clear { .. } => None,
16199            })
16200            .fold(
16201                (0, 0),
16202                |(symbols, relations), (next_symbols, next_relations)| {
16203                    (symbols + next_symbols, relations + next_relations)
16204                },
16205            );
16206        require_eq(
16207            &(symbols.report.symbols, symbols.report.relations),
16208            &admitted_counts,
16209            "post-admission symbol report counts",
16210        )?;
16211        require_eq(
16212            &symbols.identity_admission.rejections.len(),
16213            &1,
16214            "Markdown rejection detail count",
16215        )?;
16216        let rejection = symbols
16217            .identity_admission
16218            .rejections
16219            .first()
16220            .ok_or_else(|| io::Error::other("Markdown rejection detail is missing"))?;
16221        require_eq(
16222            &rejection.path.as_str(),
16223            &"docs/guide.md",
16224            "Markdown rejection path",
16225        )?;
16226        require_eq(
16227            &rejection.parser,
16228            &ParserKind::Structural,
16229            "Markdown rejection parser",
16230        )?;
16231        require_eq(
16232            &rejection.field,
16233            &GraphIdentityField::RelationTarget,
16234            "Markdown rejection field",
16235        )?;
16236        require_eq(
16237            &rejection.reason,
16238            &GraphIdentityRejectionReason::ControlCharacters,
16239            "Markdown rejection reason",
16240        )?;
16241        require_eq(
16242            &rejection.span.start_line(),
16243            &3,
16244            "Markdown rejection start line",
16245        )?;
16246        require_eq(
16247            &rejection.span.start_column(),
16248            &0,
16249            "Markdown rejection start column",
16250        )?;
16251        require_eq(
16252            &rejection.span.end_line(),
16253            &3,
16254            "Markdown rejection end line",
16255        )?;
16256        require_eq(
16257            &rejection.span.end_column(),
16258            &23,
16259            "Markdown rejection end column",
16260        )?;
16261        let database = root.join("projectatlas.db");
16262        let mut store = AtlasStore::open_for_project(&database, &root)?;
16263        store.replace_scan(&nodes)?;
16264        store.replace_symbol_graph(&source_graph)?;
16265        store.replace_symbol_graph(&target_graph)?;
16266        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
16267        let staged = stage_full_repository_graph(
16268            &store,
16269            &root,
16270            IndexGeneration::ZERO,
16271            &nodes,
16272            &scan_policy,
16273            &symbols,
16274            &control,
16275        )?;
16276        require_eq(
16277            &staged.identity_rejections.len(),
16278            &1,
16279            "staged Markdown rejection detail count",
16280        )?;
16281        require_eq(
16282            &staged.relations.len(),
16283            &1,
16284            "staged valid Markdown relation count",
16285        )?;
16286        require(
16287            staged.coverage.iter().any(|coverage| {
16288                matches!(
16289                    coverage.scope(),
16290                    CoverageScope::Path { path } if path.as_str() == "docs/guide.md"
16291                ) && coverage.state() == CoverageState::Complete
16292                    && coverage.covered() == 1
16293                    && coverage.omitted() == 0
16294            }),
16295            "invalid Markdown selector changed valid-sibling coverage semantics",
16296        )?;
16297        require(
16298            staged.relations.iter().any(|relation| {
16299                matches!(
16300                    relation.resolution(),
16301                    RelationResolution::Resolved {
16302                        selector: projectatlas_core::graph::ReusableTargetSelector::Symbol {
16303                            symbol
16304                        },
16305                        ..
16306                    } if symbol.file.as_str() == "docs/target.md"
16307                        && symbol.signature.as_str() == "target"
16308                )
16309            }),
16310            "staged valid Markdown sibling was not resolved",
16311        )?;
16312        publish_full_staged_graph(&mut store, &nodes, &staged, &control, "markdown-admission")?;
16313        drop(store);
16314        let mut store = AtlasStore::open_for_project(&database, &root)?;
16315        let project = store
16316            .project_instance_id()?
16317            .ok_or("Markdown admission project identity is missing")?;
16318        let paths = nodes
16319            .iter()
16320            .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
16321            .collect::<Result<Vec<_>, _>>()?;
16322        let persisted_rejections =
16323            store.repository_graph_identity_rejections(project, &paths, 16, None)?;
16324        require_eq(
16325            &persisted_rejections.len(),
16326            &1,
16327            "persisted Markdown rejection detail count",
16328        )?;
16329        let persisted_rejection = persisted_rejections
16330            .first()
16331            .ok_or("persisted Markdown rejection detail is missing")?;
16332        require_eq(
16333            &persisted_rejection.span.start_line(),
16334            &3,
16335            "persisted Markdown rejection start line",
16336        )?;
16337        require_eq(
16338            &persisted_rejection.span.start_column(),
16339            &0,
16340            "persisted Markdown rejection start column",
16341        )?;
16342        require_eq(
16343            &persisted_rejection.span.end_line(),
16344            &3,
16345            "persisted Markdown rejection end line",
16346        )?;
16347        require_eq(
16348            &persisted_rejection.span.end_column(),
16349            &23,
16350            "persisted Markdown rejection end column",
16351        )?;
16352        let persisted_wire = serde_json::to_string(&persisted_rejections)?;
16353        require(
16354            !persisted_wire.contains("\\u0001") && !persisted_wire.contains("target.md"),
16355            "persisted Markdown rejection retained the invalid selector",
16356        )?;
16357        let persisted_relations = store.repository_graph_relation_rows(
16358            RepositoryGraphRelationQuery::Family {
16359                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
16360            },
16361            GraphLimits::MAX_ROWS,
16362            None,
16363        )?;
16364        require_eq(
16365            &persisted_relations.rows.len(),
16366            &1,
16367            "reopened valid Markdown relation count",
16368        )?;
16369        require(
16370            persisted_relations.rows.iter().any(|relation| {
16371                matches!(
16372                    relation.relation.resolution(),
16373                    RelationResolution::Resolved {
16374                        selector: projectatlas_core::graph::ReusableTargetSelector::Symbol {
16375                            symbol
16376                        },
16377                        ..
16378                    } if symbol.file.as_str() == "docs/target.md"
16379                        && symbol.signature.as_str() == "target"
16380                )
16381            }),
16382            "reopened valid Markdown sibling was not resolved",
16383        )?;
16384
16385        let base_generation = store
16386            .index_publication()?
16387            .ok_or("Markdown admission publication is missing")?
16388            .generation;
16389        let mut incremental_symbols = symbol_build_stage_for_markdown(
16390            source_graph.clone(),
16391            projectatlas_symbols::extract_markdown_facts(guide_source),
16392        );
16393        incremental_symbols.identity_admission =
16394            super::admit_symbol_build_stage(&mut incremental_symbols, &control)?;
16395        let incremental_stage = stage_incremental_repository_graph(
16396            &store,
16397            &root,
16398            base_generation,
16399            &nodes,
16400            &["docs/guide.md".to_string()],
16401            &scan_policy,
16402            &incremental_symbols,
16403            &control,
16404        )?;
16405        require_eq(
16406            &incremental_stage.identity_rejections.len(),
16407            &1,
16408            "incremental Markdown rejection detail count",
16409        )?;
16410        require_eq(
16411            &incremental_stage.relations.len(),
16412            &1,
16413            "incremental valid Markdown relation count",
16414        )?;
16415        let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
16416        canceled.cancel();
16417        {
16418            let mut publication = store.begin_index_publication("markdown-admission-cancel")?;
16419            let error = incremental_stage.apply(&mut publication, &canceled).err();
16420            require(
16421                matches!(
16422                    error,
16423                    Some(CliError::IndexWork(IndexWorkFailure::Cancelled {
16424                        stage: IndexWorkStage::Publication
16425                    }))
16426                ),
16427                "incremental Markdown cancellation was not observed",
16428            )?;
16429        }
16430        require_eq(
16431            &store
16432                .index_publication()?
16433                .map(|publication| publication.generation),
16434            &Some(base_generation),
16435            "publication after canceled Markdown refresh",
16436        )?;
16437        {
16438            let mut publication =
16439                store.begin_index_publication("markdown-admission-incremental")?;
16440            incremental_stage.apply(&mut publication, &control)?;
16441            publication.complete()?;
16442        }
16443        let incremental_publication = store
16444            .index_publication()?
16445            .ok_or("incremental Markdown publication is missing")?;
16446        require_eq(
16447            &incremental_publication.generation,
16448            &base_generation
16449                .checked_next()
16450                .ok_or("Markdown generation overflowed")?,
16451            "incremental Markdown generation",
16452        )?;
16453        drop(store);
16454        let mut store = AtlasStore::open_for_project(&database, &root)?;
16455        let reopened_incremental =
16456            store.repository_graph_identity_rejections(project, &paths, 16, None)?;
16457        require_eq(
16458            &reopened_incremental,
16459            &persisted_rejections,
16460            "reopened incremental Markdown rejection details",
16461        )?;
16462
16463        let fault_generation = store
16464            .index_publication()?
16465            .ok_or("incremental Markdown publication disappeared")?
16466            .generation;
16467        let mut fault_symbols = symbol_build_stage_for_markdown(
16468            source_graph.clone(),
16469            projectatlas_symbols::extract_markdown_facts(guide_source),
16470        );
16471        fault_symbols.identity_admission =
16472            super::admit_symbol_build_stage(&mut fault_symbols, &control)?;
16473        let mut fault_stage = stage_incremental_repository_graph(
16474            &store,
16475            &root,
16476            fault_generation,
16477            &nodes,
16478            &["docs/guide.md".to_string()],
16479            &scan_policy,
16480            &fault_symbols,
16481            &control,
16482        )?;
16483        fault_stage.identity_rejections.resize(
16484            usize::try_from(GraphLimits::MAX_ROWS)
16485                .unwrap_or(usize::MAX)
16486                .saturating_add(1),
16487            fault_stage.identity_rejections[0].clone(),
16488        );
16489        {
16490            let mut publication = store.begin_index_publication("markdown-admission-fault")?;
16491            require(
16492                fault_stage.apply(&mut publication, &control).is_err(),
16493                "late Markdown rejection-detail fault did not fail",
16494            )?;
16495        }
16496        require_eq(
16497            &store
16498                .index_publication()?
16499                .map(|publication| publication.generation),
16500            &Some(fault_generation),
16501            "generation after late Markdown rejection-detail fault",
16502        )?;
16503        require_eq(
16504            &store.repository_graph_identity_rejections(project, &paths, 16, None)?,
16505            &reopened_incremental,
16506            "previous Markdown generation after late fault",
16507        )?;
16508        let retry_symbols = symbol_build_stage_for_markdown(
16509            source_graph,
16510            projectatlas_symbols::extract_markdown_facts(guide_source),
16511        );
16512        let retry_stage = stage_incremental_repository_graph(
16513            &store,
16514            &root,
16515            fault_generation,
16516            &nodes,
16517            &["docs/guide.md".to_string()],
16518            &scan_policy,
16519            &retry_symbols,
16520            &control,
16521        )?;
16522        {
16523            let mut publication = store.begin_index_publication("markdown-admission-retry")?;
16524            retry_stage.apply(&mut publication, &control)?;
16525            publication.complete()?;
16526        }
16527        require_eq(
16528            &store.repository_graph_identity_rejections(project, &paths, 16, None)?,
16529            &reopened_incremental,
16530            "deterministic Markdown retry",
16531        )?;
16532        Ok(())
16533    }
16534
16535    #[test]
16536    fn document_rows_use_file_identity_and_deduplicate_across_headings()
16537    -> Result<(), Box<dyn Error>> {
16538        let temp = tempfile::tempdir()?;
16539        fs::create_dir_all(temp.path().join("src"))?;
16540        fs::write(temp.path().join("src/lib.rs"), "pub fn entry() {}\n")?;
16541        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";
16542        let facts = projectatlas_symbols::extract_markdown_facts(source);
16543        let enclosing_heading_bytes = facts
16544            .link_candidates
16545            .iter()
16546            .map(|candidate| candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64)
16547            .sum::<u64>();
16548        let mut facts_without_heading_owners = facts.clone();
16549        for candidate in &mut facts_without_heading_owners.link_candidates {
16550            candidate.enclosing_heading = None;
16551        }
16552        let retained_with_heading_owners = document_fact_map_retained_bytes(&BTreeMap::from([(
16553            "docs/guide.md".to_string(),
16554            Cow::Owned(facts.clone()),
16555        )]));
16556        let retained_without_heading_owners =
16557            document_fact_map_retained_bytes(&BTreeMap::from([(
16558                "docs/guide.md".to_string(),
16559                Cow::Owned(facts_without_heading_owners),
16560            )]));
16561        require_eq(
16562            &retained_with_heading_owners.saturating_sub(retained_without_heading_owners),
16563            &enclosing_heading_bytes,
16564            "document registry enclosing-heading bytes",
16565        )?;
16566        let source_graph = facts.symbol_graph("docs/guide.md", Some("markdown"));
16567        let target_graph = SymbolGraph {
16568            path: "src/lib.rs".to_string(),
16569            language: Some("rust".to_string()),
16570            parser: ParserKind::TreeSitter,
16571            symbols: Vec::new(),
16572            relations: Vec::new(),
16573        };
16574        let graphs = vec![source_graph, target_graph];
16575        let nodes = vec![
16576            test_file_node("docs/guide.md", "markdown"),
16577            test_file_node("src/lib.rs", "rust"),
16578        ];
16579        let project = ProjectInstanceId::from_bytes([33; 16])?;
16580        let generation = IndexGeneration::new(5);
16581        let packages = PackageIndex::from_graphs(&graphs)?;
16582        let control = super::super::standalone_index_work_control();
16583        let mut projection = build_entity_projection(
16584            project, generation, &nodes, &graphs, &packages, true, &control,
16585        )?;
16586        let candidates = resolution_registry_from_exports(&projection, &control)?;
16587        let owners = projection
16588            .owners_by_graph
16589            .remove("docs/guide.md")
16590            .ok_or_else(|| io::Error::other("document owners were not projected"))?;
16591        let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
16592        let index = DocumentResolutionIndex::new(temp.path(), &nodes, &scan_policy)?;
16593        let rows = project_document_rows(
16594            project,
16595            generation,
16596            &graphs[0],
16597            &facts,
16598            &owners,
16599            &index,
16600            &candidates,
16601            &projection.entity_by_digest,
16602            &control,
16603        )?;
16604        require_eq(&rows.relations.len(), &3, "file-owned relation count")?;
16605        require_eq(&rows.occurrences.len(), &7, "distinct link occurrences")?;
16606        require(
16607            rows.relations.iter().all(|relation| {
16608                projection
16609                    .entity_by_digest
16610                    .get(relation.source().digest())
16611                    .is_some_and(|entity| {
16612                        matches!(
16613                            entity.selector(),
16614                            EntitySelector::File { path } if path.as_str() == "docs/guide.md"
16615                        )
16616                    })
16617            }),
16618            "document relations were not owned by the document file",
16619        )?;
16620        let heading_targets = rows
16621            .relations
16622            .iter()
16623            .filter_map(|relation| {
16624                let RelationResolution::Resolved {
16625                    selector: projectatlas_core::graph::ReusableTargetSelector::Symbol { symbol },
16626                    ..
16627                } = relation.resolution()
16628                else {
16629                    return None;
16630                };
16631                (symbol.kind == SymbolKind::Heading).then(|| symbol.signature.as_str().to_string())
16632            })
16633            .collect::<BTreeSet<_>>();
16634        require_eq(
16635            &heading_targets,
16636            &BTreeSet::from(["first".to_string(), "second".to_string()]),
16637            "file-owned heading targets",
16638        )?;
16639        let source_target_occurrences = rows
16640            .occurrences
16641            .iter()
16642            .filter(|occurrence| occurrence.file().as_str() == "docs/guide.md")
16643            .count();
16644        require_eq(
16645            &source_target_occurrences,
16646            &7,
16647            "file-owned relation occurrences",
16648        )?;
16649        Ok(())
16650    }
16651
16652    #[test]
16653    fn complete_document_without_static_candidates_reports_no_candidates()
16654    -> Result<(), Box<dyn Error>> {
16655        let facts = projectatlas_symbols::extract_markdown_facts(
16656            "# Overview\n\nLong prose without a repository reference.\n",
16657        );
16658        let coverage = document_coverage("docs/overview.md", &facts, IndexGeneration::new(6))?;
16659        require_eq(
16660            &coverage.state(),
16661            &CoverageState::NoCandidates,
16662            "empty document relation coverage",
16663        )?;
16664        require_eq(&coverage.total(), &0, "empty document relation total")?;
16665        Ok(())
16666    }
16667
16668    #[test]
16669    fn partial_markdown_evidence_limit_maps_to_intermediate_bytes_coverage()
16670    -> Result<(), Box<dyn Error>> {
16671        let label = "l".repeat(MAX_MARKDOWN_LABEL_BYTES);
16672        let selector = format!(
16673            "src/{}.rs",
16674            "s".repeat(MAX_DOCUMENT_SELECTOR_BYTES - "src/".len() - ".rs".len())
16675        );
16676        let evidence_bytes = label.len() + selector.len();
16677        let source = format!("[{label}]({selector})\n")
16678            .repeat(MAX_MARKDOWN_EVIDENCE_BYTES / evidence_bytes + 1);
16679        let facts = projectatlas_symbols::extract_markdown_facts(&source);
16680        require(
16681            facts
16682                .coverage
16683                .limits
16684                .contains(&MarkdownFactLimit::EvidenceBytes),
16685            "real Markdown extraction did not reach its evidence-byte limit",
16686        )?;
16687        require(
16688            !facts.link_candidates.is_empty(),
16689            "evidence-limited Markdown extraction lost every valid candidate",
16690        )?;
16691
16692        let coverage = document_coverage("docs/limited.md", &facts, IndexGeneration::new(7))?;
16693        require_eq(
16694            &coverage.state(),
16695            &CoverageState::Partial,
16696            "evidence-limited document coverage state",
16697        )?;
16698        require_eq(
16699            &coverage.reached_limit(),
16700            &Some(GraphLimitKind::IntermediateBytes),
16701            "evidence-limited document graph limit",
16702        )?;
16703        Ok(())
16704    }
16705
16706    #[test]
16707    fn document_cycles_emit_only_canonical_bounded_edges() -> Result<(), Box<dyn Error>> {
16708        let temp = tempfile::tempdir()?;
16709        fs::create_dir_all(temp.path().join("docs"))?;
16710        fs::write(temp.path().join("docs/a.md"), "# A\n\n[b](b.md#b)\n")?;
16711        fs::write(temp.path().join("docs/b.md"), "# B\n\n[a](a.md#a)\n")?;
16712        let facts = [
16713            projectatlas_symbols::extract_markdown_facts("# A\n\n[b](b.md#b)\n"),
16714            projectatlas_symbols::extract_markdown_facts("# B\n\n[a](a.md#a)\n"),
16715        ];
16716        let graphs = vec![
16717            facts[0].symbol_graph("docs/a.md", Some("markdown")),
16718            facts[1].symbol_graph("docs/b.md", Some("markdown")),
16719        ];
16720        let nodes = vec![
16721            test_file_node("docs/a.md", "markdown"),
16722            test_file_node("docs/b.md", "markdown"),
16723        ];
16724        let project = ProjectInstanceId::from_bytes([35; 16])?;
16725        let generation = IndexGeneration::new(1);
16726        let packages = PackageIndex::from_graphs(&graphs)?;
16727        let control = super::super::standalone_index_work_control();
16728        let mut projection = build_entity_projection(
16729            project, generation, &nodes, &graphs, &packages, true, &control,
16730        )?;
16731        let candidates = resolution_registry_from_exports(&projection, &control)?;
16732        let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
16733        let index = DocumentResolutionIndex::new(temp.path(), &nodes, &scan_policy)?;
16734        let mut relations = Vec::new();
16735        for (graph, facts) in graphs.iter().zip(&facts) {
16736            let owners = projection
16737                .owners_by_graph
16738                .remove(&graph.path)
16739                .ok_or_else(|| io::Error::other("document owners were not projected"))?;
16740            relations.extend(
16741                project_document_rows(
16742                    project,
16743                    generation,
16744                    graph,
16745                    facts,
16746                    &owners,
16747                    &index,
16748                    &candidates,
16749                    &projection.entity_by_digest,
16750                    &control,
16751                )?
16752                .relations,
16753            );
16754        }
16755        require_eq(&relations.len(), &2, "document cycle relation count")?;
16756        require(
16757            relations.iter().all(|relation| {
16758                relation.kind() == GraphRelationKind::Extended(ExtendedRelationKind::Documents)
16759                    && matches!(
16760                        relation.resolution(),
16761                        RelationResolution::Resolved {
16762                            selector: ReusableTargetSelector::Symbol { symbol },
16763                            ..
16764                        } if symbol.kind == SymbolKind::Heading
16765                    )
16766            }),
16767            "document cycle emitted a non-canonical or unresolved edge",
16768        )?;
16769        Ok(())
16770    }
16771
16772    #[test]
16773    fn document_casefold_collisions_refuse_exact_winners_and_share_invalidation_keys()
16774    -> Result<(), Box<dyn Error>> {
16775        let temp = tempfile::tempdir()?;
16776        let nodes = vec![
16777            test_file_node("src/lib.rs", "rust"),
16778            test_file_node("SRC/lib.rs", "rust"),
16779        ];
16780        let control = super::super::standalone_index_work_control();
16781        let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
16782        let index = DocumentResolutionIndex::new(temp.path(), &nodes, &scan_policy)?;
16783        require_eq(
16784            &index.unresolved_reason("src/lib.rs")?,
16785            &Some(DocumentTargetUnresolvedReason::CaseConflict),
16786            "exact case-collision target",
16787        )?;
16788        let project = ProjectInstanceId::from_bytes([32; 16])?;
16789        require_eq(
16790            &document_casefold_resolution_key(project, "src/lib.rs")?,
16791            &document_casefold_resolution_key(project, "SRC/lib.rs")?,
16792            "casefold invalidation key",
16793        )?;
16794        Ok(())
16795    }
16796
16797    #[test]
16798    fn document_target_state_change_refuses_stale_publication() -> Result<(), Box<dyn Error>> {
16799        let temp = tempfile::tempdir()?;
16800        let control = super::super::standalone_index_work_control();
16801        let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
16802        let index = DocumentResolutionIndex::new(temp.path(), &[], &scan_policy)?;
16803        let actual = index
16804            .unresolved_reason("docs/target.md")?
16805            .ok_or_else(|| std::io::Error::other("absent target unexpectedly resolved"))?;
16806        let stale = if actual == DocumentTargetUnresolvedReason::Missing {
16807            DocumentTargetUnresolvedReason::Ignored
16808        } else {
16809            DocumentTargetUnresolvedReason::Missing
16810        };
16811        let document_target_states = vec![("docs/target.md".to_string(), stale)];
16812        drop(index);
16813        let staged = StagedRepositoryGraph {
16814            project: ProjectInstanceId::from_bytes([34; 16])?,
16815            mutation: RepositoryGraphMutation::Full,
16816            entities: Vec::new(),
16817            relations: Vec::new(),
16818            occurrences: Vec::new(),
16819            coverage: Vec::new(),
16820            entity_exports: Vec::new(),
16821            relation_dependencies: Vec::new(),
16822            document_unresolved_reasons: Vec::new(),
16823            identity_rejections: Vec::new(),
16824            resolution_derivations: BTreeMap::new(),
16825            peak_retained_bytes: 0,
16826            projection_removals_before_entities: Vec::new(),
16827            scan_policy,
16828            document_target_states,
16829            database: None,
16830            source_recovery: SourceRecoveryWork::default(),
16831            retained_bytes: 0,
16832        };
16833        require(
16834            matches!(
16835                staged.revalidate_document_targets(temp.path()),
16836                Err(CliError::RefreshRequired(_))
16837            ),
16838            "changed non-indexed target state reached publication",
16839        )?;
16840        Ok(())
16841    }
16842
16843    /// Give filesystem-backed recovery fixtures the actual captured byte identity.
16844    fn capture_test_file_hashes(root: &Path, nodes: &mut [Node]) -> Result<(), Box<dyn Error>> {
16845        for node in nodes.iter_mut().filter(|node| node.kind == NodeKind::File) {
16846            let bytes = fs::read(root.join(&node.path))?;
16847            node.content_hash = Some(blake3::hash(&bytes).to_hex().to_string());
16848            node.size_bytes = Some(u64::try_from(bytes.len())?);
16849        }
16850        Ok(())
16851    }
16852
16853    fn test_file_node(path: &str, language: &str) -> Node {
16854        Node {
16855            path: path.to_string(),
16856            kind: NodeKind::File,
16857            parent_path: path
16858                .rsplit_once('/')
16859                .map(|(parent, _name)| parent.to_string()),
16860            extension: Path::new(path)
16861                .extension()
16862                .map(|extension| format!(".{}", extension.to_string_lossy())),
16863            language: Some(language.to_string()),
16864            size_bytes: Some(1),
16865            mtime_ns: Some(1),
16866            content_hash: Some(format!("hash:{path}")),
16867        }
16868    }
16869
16870    #[test]
16871    fn large_document_projection_publishes_in_memory_staging_and_incremental()
16872    -> Result<(), Box<dyn Error>> {
16873        const DOCUMENT_COUNT: usize = 10;
16874        const LINKS_PER_DOCUMENT: usize = 1_024;
16875        const INCREMENTAL_LINKS: usize = 1_024;
16876        let temp = tempfile::tempdir()?;
16877        let root = fs::canonicalize(temp.path())?;
16878        fs::create_dir_all(root.join("content/real-target"))?;
16879        fs::write(root.join("content/resolved.md"), "# Resolved\n")?;
16880        let symlink_available = match create_directory_link(
16881            &root.join("content/real-target"),
16882            &root.join("content/alias"),
16883        ) {
16884            Ok(()) => true,
16885            Err(source) if cfg!(windows) && source.raw_os_error() == Some(1314) => {
16886                fs::create_dir(root.join("content/alias"))?;
16887                false
16888            }
16889            Err(source) => return Err(source.into()),
16890        };
16891        let database = root.join("projectatlas.db");
16892        let mut store = AtlasStore::open_for_project(&database, &root)?;
16893        let project = store
16894            .project_instance_id()?
16895            .ok_or("large document fixture project identity is missing")?;
16896        let control = IndexWorkControl::new(IndexCancellation::new(), None);
16897        let mut nodes = Vec::with_capacity(DOCUMENT_COUNT + 2);
16898        let mut docs_node = test_file_node("content", "unknown");
16899        docs_node.kind = NodeKind::Folder;
16900        docs_node.language = None;
16901        docs_node.extension = None;
16902        nodes.push(docs_node);
16903        let mut graphs = Vec::with_capacity(DOCUMENT_COUNT + 1);
16904        let mut document_facts: BTreeMap<
16905            String,
16906            Cow<'static, projectatlas_symbols::MarkdownFacts>,
16907        > = BTreeMap::new();
16908        for document in 0..DOCUMENT_COUNT {
16909            let path = format!("content/source-{document:02}.md");
16910            let mut links = Vec::with_capacity(LINKS_PER_DOCUMENT);
16911            if document == 0 {
16912                links.push("[resolved](resolved.md)".to_string());
16913                links.push("[symlink](alias)".to_string());
16914            }
16915            while links.len() < LINKS_PER_DOCUMENT {
16916                let link = links.len();
16917                links.push(format!(
16918                    "[missing-{document:02}-{link:04}](missing-{document:02}-{link:04}.md)"
16919                ));
16920            }
16921            let facts = projectatlas_symbols::extract_markdown_facts(&links.join("\n"));
16922            require_eq(
16923                &facts.link_candidates.len(),
16924                &LINKS_PER_DOCUMENT,
16925                "large document candidate count",
16926            )?;
16927            graphs.push(facts.symbol_graph(&path, Some("markdown")));
16928            document_facts.insert(path.clone(), Cow::Owned(facts));
16929            nodes.push(test_file_node(&path, "markdown"));
16930        }
16931        let resolved_facts = projectatlas_symbols::extract_markdown_facts("# Resolved\n");
16932        graphs.push(resolved_facts.symbol_graph("content/resolved.md", Some("markdown")));
16933        nodes.push(test_file_node("content/resolved.md", "markdown"));
16934        let candidate_count = document_facts
16935            .values()
16936            .map(|facts| facts.link_candidates.len())
16937            .sum::<usize>();
16938        require_eq(
16939            &candidate_count,
16940            &(DOCUMENT_COUNT * LINKS_PER_DOCUMENT),
16941            "large document projection candidate total",
16942        )?;
16943        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
16944        let packages = PackageIndex::from_graphs(&graphs)?;
16945
16946        let generation = IndexGeneration::new(1);
16947        let entities = build_entity_projection(
16948            project, generation, &nodes, &graphs, &packages, true, &control,
16949        )?;
16950        let candidates = resolution_registry_from_exports(&entities, &control)?;
16951        let in_memory = finish_projection_with_documents(
16952            project,
16953            generation,
16954            RepositoryGraphMutation::Full,
16955            &graphs,
16956            &root,
16957            &nodes,
16958            &document_facts,
16959            &GraphIdentityAdmission::default(),
16960            entities,
16961            &candidates,
16962            &scan_policy,
16963            &control,
16964        )?;
16965        require(
16966            in_memory.database.is_none(),
16967            "normal projection unexpectedly selected disposable staging",
16968        )?;
16969        require(
16970            in_memory.document_unresolved_reasons.len() > GraphLimits::MAX_ROWS as usize,
16971            "normal projection did not retain a multi-ceiling reason vector",
16972        )?;
16973        require_eq(
16974            &in_memory.relations.len(),
16975            &candidate_count,
16976            "normal projection relation count",
16977        )?;
16978        require(
16979            in_memory.relations.iter().any(|relation| {
16980                matches!(relation.resolution(), RelationResolution::Resolved { .. })
16981            }),
16982            "normal projection lost the resolved document result",
16983        )?;
16984        let symlink_relation = in_memory
16985            .relations
16986            .iter()
16987            .find(|relation| {
16988                matches!(
16989                    relation.resolution(),
16990                    RelationResolution::Unresolved { reference }
16991                        if reference.as_str() == "alias"
16992                )
16993            })
16994            .ok_or("normal projection lost the symlink document result")?;
16995        let symlink_reason = in_memory
16996            .document_unresolved_reasons
16997            .iter()
16998            .find(|(key, _reason)| key == symlink_relation.key())
16999            .map(|(_key, reason)| *reason);
17000        require_eq(
17001            &symlink_reason,
17002            &Some(DocumentTargetUnresolvedReason::Unsupported),
17003            "symlink document result did not retain its closed reason",
17004        )?;
17005        require(
17006            !symlink_available || root.join("content/alias").exists(),
17007            "symlink document fixture disappeared before publication",
17008        )?;
17009        {
17010            let mut publication = store.begin_index_publication("large-document-projection")?;
17011            publication.upsert_scan_node_batch(&nodes)?;
17012            in_memory.apply(&mut publication, &control)?;
17013            publication.complete()?;
17014        }
17015        let published_page = store.repository_graph_relation_rows(
17016            RepositoryGraphRelationQuery::Family {
17017                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
17018            },
17019            GraphLimits::MAX_ROWS,
17020            None,
17021        )?;
17022        require(
17023            published_page.truncated && published_page.rows.len() == GraphLimits::MAX_ROWS as usize,
17024            "normal projection publication did not expose a bounded multi-page result",
17025        )?;
17026
17027        let incremental_path = "content/source-00.md".to_string();
17028        let incremental_source = (0..INCREMENTAL_LINKS)
17029            .map(|link| format!("[incremental-{link:04}](incremental-{link:04}.md)"))
17030            .collect::<Vec<_>>()
17031            .join("\n");
17032        let incremental_facts = projectatlas_symbols::extract_markdown_facts(&incremental_source);
17033        let incremental_graph = incremental_facts.symbol_graph(&incremental_path, Some("markdown"));
17034        let incremental_nodes = vec![test_file_node(&incremental_path, "markdown")];
17035        let incremental_packages =
17036            PackageIndex::from_graphs(std::slice::from_ref(&incremental_graph))?;
17037        let incremental_entities = build_entity_projection(
17038            project,
17039            IndexGeneration::new(2),
17040            &incremental_nodes,
17041            std::slice::from_ref(&incremental_graph),
17042            &incremental_packages,
17043            false,
17044            &control,
17045        )?;
17046        let incremental_candidates =
17047            resolution_registry_from_exports(&incremental_entities, &control)?;
17048        let incremental_staged = finish_projection_with_documents(
17049            project,
17050            IndexGeneration::new(2),
17051            RepositoryGraphMutation::AffectedPaths(vec![incremental_path.clone()]),
17052            std::slice::from_ref(&incremental_graph),
17053            &root,
17054            &nodes,
17055            &BTreeMap::from([(incremental_path.clone(), Cow::Owned(incremental_facts))]),
17056            &GraphIdentityAdmission::default(),
17057            incremental_entities,
17058            &incremental_candidates,
17059            &scan_policy,
17060            &control,
17061        )?;
17062        require(
17063            incremental_staged.document_unresolved_reasons.len() <= GraphLimits::MAX_ROWS as usize,
17064            "incremental projection exceeded its aggregate row budget",
17065        )?;
17066        enforce_incremental_projection_limits(
17067            &root,
17068            &BTreeSet::from([incremental_path]),
17069            RepositoryAffectedSourceFootprint {
17070                rows: 0,
17071                retained_bytes: 0,
17072                truncated: false,
17073            },
17074            &incremental_staged,
17075        )?;
17076        {
17077            let mut publication =
17078                store.begin_index_projection_refresh("large-document-projection")?;
17079            incremental_staged.apply(&mut publication, &control)?;
17080            publication.complete()?;
17081        }
17082        require_eq(
17083            &store.repository_graph_generation()?,
17084            &Some(IndexGeneration::new(2)),
17085            "incremental publication generation",
17086        )?;
17087
17088        let staging_generation = IndexGeneration::new(3);
17089        let staging_entities = build_entity_projection(
17090            project,
17091            staging_generation,
17092            &nodes,
17093            &graphs,
17094            &packages,
17095            true,
17096            &control,
17097        )?;
17098        let staging_candidates = resolution_registry_from_exports(&staging_entities, &control)?;
17099        let staged = finish_projection_in_database_with_documents(
17100            &root,
17101            &nodes,
17102            project,
17103            staging_generation,
17104            &graphs,
17105            &document_facts,
17106            &GraphIdentityAdmission::default(),
17107            staging_entities,
17108            &staging_candidates,
17109            &scan_policy,
17110            &control,
17111        )?;
17112        let database_stage = staged
17113            .database
17114            .as_ref()
17115            .ok_or("large projection did not select disposable staging")?;
17116        let staged_database_bytes = fs::metadata(
17117            database_stage
17118                .directory()?
17119                .path()
17120                .join(GRAPH_STAGE_DATABASE_FILE_NAME),
17121        )?
17122        .len();
17123        require(
17124            staged_database_bytes > 0,
17125            "disposable staging database retained no durable bytes",
17126        )?;
17127        {
17128            let mut publication =
17129                store.begin_index_projection_refresh("large-document-projection")?;
17130            publication.upsert_scan_node_batch(&nodes)?;
17131            staged.apply(&mut publication, &control)?;
17132            publication.complete()?;
17133        }
17134        let staged_page = store.repository_graph_relation_rows(
17135            RepositoryGraphRelationQuery::Family {
17136                relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
17137            },
17138            GraphLimits::MAX_ROWS,
17139            None,
17140        )?;
17141        require(
17142            staged_page.truncated && staged_page.rows.len() == GraphLimits::MAX_ROWS as usize,
17143            "disposable staging publication did not expose a bounded multi-page result",
17144        )?;
17145        Ok(())
17146    }
17147
17148    #[test]
17149    fn incremental_document_admission_uses_emitted_rows() -> Result<(), Box<dyn Error>> {
17150        const DISCARDED_DOCUMENT_COUNT: usize = 513;
17151        const EMITTED_DOCUMENT_COUNT: usize = 10;
17152        const CANDIDATES_PER_DOCUMENT: usize = 1_024;
17153        let temp = tempfile::tempdir()?;
17154        let root = fs::canonicalize(temp.path())?;
17155        fs::create_dir_all(root.join("content"))?;
17156        let database = root.join("projectatlas.db");
17157        let mut store = AtlasStore::open_for_project(&database, &root)?;
17158        let paths = (0..DISCARDED_DOCUMENT_COUNT)
17159            .map(|index| format!("content/links-{index:03}.md"))
17160            .collect::<Vec<_>>();
17161        let mut nodes = paths
17162            .iter()
17163            .map(|path| test_file_node(path, "markdown"))
17164            .collect::<Vec<_>>();
17165        store.replace_scan(&nodes)?;
17166        let control = IndexWorkControl::new(IndexCancellation::new(), None);
17167        let symbols = empty_symbol_build_stage();
17168
17169        for path in &paths {
17170            let file_name = path
17171                .rsplit_once('/')
17172                .map(|(_parent, file_name)| file_name)
17173                .ok_or("same-file document fixture path has no parent")?;
17174            let self_source = (0..CANDIDATES_PER_DOCUMENT)
17175                .map(|index| format!("[self-{index:05}]({file_name})"))
17176                .collect::<Vec<_>>()
17177                .join("\n");
17178            fs::write(root.join(path), &self_source)?;
17179            let self_facts = projectatlas_symbols::extract_markdown_facts(&self_source);
17180            require_eq(
17181                &self_facts.link_candidates.len(),
17182                &CANDIDATES_PER_DOCUMENT,
17183                "same-file raw candidates per document",
17184            )?;
17185            store.replace_symbol_graph(&self_facts.symbol_graph(path, Some("markdown")))?;
17186        }
17187        for (node, path) in nodes.iter_mut().zip(&paths) {
17188            let bytes = fs::read(root.join(path))?;
17189            node.content_hash = Some(blake3::hash(&bytes).to_hex().to_string());
17190        }
17191        store.replace_scan(&nodes)?;
17192        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
17193        let incremental = stage_incremental_repository_graph(
17194            &store,
17195            &root,
17196            IndexGeneration::new(0),
17197            &nodes,
17198            &paths,
17199            &scan_policy,
17200            &symbols,
17201            &control,
17202        )?;
17203        require(
17204            matches!(
17205                &incremental.mutation,
17206                RepositoryGraphMutation::AffectedPaths(affected) if affected == &paths
17207            ),
17208            "same-file candidates were not retained as an incremental projection",
17209        )?;
17210        require(
17211            incremental.relations.is_empty() && incremental.document_unresolved_reasons.is_empty(),
17212            "same-file candidates emitted document rows despite the no-fragment filter",
17213        )?;
17214        require(
17215            store.repository_graph_generation()?.is_none(),
17216            "incremental admission test unexpectedly published a generation",
17217        )?;
17218
17219        let emitted_paths = paths
17220            .iter()
17221            .take(EMITTED_DOCUMENT_COUNT)
17222            .cloned()
17223            .collect::<Vec<_>>();
17224        for path in &emitted_paths {
17225            let unsupported_source = (0..CANDIDATES_PER_DOCUMENT)
17226                .map(|index| format!("[external-{index:05}](target-{index:05}.md#invalid?)"))
17227                .collect::<Vec<_>>()
17228                .join("\n");
17229            fs::write(root.join(path), &unsupported_source)?;
17230            let unsupported_facts =
17231                projectatlas_symbols::extract_markdown_facts(&unsupported_source);
17232            require_eq(
17233                &unsupported_facts.link_candidates.len(),
17234                &CANDIDATES_PER_DOCUMENT,
17235                "unsupported raw candidates per document",
17236            )?;
17237            store.replace_symbol_graph(&unsupported_facts.symbol_graph(path, Some("markdown")))?;
17238        }
17239        for (node, path) in nodes.iter_mut().zip(&paths) {
17240            let bytes = fs::read(root.join(path))?;
17241            node.content_hash = Some(blake3::hash(&bytes).to_hex().to_string());
17242        }
17243        store.replace_scan(&nodes)?;
17244        let error = stage_incremental_repository_graph(
17245            &store,
17246            &root,
17247            IndexGeneration::new(0),
17248            &nodes,
17249            &emitted_paths,
17250            &scan_policy,
17251            &symbols,
17252            &control,
17253        )
17254        .err()
17255        .ok_or("emitted document rows over the ceiling were admitted incrementally")?;
17256        let CliError::RefreshRequired(report) = error else {
17257            return Err(io::Error::other(format!(
17258                "expected typed full-refresh guidance, found {error:?}"
17259            ))
17260            .into());
17261        };
17262        require_eq(
17263            &report.reason,
17264            &IndexRefreshReason::DependencyClosureLimit,
17265            "emitted-row overflow reason",
17266        )?;
17267        require_eq(
17268            &report.scope,
17269            &IndexRefreshScope::Full,
17270            "emitted-row overflow scope",
17271        )?;
17272        require(
17273            store.repository_graph_generation()?.is_none(),
17274            "emitted-row overflow changed the current generation",
17275        )?;
17276        Ok(())
17277    }
17278
17279    #[test]
17280    fn reopened_source_parse_success_does_not_promote_fallback_graph_facts()
17281    -> Result<(), Box<dyn Error>> {
17282        let temp = tempfile::tempdir()?;
17283        let database = temp.path().join("projectatlas.db");
17284        let mut store = AtlasStore::open(&database)?;
17285        let graph = SymbolGraph {
17286            path: "src/optional.lang".to_string(),
17287            language: Some("optional-language".to_string()),
17288            parser: ParserKind::Fallback,
17289            symbols: vec![CodeSymbol {
17290                path: "src/optional.lang".to_string(),
17291                language: Some("optional-language".to_string()),
17292                name: "entry".to_string(),
17293                kind: SymbolKind::Function,
17294                signature: "entry()".to_string(),
17295                exported: false,
17296                documentation: None,
17297                line_start: 1,
17298                line_end: 1,
17299                source_selector: None,
17300                parent: None,
17301                parser: ParserKind::Fallback,
17302                detail: None,
17303            }],
17304            relations: vec![SymbolRelation {
17305                path: "src/optional.lang".to_string(),
17306                source_name: "entry".to_string(),
17307                target_name: "helper".to_string(),
17308                kind: RelationKind::Calls,
17309                line: 1,
17310                context: "helper()".to_string(),
17311                parser: ParserKind::Fallback,
17312            }],
17313        };
17314        store.replace_symbol_graph_with_metadata(
17315            &graph,
17316            &SourceParseMetadata {
17317                path: graph.path.clone(),
17318                language: graph.language.clone(),
17319                parser: ParserKind::TreeSitter,
17320                symbol_count: graph.symbols.len(),
17321                relation_count: graph.relations.len(),
17322            },
17323        )?;
17324        drop(store);
17325
17326        let reader = AtlasStore::open_read_only(&database)?;
17327        let graphs = reader.load_symbol_graphs_for_paths(std::slice::from_ref(&graph.path))?;
17328        require_eq(&graphs, &vec![graph], "reopened fact graph")?;
17329        let project = ProjectInstanceId::from_bytes([7; 16])?;
17330        let generation = IndexGeneration::new(1);
17331        let control = IndexWorkControl::new(IndexCancellation::new(), None);
17332        let packages = PackageIndex::from_graphs(&graphs)?;
17333        let entities =
17334            build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
17335        let candidates = resolution_registry_from_exports(&entities, &control)?;
17336        let staged = finish_projection(
17337            project,
17338            generation,
17339            RepositoryGraphMutation::Full,
17340            &graphs,
17341            entities,
17342            &candidates,
17343            &control,
17344        )?;
17345        require_eq(&staged.relations.len(), &1, "normalized relation count")?;
17346        require_eq(
17347            &staged.relations[0].confidence(),
17348            &ConfidenceClass::Low,
17349            "fallback relation confidence after reopen",
17350        )?;
17351        require_eq(
17352            &staged.relations[0].completeness(),
17353            &Completeness::Partial,
17354            "fallback relation completeness after reopen",
17355        )?;
17356        require(
17357            staged
17358                .relations
17359                .iter()
17360                .any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)),
17361            "reopened graph lost its legacy call relation",
17362        )?;
17363        require_eq(&staged.coverage.len(), &1, "normalized coverage count")?;
17364        require_eq(
17365            &staged.coverage[0].state(),
17366            &CoverageState::Partial,
17367            "fallback coverage after reopen",
17368        )?;
17369        reader.finish_index_read_snapshot()?;
17370        Ok(())
17371    }
17372
17373    #[test]
17374    fn partial_php_graph_publishes_existing_partial_coverage() -> Result<(), Box<dyn Error>> {
17375        let graph = extract_symbol_graph(
17376            "src/dynamic.php",
17377            Some("php"),
17378            "<?php function run(): void { $callable(); helper(); }",
17379        );
17380        require_eq(
17381            &graph.parser,
17382            &ParserKind::Fallback,
17383            "partial PHP fact parser",
17384        )?;
17385        let coverage = coverage_for_graph(
17386            &graph,
17387            IndexGeneration::new(1),
17388            &GraphIdentityAdmission::default(),
17389            &GraphIdentityAdmission::default(),
17390        )?;
17391        require_eq(
17392            &coverage.state(),
17393            &CoverageState::Partial,
17394            "partial PHP coverage state",
17395        )?;
17396        require_eq(&coverage.covered(), &1, "partial PHP covered relations")?;
17397        require_eq(&coverage.omitted(), &1, "partial PHP omitted relations")?;
17398        require(
17399            coverage.reason().is_some(),
17400            "partial PHP coverage must disclose its reason",
17401        )?;
17402        Ok(())
17403    }
17404
17405    #[test]
17406    fn mixed_php_graph_publishes_complete_coverage() -> Result<(), Box<dyn Error>> {
17407        for (path, source, symbol_name) in [
17408            (
17409                "src/inline-output.php",
17410                "//x<?php function marker(): void { helper(); }",
17411                "marker",
17412            ),
17413            (
17414                "src/inline-echo.php",
17415                "#output<?= $value ?><?php function after_echo(): void { helper(); }",
17416                "after_echo",
17417            ),
17418        ] {
17419            let graph = extract_symbol_graph(path, Some("php"), source);
17420            require_eq(
17421                &graph.parser,
17422                &ParserKind::TreeSitter,
17423                "mixed PHP fact parser",
17424            )?;
17425            require(
17426                graph
17427                    .symbols
17428                    .iter()
17429                    .any(|symbol| symbol.name == symbol_name),
17430                "mixed PHP declaration is missing before coverage projection",
17431            )?;
17432            let coverage = coverage_for_graph(
17433                &graph,
17434                IndexGeneration::new(1),
17435                &GraphIdentityAdmission::default(),
17436                &GraphIdentityAdmission::default(),
17437            )?;
17438            require_eq(
17439                &coverage.state(),
17440                &CoverageState::Complete,
17441                "mixed PHP coverage state",
17442            )?;
17443            require(
17444                coverage.covered() > 0,
17445                "mixed PHP complete coverage must retain static relations",
17446            )?;
17447            require_eq(&coverage.omitted(), &0, "mixed PHP omitted relations")?;
17448            require(
17449                coverage.reason().is_none(),
17450                "complete mixed PHP coverage must not disclose a partial reason",
17451            )?;
17452        }
17453        Ok(())
17454    }
17455
17456    #[test]
17457    fn persisted_closure_overflow_preserves_the_complete_generation() -> Result<(), Box<dyn Error>>
17458    {
17459        let temp = tempfile::tempdir()?;
17460        let root = temp.path().join("persisted-closure-overflow");
17461        fs::create_dir_all(&root)?;
17462        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
17463        let project = store
17464            .project_instance_id()?
17465            .ok_or("bound project identity is missing")?;
17466        let control = IndexWorkControl::new(IndexCancellation::new(), None);
17467        let graphs = vec![function_graph("src/lib.rs", 1)];
17468        let packages = PackageIndex::from_graphs(&graphs)?;
17469        let entity_projection = build_entity_projection(
17470            project,
17471            IndexGeneration::new(1),
17472            &[],
17473            &graphs,
17474            &packages,
17475            true,
17476            &control,
17477        )?;
17478        let dependency_keys = entity_projection
17479            .keys_by_graph
17480            .values()
17481            .flat_map(projectatlas_symbols::ResolutionKeyProjection::relation_keys)
17482            .flat_map(|relation| relation.keys().iter().cloned())
17483            .collect::<BTreeSet<_>>()
17484            .into_iter()
17485            .collect::<Vec<_>>();
17486        let candidates = resolution_registry_from_exports(&entity_projection, &control)?;
17487        let staged = finish_projection(
17488            project,
17489            IndexGeneration::new(1),
17490            RepositoryGraphMutation::Full,
17491            &graphs,
17492            entity_projection,
17493            &candidates,
17494            &control,
17495        )?;
17496        let file_key = staged
17497            .entities
17498            .iter()
17499            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
17500            .ok_or("staged file entity is missing")?
17501            .key()
17502            .clone();
17503        {
17504            let mut publication = store.begin_index_publication("closure-overflow")?;
17505            publication.begin_scan_replacement()?;
17506            publication.upsert_scan_node_batch(&[Node {
17507                path: "src/lib.rs".to_string(),
17508                kind: NodeKind::File,
17509                parent_path: Some("src".to_string()),
17510                extension: Some(".rs".to_string()),
17511                language: Some("rust".to_string()),
17512                size_bytes: Some(1),
17513                mtime_ns: Some(1),
17514                content_hash: Some("initial".to_string()),
17515            }])?;
17516            publication.finish_scan_replacement()?;
17517            staged.apply(&mut publication, &control)?;
17518            publication.complete()?;
17519        }
17520
17521        store.replace_symbol_graph(&function_graph(
17522            "src/lib.rs",
17523            usize::try_from(MAX_INCREMENTAL_GRAPH_ROWS).unwrap_or(usize::MAX),
17524        ))?;
17525        let publication_before = store
17526            .index_publication()?
17527            .ok_or("complete publication is missing")?;
17528        let entity_before = store
17529            .repository_graph_entity(&file_key)?
17530            .ok_or("published file entity is missing")?;
17531        let exports_before =
17532            store.repository_export_keys_for_paths(project, &["src/lib.rs".to_string()], 100)?;
17533        let dependencies_before =
17534            store.repository_affected_source_paths(project, &dependency_keys, 100)?;
17535        let empty_symbols = empty_symbol_build_stage();
17536        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
17537
17538        let error = stage_incremental_repository_graph(
17539            &store,
17540            &root,
17541            publication_before.generation,
17542            &[],
17543            &["src/lib.rs".to_string()],
17544            &scan_policy,
17545            &empty_symbols,
17546            &control,
17547        )
17548        .err()
17549        .ok_or_else(|| io::Error::other("oversized closure acquired the publication writer"))?;
17550        let CliError::RefreshRequired(report) = error else {
17551            return Err(io::Error::other(format!(
17552                "expected typed full-refresh guidance, found {error:?}"
17553            ))
17554            .into());
17555        };
17556        require_eq(
17557            &report.reason,
17558            &IndexRefreshReason::DependencyClosureLimit,
17559            "persisted overflow reason",
17560        )?;
17561        require_eq(
17562            &report.scope,
17563            &IndexRefreshScope::Full,
17564            "persisted overflow scope",
17565        )?;
17566        require_eq(
17567            &store.index_publication()?,
17568            &Some(publication_before),
17569            "publication after overflow",
17570        )?;
17571        require_eq(
17572            &store.repository_graph_entity(&file_key)?,
17573            &Some(entity_before),
17574            "file entity after overflow",
17575        )?;
17576        require_eq(
17577            &store.repository_export_keys_for_paths(project, &["src/lib.rs".to_string()], 100)?,
17578            &exports_before,
17579            "export keys after overflow",
17580        )?;
17581        require_eq(
17582            &store.repository_affected_source_paths(project, &dependency_keys, 100)?,
17583            &dependencies_before,
17584            "dependency owners after overflow",
17585        )?;
17586        Ok(())
17587    }
17588
17589    #[test]
17590    fn semantic_provider_key_rejection_keeps_valid_exports_and_relations()
17591    -> Result<(), Box<dyn Error>> {
17592        let temp = tempfile::tempdir()?;
17593        let root = temp.path().join("semantic-resolution-key-admission");
17594        fs::create_dir_all(&root)?;
17595        let long_path = |folder: &str, component: char, file: &str| {
17596            let components = (0..20)
17597                .map(|_| std::iter::repeat_n(component, 200).collect::<String>())
17598                .collect::<Vec<_>>()
17599                .join("/");
17600            format!("src/{folder}/{components}/{file}")
17601        };
17602        let path = long_path("first", 'd', "page.rs");
17603        let sibling_path = long_path("second", 'e', "sibling.rs");
17604        let nodes = vec![
17605            test_file_node(&path, "rust"),
17606            test_file_node(&sibling_path, "rust"),
17607        ];
17608        let project_database = root.join(".projectatlas/projectatlas.db");
17609        fs::create_dir_all(root.join(".projectatlas"))?;
17610        let mut store = AtlasStore::open_for_project(&project_database, &root)?;
17611        let project = store
17612            .project_instance_id()?
17613            .ok_or("semantic resolution project identity is missing")?;
17614        let initial_graphs = vec![
17615            semantic_resolution_key_graph(&path, true),
17616            semantic_resolution_key_graph(&sibling_path, false),
17617        ];
17618        let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
17619        let scan_policy =
17620            RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
17621        let initial_stage = stage_full_repository_graph(
17622            &store,
17623            &root,
17624            IndexGeneration::ZERO,
17625            &nodes,
17626            &scan_policy,
17627            &symbol_build_stage_for_graphs(initial_graphs.clone()),
17628            &initial_control,
17629        )?;
17630        require(
17631            initial_stage
17632                .identity_rejections
17633                .iter()
17634                .filter(|row| row.field == GraphIdentityField::ResolutionKey)
17635                .count()
17636                >= 2,
17637            "semantic provider full-stage did not retain every invalid resolution-key fact",
17638        )?;
17639        let initial_coverage = initial_stage
17640            .coverage
17641            .iter()
17642            .find(|coverage| {
17643                matches!(
17644                    coverage.scope(),
17645                    CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
17646                )
17647            })
17648            .ok_or("semantic provider coverage row is missing")?;
17649        require_eq(
17650            &initial_coverage.state(),
17651            &CoverageState::Partial,
17652            "semantic provider valid-symbol coverage state",
17653        )?;
17654        require(
17655            initial_coverage.covered() > 0,
17656            "semantic provider valid symbols were not counted as covered",
17657        )?;
17658        let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
17659        canceled.cancel();
17660        {
17661            let mut publication = store.begin_index_publication("semantic-resolution-cancel")?;
17662            publication.begin_scan_replacement()?;
17663            publication.upsert_scan_node_batch(&nodes)?;
17664            publication.finish_scan_replacement()?;
17665            let error = initial_stage.apply(&mut publication, &canceled).err();
17666            require(
17667                matches!(
17668                    error,
17669                    Some(CliError::IndexWork(IndexWorkFailure::Cancelled {
17670                        stage: IndexWorkStage::Publication
17671                    }))
17672                ),
17673                "semantic provider cancellation was not observed after admission",
17674            )?;
17675        }
17676        require_eq(
17677            &store.index_publication()?,
17678            &None,
17679            "publication after semantic provider cancellation",
17680        )?;
17681        publish_full_staged_graph(
17682            &mut store,
17683            &nodes,
17684            &initial_stage,
17685            &initial_control,
17686            "semantic-resolution-initial",
17687        )?;
17688        let base_generation = store
17689            .index_publication()?
17690            .ok_or("semantic resolution initial publication is missing")?
17691            .generation;
17692        let rejection_paths = nodes
17693            .iter()
17694            .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
17695            .collect::<Result<Vec<_>, _>>()?;
17696        let rejections =
17697            store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
17698        let rejections = rejections
17699            .into_iter()
17700            .filter(|row| row.field == GraphIdentityField::ResolutionKey)
17701            .collect::<Vec<_>>();
17702        require(
17703            rejections.len() >= 2,
17704            "semantic provider full-stage rejection detail count",
17705        )?;
17706        require(
17707            rejections.iter().all(|row| {
17708                row.path.as_str() == path
17709                    && row.reason == GraphIdentityRejectionReason::Oversized
17710                    && row.parser == ParserKind::TreeSitter
17711            }),
17712            "semantic provider rejection provenance was not exact",
17713        )?;
17714        require(
17715            rejections.iter().any(|row| row.span.start_line() == 4)
17716                && rejections.iter().any(|row| row.span.start_line() == 5),
17717            "semantic provider did not retain distinct invalid symbol spans",
17718        )?;
17719        let rejection_wire = serde_json::to_string(&rejections)?;
17720        require(
17721            !rejection_wire.contains("LeakedIdentity"),
17722            "semantic provider rejection retained raw identity text",
17723        )?;
17724
17725        let file_entities = store.repository_graph_entities_by_path(
17726            project,
17727            &RepositoryNodePath::new(Path::new(&path))?,
17728            64,
17729        )?;
17730        require(
17731            file_entities.rows.iter().any(|entity| {
17732                matches!(
17733                    entity.selector(),
17734                    EntitySelector::Symbol { symbol } if symbol.name.as_str() == "page_helper"
17735                )
17736            }),
17737            "valid semantic sibling export was dropped",
17738        )?;
17739        let sibling_entities = store.repository_graph_entities_by_path(
17740            project,
17741            &RepositoryNodePath::new(Path::new(&sibling_path))?,
17742            64,
17743        )?;
17744        require(
17745            sibling_entities.rows.iter().any(|entity| {
17746                matches!(
17747                    entity.selector(),
17748                    EntitySelector::Symbol { symbol } if symbol.name.as_str() == "sibling_helper"
17749                )
17750            }),
17751            "valid semantic sibling-folder export was dropped",
17752        )?;
17753        let calls = store.repository_graph_relations(
17754            RepositoryGraphRelationQuery::Family {
17755                relation: GraphRelationKind::Legacy(RelationKind::Calls),
17756            },
17757            32,
17758        )?;
17759        require(
17760            calls
17761                .rows
17762                .iter()
17763                .any(|relation| relation.resolution().resolved_target().is_some()),
17764            "valid semantic sibling relation was not resolved",
17765        )?;
17766        let imports = store.repository_graph_relations(
17767            RepositoryGraphRelationQuery::Family {
17768                relation: GraphRelationKind::Legacy(RelationKind::Imports),
17769            },
17770            32,
17771        )?;
17772        require(
17773            !imports.rows.is_empty(),
17774            "valid semantic import relation was dropped beside invalid imports",
17775        )?;
17776        let exports =
17777            store.repository_export_keys_for_paths(project, std::slice::from_ref(&path), 128)?;
17778        require(
17779            exports.rows.len() > 2,
17780            "valid semantic provider resolution exports were dropped",
17781        )?;
17782        let sibling_exports = store.repository_export_keys_for_paths(
17783            project,
17784            std::slice::from_ref(&sibling_path),
17785            128,
17786        )?;
17787        require(
17788            sibling_exports.rows.len() > 2,
17789            "valid semantic sibling-folder resolution exports were dropped",
17790        )?;
17791        let sibling_exports_before_incremental = sibling_exports.rows;
17792
17793        drop(store);
17794        let mut store = AtlasStore::open_for_project(&project_database, &root)?;
17795        require_eq(
17796            &store.project_instance_id()?,
17797            &Some(project),
17798            "semantic provider project identity after SQLite reopen",
17799        )?;
17800        let reopened_sibling_exports = store.repository_export_keys_for_paths(
17801            project,
17802            std::slice::from_ref(&sibling_path),
17803            128,
17804        )?;
17805        require_eq(
17806            &reopened_sibling_exports.rows,
17807            &sibling_exports_before_incremental,
17808            "semantic provider valid exports after SQLite reopen",
17809        )?;
17810
17811        let invalid_sibling_control = IndexWorkControl::new(IndexCancellation::new(), None);
17812        let invalid_sibling_policy =
17813            RootScanPolicy::discover(&root, &ScanOptions::default(), &invalid_sibling_control)?;
17814        let invalid_sibling_stage = stage_incremental_repository_graph(
17815            &store,
17816            &root,
17817            base_generation,
17818            &nodes,
17819            std::slice::from_ref(&sibling_path),
17820            &invalid_sibling_policy,
17821            &symbol_build_stage_for_graphs(vec![semantic_resolution_key_graph(
17822                &sibling_path,
17823                true,
17824            )]),
17825            &invalid_sibling_control,
17826        )?;
17827        let incremental_generation = base_generation
17828            .checked_next()
17829            .ok_or("semantic resolution incremental generation overflowed")?;
17830        let direct_derivations = invalid_sibling_stage
17831            .resolution_derivations
17832            .get(&(sibling_path.clone(), incremental_generation))
17833            .copied();
17834        require_eq(
17835            &direct_derivations,
17836            &Some(1),
17837            "semantic provider direct resolution derivation count",
17838        )?;
17839        require(
17840            invalid_sibling_stage
17841                .identity_rejections
17842                .iter()
17843                .filter(|row| row.field == GraphIdentityField::ResolutionKey)
17844                .count()
17845                >= 2,
17846            "semantic provider incremental resolution-key rejection",
17847        )?;
17848        {
17849            let mut publication =
17850                store.begin_index_publication("semantic-resolution-incremental-invalid")?;
17851            invalid_sibling_stage.apply(&mut publication, &invalid_sibling_control)?;
17852            publication.complete()?;
17853        }
17854        let incremented_generation = store
17855            .index_publication()?
17856            .ok_or("semantic resolution incremental publication is missing")?
17857            .generation;
17858        let with_two_rejections =
17859            store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
17860        require(
17861            with_two_rejections.len() >= 4,
17862            "semantic provider incremental rejection detail count",
17863        )?;
17864        require(
17865            with_two_rejections
17866                .iter()
17867                .any(|row| row.path.as_str() == sibling_path),
17868            "semantic provider incremental rejection was not added for the changed path",
17869        )?;
17870        let invalid_sibling_exports = store.repository_export_keys_for_paths(
17871            project,
17872            std::slice::from_ref(&sibling_path),
17873            128,
17874        )?;
17875        require(
17876            sibling_exports_before_incremental
17877                .iter()
17878                .all(|key| invalid_sibling_exports.rows.contains(key)),
17879            "semantic provider valid exports were lost beside incremental rejection",
17880        )?;
17881        let invalid_sibling_calls = store.repository_graph_relations(
17882            RepositoryGraphRelationQuery::Family {
17883                relation: GraphRelationKind::Legacy(RelationKind::Calls),
17884            },
17885            32,
17886        )?;
17887        require(
17888            invalid_sibling_calls
17889                .rows
17890                .iter()
17891                .any(|relation| relation.resolution().resolved_target().is_some()),
17892            "semantic provider valid resolved relation was lost beside incremental rejection",
17893        )?;
17894
17895        let repaired_control = IndexWorkControl::new(IndexCancellation::new(), None);
17896        let repaired_policy =
17897            RootScanPolicy::discover(&root, &ScanOptions::default(), &repaired_control)?;
17898        let repaired_stage = stage_incremental_repository_graph(
17899            &store,
17900            &root,
17901            incremented_generation,
17902            &nodes,
17903            std::slice::from_ref(&sibling_path),
17904            &repaired_policy,
17905            &symbol_build_stage_for_graphs(vec![semantic_resolution_key_graph(
17906                &sibling_path,
17907                false,
17908            )]),
17909            &repaired_control,
17910        )?;
17911        {
17912            let mut publication =
17913                store.begin_index_publication("semantic-resolution-incremental-repair")?;
17914            repaired_stage.apply(&mut publication, &repaired_control)?;
17915            publication.complete()?;
17916        }
17917        let repaired_generation = store
17918            .index_publication()?
17919            .ok_or("semantic resolution repair publication is missing")?
17920            .generation;
17921        let repaired_rejections =
17922            store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
17923        require(
17924            repaired_rejections.len() >= 2,
17925            "semantic provider repaired rejection detail count",
17926        )?;
17927        require(
17928            repaired_rejections
17929                .iter()
17930                .all(|row| row.path.as_str() == path),
17931            "semantic provider repair removed or replaced an unrelated path detail",
17932        )?;
17933        let repaired_sibling_exports = store.repository_export_keys_for_paths(
17934            project,
17935            std::slice::from_ref(&sibling_path),
17936            128,
17937        )?;
17938        require_eq(
17939            &repaired_sibling_exports.rows,
17940            &sibling_exports_before_incremental,
17941            "semantic provider repair did not restore valid exports",
17942        )?;
17943
17944        let fault_control = IndexWorkControl::new(IndexCancellation::new(), None);
17945        let fault_policy =
17946            RootScanPolicy::discover(&root, &ScanOptions::default(), &fault_control)?;
17947        let mut fault_stage = stage_full_repository_graph(
17948            &store,
17949            &root,
17950            repaired_generation,
17951            &nodes,
17952            &fault_policy,
17953            &symbol_build_stage_for_graphs(initial_graphs.clone()),
17954            &fault_control,
17955        )?;
17956        fault_stage.identity_rejections.resize(
17957            usize::try_from(GraphLimits::MAX_ROWS)
17958                .unwrap_or(usize::MAX)
17959                .saturating_add(1),
17960            fault_stage.identity_rejections[0].clone(),
17961        );
17962        {
17963            let mut publication = store.begin_index_publication("semantic-resolution-fault")?;
17964            publication.begin_scan_replacement()?;
17965            publication.upsert_scan_node_batch(&nodes)?;
17966            publication.finish_scan_replacement()?;
17967            require(
17968                fault_stage.apply(&mut publication, &fault_control).is_err(),
17969                "semantic provider late rejection-detail fault did not fail",
17970            )?;
17971        }
17972        require_eq(
17973            &store
17974                .index_publication()?
17975                .map(|publication| publication.generation),
17976            &Some(repaired_generation),
17977            "semantic provider generation after late rejection-detail fault",
17978        )?;
17979        require_eq(
17980            &store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?,
17981            &repaired_rejections,
17982            "semantic provider prior generation after fault",
17983        )?;
17984
17985        let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
17986        let retry_policy =
17987            RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
17988        let retry_stage = stage_full_repository_graph(
17989            &store,
17990            &root,
17991            repaired_generation,
17992            &nodes,
17993            &retry_policy,
17994            &symbol_build_stage_for_graphs(initial_graphs),
17995            &retry_control,
17996        )?;
17997        publish_full_staged_graph(
17998            &mut store,
17999            &nodes,
18000            &retry_stage,
18001            &retry_control,
18002            "semantic-resolution-retry",
18003        )?;
18004        require_eq(
18005            &store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?,
18006            &rejections,
18007            "semantic provider deterministic retry",
18008        )?;
18009        Ok(())
18010    }
18011
18012    #[test]
18013    fn full_stage_admits_siblings_and_reopens_typed_rejections_with_cancel_retry()
18014    -> Result<(), Box<dyn Error>> {
18015        let temp = tempfile::tempdir()?;
18016        let root = temp.path().join("full-identity-admission");
18017        fs::create_dir_all(root.join("src"))?;
18018        fs::create_dir_all(root.join("tests"))?;
18019        fs::write(
18020            root.join("src/one.rs"),
18021            "pub fn caller() { helper(); }\nfn helper() {}\n",
18022        )?;
18023        fs::write(
18024            root.join("tests/two.rs"),
18025            "pub fn caller() { helper(); }\nfn helper() {}\n",
18026        )?;
18027        let database = root.join(".projectatlas/projectatlas.db");
18028        fs::create_dir_all(root.join(".projectatlas"))?;
18029        let mut store = AtlasStore::open_for_project(&database, &root)?;
18030        let project = store
18031            .project_instance_id()?
18032            .ok_or("full admission project identity is missing")?;
18033        let nodes = vec![
18034            test_file_node("src/one.rs", "rust"),
18035            test_file_node("tests/two.rs", "rust"),
18036        ];
18037        let mut first_graph =
18038            identity_sibling_graph("src/one.rs", GraphIdentityField::RelationTarget);
18039        first_graph.relations.push(SymbolRelation {
18040            path: "src/one.rs".to_string(),
18041            source_name: "bad\u{0}source".to_string(),
18042            target_name: "bad\u{0}target".to_string(),
18043            kind: RelationKind::Calls,
18044            line: 4,
18045            context: "identity admission dual-field fixture".to_string(),
18046            parser: ParserKind::TreeSitter,
18047        });
18048        let graphs = vec![
18049            first_graph,
18050            identity_sibling_graph("tests/two.rs", GraphIdentityField::RelationSource),
18051        ];
18052        let symbols = symbol_build_stage_for_graphs(graphs);
18053        let control = IndexWorkControl::new(IndexCancellation::new(), None);
18054        let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
18055        let staged = stage_full_repository_graph(
18056            &store,
18057            &root,
18058            IndexGeneration::ZERO,
18059            &nodes,
18060            &scan_policy,
18061            &symbols,
18062            &control,
18063        )?;
18064        require_eq(
18065            &staged.identity_rejections.len(),
18066            &4,
18067            "full-stage typed rejection count",
18068        )?;
18069        let staged_dual_rejections = staged
18070            .identity_rejections
18071            .iter()
18072            .filter(|row| {
18073                row.path.as_str() == "src/one.rs"
18074                    && row.span.start_line() == 4
18075                    && row.reason == GraphIdentityRejectionReason::ControlCharacters
18076            })
18077            .collect::<Vec<_>>();
18078        require_eq(
18079            &staged_dual_rejections.len(),
18080            &2,
18081            "full-stage dual-field rejection details",
18082        )?;
18083        require(
18084            staged_dual_rejections
18085                .iter()
18086                .any(|row| row.field == GraphIdentityField::RelationSource)
18087                && staged_dual_rejections
18088                    .iter()
18089                    .any(|row| row.field == GraphIdentityField::RelationTarget),
18090            "full-stage dual-field rejection lost source or target provenance",
18091        )?;
18092        require(
18093            staged_dual_rejections
18094                .iter()
18095                .map(|row| row.fact_index)
18096                .all(|fact_index| fact_index == staged_dual_rejections[0].fact_index),
18097            "full-stage dual-field rejection lost parser fact identity",
18098        )?;
18099        let staged_first_coverage = staged
18100            .coverage
18101            .iter()
18102            .find(|coverage| {
18103                matches!(
18104                    coverage.scope(),
18105                    CoverageScope::Path { path } if path.as_str() == "src/one.rs"
18106                )
18107            })
18108            .ok_or("full-stage first coverage row is missing")?;
18109        require_eq(
18110            &staged_first_coverage.omitted(),
18111            &2,
18112            "full-stage dual-field rejection counted once per parser fact",
18113        )?;
18114        let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
18115        canceled.cancel();
18116        {
18117            let mut publication = store.begin_index_publication("full-identity-cancel")?;
18118            publication.begin_scan_replacement()?;
18119            publication.upsert_scan_node_batch(&nodes)?;
18120            publication.finish_scan_replacement()?;
18121            let error = staged.apply(&mut publication, &canceled).err();
18122            require(
18123                matches!(
18124                    error,
18125                    Some(CliError::IndexWork(IndexWorkFailure::Cancelled {
18126                        stage: IndexWorkStage::Publication
18127                    }))
18128                ),
18129                "full-stage cancellation was not observed after admission",
18130            )?;
18131        }
18132        require_eq(
18133            &store.index_publication()?,
18134            &None,
18135            "publication after canceled full-stage admission",
18136        )?;
18137
18138        publish_full_staged_graph(&mut store, &nodes, &staged, &control, "full-identity")?;
18139        let first_publication = store
18140            .index_publication()?
18141            .ok_or("full identity publication is missing")?;
18142        require_eq(
18143            &first_publication.generation,
18144            &IndexGeneration::new(1),
18145            "first full identity generation",
18146        )?;
18147        drop(store);
18148
18149        let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
18150        let rejection_paths = nodes
18151            .iter()
18152            .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
18153            .collect::<Result<Vec<_>, _>>()?;
18154        let reopened_rejections =
18155            reopened.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
18156        require_eq(
18157            &reopened_rejections.len(),
18158            &4,
18159            "reopened full-stage typed rejection count",
18160        )?;
18161        let reopened_dual_rejections = reopened_rejections
18162            .iter()
18163            .filter(|row| {
18164                row.path.as_str() == "src/one.rs"
18165                    && row.span.start_line() == 4
18166                    && row.reason == GraphIdentityRejectionReason::ControlCharacters
18167            })
18168            .collect::<Vec<_>>();
18169        require_eq(
18170            &reopened_dual_rejections.len(),
18171            &2,
18172            "reopened dual-field rejection details",
18173        )?;
18174        require(
18175            reopened_dual_rejections
18176                .iter()
18177                .any(|row| row.field == GraphIdentityField::RelationSource)
18178                && reopened_dual_rejections
18179                    .iter()
18180                    .any(|row| row.field == GraphIdentityField::RelationTarget),
18181            "reopened dual-field rejection lost source or target provenance",
18182        )?;
18183        require(
18184            reopened_dual_rejections
18185                .iter()
18186                .map(|row| row.fact_index)
18187                .all(|fact_index| fact_index == reopened_dual_rejections[0].fact_index),
18188            "reopened dual-field rejection lost parser fact identity",
18189        )?;
18190        let target_rejection = reopened_rejections
18191            .iter()
18192            .find(|row| {
18193                row.field == GraphIdentityField::RelationTarget
18194                    && row.path.as_str() == "src/one.rs"
18195                    && row.span.start_line() == 3
18196            })
18197            .ok_or("relation-target rejection is missing")?;
18198        require_eq(
18199            &target_rejection.path.as_str(),
18200            &"src/one.rs",
18201            "relation-target rejection path",
18202        )?;
18203        require_eq(
18204            &target_rejection.parser,
18205            &ParserKind::TreeSitter,
18206            "relation-target rejection parser",
18207        )?;
18208        require_eq(
18209            &target_rejection.reason,
18210            &GraphIdentityRejectionReason::ControlCharacters,
18211            "relation-target rejection reason",
18212        )?;
18213        require_eq(
18214            &target_rejection.span.start_line(),
18215            &3,
18216            "relation-target rejection start line",
18217        )?;
18218        require_eq(
18219            &target_rejection.span.end_line(),
18220            &3,
18221            "relation-target rejection end line",
18222        )?;
18223        let source_rejection = reopened_rejections
18224            .iter()
18225            .find(|row| {
18226                row.field == GraphIdentityField::RelationSource
18227                    && row.path.as_str() == "tests/two.rs"
18228            })
18229            .ok_or("relation-source rejection is missing")?;
18230        require_eq(
18231            &source_rejection.path.as_str(),
18232            &"tests/two.rs",
18233            "relation-source rejection path",
18234        )?;
18235        require_eq(
18236            &source_rejection.parser,
18237            &ParserKind::TreeSitter,
18238            "relation-source rejection parser",
18239        )?;
18240        require_eq(
18241            &source_rejection.reason,
18242            &GraphIdentityRejectionReason::ControlCharacters,
18243            "relation-source rejection reason",
18244        )?;
18245        require_eq(
18246            &source_rejection.span.start_line(),
18247            &3,
18248            "relation-source rejection start line",
18249        )?;
18250        require_eq(
18251            &source_rejection.span.end_line(),
18252            &3,
18253            "relation-source rejection end line",
18254        )?;
18255        let wire = serde_json::to_string(&reopened_rejections)?;
18256        require(
18257            !wire.contains("bad") && !wire.contains("target\u{0}"),
18258            "full-stage typed rejection retained raw invalid identity material",
18259        )?;
18260        for path in &rejection_paths {
18261            let entities = reopened.repository_graph_entities_by_path(project, path, 64)?;
18262            require(
18263                entities.rows.iter().any(|entity| {
18264                    matches!(
18265                        entity.selector(),
18266                        EntitySelector::Symbol { symbol } if symbol.name.as_str() == "caller"
18267                    )
18268                }),
18269                "valid sibling symbol was not navigable after SQLite reopen",
18270            )?;
18271        }
18272        let calls = reopened.repository_graph_relations(
18273            RepositoryGraphRelationQuery::Family {
18274                relation: GraphRelationKind::Legacy(RelationKind::Calls),
18275            },
18276            16,
18277        )?;
18278        require_eq(
18279            &calls.rows.len(),
18280            &2,
18281            "valid sibling call relations after SQLite reopen",
18282        )?;
18283        reopened.finish_index_read_snapshot()?;
18284
18285        let mut writer = AtlasStore::open_for_project(&database, &root)?;
18286        let fault_control = IndexWorkControl::new(IndexCancellation::new(), None);
18287        let fault_policy =
18288            RootScanPolicy::discover(&root, &ScanOptions::default(), &fault_control)?;
18289        let mut fault_stage = stage_full_repository_graph(
18290            &writer,
18291            &root,
18292            first_publication.generation,
18293            &nodes,
18294            &fault_policy,
18295            &symbols,
18296            &fault_control,
18297        )?;
18298        fault_stage.identity_rejections.resize(
18299            usize::try_from(GraphLimits::MAX_ROWS)
18300                .unwrap_or(usize::MAX)
18301                .saturating_add(1),
18302            fault_stage.identity_rejections[0].clone(),
18303        );
18304        {
18305            let mut publication = writer.begin_index_publication("full-identity-fault")?;
18306            publication.begin_scan_replacement()?;
18307            publication.upsert_scan_node_batch(&nodes)?;
18308            publication.finish_scan_replacement()?;
18309            require(
18310                fault_stage.apply(&mut publication, &fault_control).is_err(),
18311                "oversized rejection detail did not fault after graph replacement",
18312            )?;
18313        }
18314        require_eq(
18315            &writer
18316                .index_publication()?
18317                .map(|publication| publication.generation),
18318            &Some(first_publication.generation),
18319            "generation after late rejection-detail fault",
18320        )?;
18321        let retained =
18322            writer.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
18323        require_eq(
18324            &retained,
18325            &reopened_rejections,
18326            "prior complete typed rejection generation after fault",
18327        )?;
18328        let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
18329        let retry_policy =
18330            RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
18331        let retry_stage = stage_full_repository_graph(
18332            &writer,
18333            &root,
18334            first_publication.generation,
18335            &nodes,
18336            &retry_policy,
18337            &symbols,
18338            &retry_control,
18339        )?;
18340        publish_full_staged_graph(
18341            &mut writer,
18342            &nodes,
18343            &retry_stage,
18344            &retry_control,
18345            "full-identity-retry",
18346        )?;
18347        let retried =
18348            writer.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
18349        require_eq(
18350            &retried,
18351            &reopened_rejections,
18352            "deterministic full-stage retry",
18353        )?;
18354        Ok(())
18355    }
18356
18357    #[test]
18358    fn full_stage_reuse_carries_persisted_rejection_coverage_and_replaces_it()
18359    -> Result<(), Box<dyn Error>> {
18360        let temp = tempfile::tempdir()?;
18361        let root = temp.path().join("full-reuse-identity-admission");
18362        fs::create_dir_all(root.join("src"))?;
18363        fs::write(
18364            root.join("src/reused.rs"),
18365            "pub fn caller() { helper(); }\nfn helper() {}\n",
18366        )?;
18367        let database = root.join(".projectatlas/projectatlas.db");
18368        fs::create_dir_all(root.join(".projectatlas"))?;
18369        let mut store = AtlasStore::open_for_project(&database, &root)?;
18370        let project = store
18371            .project_instance_id()?
18372            .ok_or("full reuse project identity is missing")?;
18373        let path = "src/reused.rs";
18374        let mut nodes = vec![test_file_node(path, "rust")];
18375        capture_test_file_hashes(&root, &mut nodes)?;
18376        let graph = identity_sibling_graph(path, GraphIdentityField::RelationTarget);
18377        let first_control = IndexWorkControl::new(IndexCancellation::new(), None);
18378        let first_policy =
18379            RootScanPolicy::discover(&root, &ScanOptions::default(), &first_control)?;
18380        let mut first_symbols = symbol_build_stage_for_graphs(vec![graph]);
18381        first_symbols.identity_admission =
18382            super::admit_symbol_build_stage(&mut first_symbols, &first_control)?;
18383        let first_graph = first_symbols
18384            .changes
18385            .iter()
18386            .find_map(|change| match change {
18387                SymbolProjectionChange::Parsed(parsed) => Some(parsed.graph.clone()),
18388                SymbolProjectionChange::Clear { .. } => None,
18389            })
18390            .ok_or("full reuse parsed graph is missing")?;
18391        let first_stage = stage_full_repository_graph(
18392            &store,
18393            &root,
18394            IndexGeneration::ZERO,
18395            &nodes,
18396            &first_policy,
18397            &first_symbols,
18398            &first_control,
18399        )?;
18400        require_eq(
18401            &first_stage.identity_rejections.len(),
18402            &1,
18403            "full reuse initial rejection detail count",
18404        )?;
18405        publish_full_staged_graph(
18406            &mut store,
18407            &nodes,
18408            &first_stage,
18409            &first_control,
18410            "full-reuse-initial",
18411        )?;
18412        // This is the same persistence sink used by apply_symbol_build_stage;
18413        // the next full stage intentionally receives no parser changes.
18414        store.replace_symbol_graph(&first_graph)?;
18415        let first_generation = store
18416            .index_publication()?
18417            .ok_or("full reuse initial publication is missing")?
18418            .generation;
18419        drop(store);
18420
18421        let mut store = AtlasStore::open_for_project(&database, &root)?;
18422        let reuse_control = IndexWorkControl::new(IndexCancellation::new(), None);
18423        let reuse_policy =
18424            RootScanPolicy::discover(&root, &ScanOptions::default(), &reuse_control)?;
18425        let reused_stage = stage_full_repository_graph(
18426            &store,
18427            &root,
18428            first_generation,
18429            &nodes,
18430            &reuse_policy,
18431            &empty_symbol_build_stage(),
18432            &reuse_control,
18433        )?;
18434        require_eq(
18435            &reused_stage.identity_rejections,
18436            &first_stage.identity_rejections,
18437            "full reuse retained exact persisted rejection details",
18438        )?;
18439        let reused_coverage = reused_stage
18440            .coverage
18441            .iter()
18442            .find(|coverage| {
18443                matches!(
18444                    coverage.scope(),
18445                    CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
18446                ) && coverage.relation().is_none()
18447            })
18448            .ok_or("full reuse coverage row is missing")?;
18449        require_eq(
18450            &reused_coverage.state(),
18451            &CoverageState::Partial,
18452            "full reuse retained partial coverage",
18453        )?;
18454        require_eq(
18455            &reused_coverage.omitted(),
18456            &1,
18457            "full reuse retained rejection omission count",
18458        )?;
18459        require(
18460            reused_stage
18461                .relations
18462                .iter()
18463                .any(|relation| relation.resolution().resolved_target().is_some()),
18464            "full reuse dropped the valid sibling relation",
18465        )?;
18466        publish_full_staged_graph(
18467            &mut store,
18468            &nodes,
18469            &reused_stage,
18470            &reuse_control,
18471            "full-reuse-republish",
18472        )?;
18473        let persisted_paths = vec![RepositoryNodePath::new(Path::new(path))?];
18474        let persisted =
18475            store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?;
18476        require_eq(
18477            &persisted,
18478            &first_stage.identity_rejections,
18479            "full reuse persisted exact rejection details",
18480        )?;
18481
18482        let cancel_control = IndexWorkControl::new(IndexCancellation::new(), None);
18483        let canceled_stage = stage_full_repository_graph(
18484            &store,
18485            &root,
18486            first_generation
18487                .checked_next()
18488                .ok_or("full reuse generation overflow")?,
18489            &nodes,
18490            &reuse_policy,
18491            &empty_symbol_build_stage(),
18492            &cancel_control,
18493        )?;
18494        cancel_control.cancel();
18495        {
18496            let mut publication = store.begin_index_publication("full-reuse-cancel")?;
18497            publication.begin_scan_replacement()?;
18498            publication.upsert_scan_node_batch(&nodes)?;
18499            publication.finish_scan_replacement()?;
18500            require(
18501                canceled_stage
18502                    .apply(&mut publication, &cancel_control)
18503                    .is_err(),
18504                "full reuse cancellation was ignored",
18505            )?;
18506        }
18507        let retained_generation = store
18508            .index_publication()?
18509            .ok_or("full reuse republished generation is missing")?
18510            .generation;
18511        require_eq(
18512            &retained_generation,
18513            &first_generation
18514                .checked_next()
18515                .ok_or("full reuse generation overflow")?,
18516            "full reuse cancellation changed the current generation",
18517        )?;
18518        require_eq(
18519            &store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?,
18520            &persisted,
18521            "full reuse cancellation changed rejection details",
18522        )?;
18523
18524        let fault_control = IndexWorkControl::new(IndexCancellation::new(), None);
18525        let fault_policy =
18526            RootScanPolicy::discover(&root, &ScanOptions::default(), &fault_control)?;
18527        let mut fault_stage = stage_full_repository_graph(
18528            &store,
18529            &root,
18530            retained_generation,
18531            &nodes,
18532            &fault_policy,
18533            &empty_symbol_build_stage(),
18534            &fault_control,
18535        )?;
18536        fault_stage.identity_rejections.resize(
18537            usize::try_from(GraphLimits::MAX_ROWS)
18538                .unwrap_or(usize::MAX)
18539                .saturating_add(1),
18540            fault_stage.identity_rejections[0].clone(),
18541        );
18542        {
18543            let mut publication = store.begin_index_publication("full-reuse-fault")?;
18544            publication.begin_scan_replacement()?;
18545            publication.upsert_scan_node_batch(&nodes)?;
18546            publication.finish_scan_replacement()?;
18547            require(
18548                fault_stage.apply(&mut publication, &fault_control).is_err(),
18549                "full reuse late rejection-detail fault was ignored",
18550            )?;
18551        }
18552        require_eq(
18553            &store
18554                .index_publication()?
18555                .ok_or("full reuse fault lost publication")?
18556                .generation,
18557            &retained_generation,
18558            "full reuse late fault changed current generation",
18559        )?;
18560        require_eq(
18561            &store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?,
18562            &persisted,
18563            "full reuse late fault changed rejection details",
18564        )?;
18565        let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
18566        let retry_policy =
18567            RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
18568        let retry_stage = stage_full_repository_graph(
18569            &store,
18570            &root,
18571            retained_generation,
18572            &nodes,
18573            &retry_policy,
18574            &empty_symbol_build_stage(),
18575            &retry_control,
18576        )?;
18577        publish_full_staged_graph(
18578            &mut store,
18579            &nodes,
18580            &retry_stage,
18581            &retry_control,
18582            "full-reuse-retry",
18583        )?;
18584        require_eq(
18585            &store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?,
18586            &persisted,
18587            "full reuse retry changed rejection details",
18588        )?;
18589        let retained_generation = store
18590            .index_publication()?
18591            .ok_or("full reuse retry publication is missing")?
18592            .generation;
18593
18594        let valid_graph = extract_symbol_graph(
18595            path,
18596            Some("rust"),
18597            "pub fn caller() { helper(); }\nfn helper() {}\n",
18598        );
18599        let valid_control = IndexWorkControl::new(IndexCancellation::new(), None);
18600        let valid_policy =
18601            RootScanPolicy::discover(&root, &ScanOptions::default(), &valid_control)?;
18602        let valid_stage = stage_full_repository_graph(
18603            &store,
18604            &root,
18605            retained_generation,
18606            &nodes,
18607            &valid_policy,
18608            &symbol_build_stage_for_graphs(vec![valid_graph]),
18609            &valid_control,
18610        )?;
18611        require(
18612            valid_stage.identity_rejections.is_empty(),
18613            "full reuse changed graph retained stale rejection detail",
18614        )?;
18615        publish_full_staged_graph(
18616            &mut store,
18617            &nodes,
18618            &valid_stage,
18619            &valid_control,
18620            "full-reuse-repair",
18621        )?;
18622        require(
18623            store
18624                .repository_graph_identity_rejections(project, &persisted_paths, 16, None)?
18625                .is_empty(),
18626            "full reuse repaired graph retained stale rejection detail",
18627        )?;
18628        Ok(())
18629    }
18630
18631    #[test]
18632    fn full_stage_reuse_preserves_capped_fallback_omission_count_without_details()
18633    -> Result<(), Box<dyn Error>> {
18634        let temp = tempfile::tempdir()?;
18635        let root = temp.path().join("full-reuse-fallback-identity-admission");
18636        fs::create_dir_all(root.join("src"))?;
18637        fs::write(
18638            root.join("src/reused.rs"),
18639            "pub fn caller() { helper(); }\nfn helper() {}\n",
18640        )?;
18641        let database = root.join(".projectatlas/projectatlas.db");
18642        fs::create_dir_all(root.join(".projectatlas"))?;
18643        let mut store = AtlasStore::open_for_project(&database, &root)?;
18644        let project = store
18645            .project_instance_id()?
18646            .ok_or("fallback reuse project identity is missing")?;
18647        let path = "src/reused.rs";
18648        let mut nodes = vec![test_file_node(path, "rust")];
18649        capture_test_file_hashes(&root, &mut nodes)?;
18650        let mut graph = identity_sibling_graph(path, GraphIdentityField::RelationTarget);
18651        graph.parser = ParserKind::Fallback;
18652        for symbol in &mut graph.symbols {
18653            symbol.parser = ParserKind::Fallback;
18654        }
18655        for relation in &mut graph.relations {
18656            relation.parser = ParserKind::Fallback;
18657        }
18658        let control = IndexWorkControl::new(IndexCancellation::new(), None);
18659        let policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
18660        let mut symbols = symbol_build_stage_for_graphs(vec![graph]);
18661        symbols.identity_admission = super::admit_symbol_build_stage(&mut symbols, &control)?;
18662        let persisted_graph = symbols
18663            .changes
18664            .iter()
18665            .find_map(|change| match change {
18666                SymbolProjectionChange::Parsed(parsed) => Some(parsed.graph.clone()),
18667                SymbolProjectionChange::Clear { .. } => None,
18668            })
18669            .ok_or("fallback sanitized graph is missing")?;
18670        let mut stage = stage_full_repository_graph(
18671            &store,
18672            &root,
18673            IndexGeneration::ZERO,
18674            &nodes,
18675            &policy,
18676            &symbols,
18677            &control,
18678        )?;
18679        require_eq(
18680            &stage.identity_rejections.len(),
18681            &1,
18682            "fallback initial typed rejection detail count",
18683        )?;
18684        let coverage = stage
18685            .coverage
18686            .iter_mut()
18687            .find(|coverage| {
18688                matches!(
18689                    coverage.scope(),
18690                    CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
18691                ) && coverage.relation().is_none()
18692            })
18693            .ok_or("fallback initial coverage row is missing")?;
18694        let scope = coverage.scope().clone();
18695        let state = coverage.state();
18696        let covered = coverage.covered();
18697        let generation = coverage.generation();
18698        let reason = coverage.reason().cloned();
18699        let reached_limit = coverage.reached_limit();
18700        *coverage = CoverageRecord::new(
18701            scope,
18702            None,
18703            state,
18704            covered,
18705            2,
18706            generation,
18707            reason,
18708            reached_limit,
18709        )?;
18710        // Simulate a capped publication: the persisted coverage count remains
18711        // authoritative even when no typed detail row survived the ceiling.
18712        stage.identity_rejections.clear();
18713        publish_full_staged_graph(
18714            &mut store,
18715            &nodes,
18716            &stage,
18717            &control,
18718            "fallback-reuse-initial",
18719        )?;
18720        store.replace_symbol_graph(&persisted_graph)?;
18721        let base_generation = store
18722            .index_publication()?
18723            .ok_or("fallback initial publication is missing")?
18724            .generation;
18725        let persisted_paths = vec![RepositoryNodePath::new(Path::new(path))?];
18726        require(
18727            store
18728                .repository_graph_identity_rejections(project, &persisted_paths, 16, None)?
18729                .is_empty(),
18730            "fallback capped publication retained an unexpected detail row",
18731        )?;
18732
18733        let reuse_control = IndexWorkControl::new(IndexCancellation::new(), None);
18734        let reuse_policy =
18735            RootScanPolicy::discover(&root, &ScanOptions::default(), &reuse_control)?;
18736        let reused = stage_full_repository_graph(
18737            &store,
18738            &root,
18739            base_generation,
18740            &nodes,
18741            &reuse_policy,
18742            &empty_symbol_build_stage(),
18743            &reuse_control,
18744        )?;
18745        require(
18746            reused.identity_rejections.is_empty(),
18747            "fallback reuse fabricated a detail row after cap",
18748        )?;
18749        let reused_coverage = reused
18750            .coverage
18751            .iter()
18752            .find(|coverage| {
18753                matches!(
18754                    coverage.scope(),
18755                    CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
18756                ) && coverage.relation().is_none()
18757            })
18758            .ok_or("fallback reused coverage row is missing")?;
18759        require_eq(
18760            &reused_coverage.omitted(),
18761            &2,
18762            "fallback reused persisted omission count",
18763        )?;
18764        require_eq(
18765            &reused_coverage.state(),
18766            &CoverageState::Partial,
18767            "fallback reused coverage state",
18768        )?;
18769        Ok(())
18770    }
18771
18772    #[test]
18773    fn incremental_reuse_hydrates_unchanged_affected_dependent_rejections()
18774    -> Result<(), Box<dyn Error>> {
18775        let temp = tempfile::tempdir()?;
18776        let root = temp
18777            .path()
18778            .join("incremental-reuse-dependent-identity-admission");
18779        fs::create_dir_all(root.join("src"))?;
18780        fs::write(root.join("src/provider.rs"), "pub fn changed() {}\n")?;
18781        fs::write(
18782            root.join("src/consumer.rs"),
18783            "pub fn caller() { changed(); }\n",
18784        )?;
18785        let database = root.join(".projectatlas/projectatlas.db");
18786        fs::create_dir_all(root.join(".projectatlas"))?;
18787        let mut store = AtlasStore::open_for_project(&database, &root)?;
18788        let project = store
18789            .project_instance_id()?
18790            .ok_or("incremental dependent project identity is missing")?;
18791        let provider_path = "src/provider.rs";
18792        let consumer_path = "src/consumer.rs";
18793        let mut nodes = vec![
18794            test_file_node(provider_path, "rust"),
18795            test_file_node(consumer_path, "rust"),
18796        ];
18797        capture_test_file_hashes(&root, &mut nodes)?;
18798        let provider = extract_symbol_graph(provider_path, Some("rust"), "pub fn changed() {}\n");
18799        let mut consumer = extract_symbol_graph(
18800            consumer_path,
18801            Some("rust"),
18802            "pub fn caller() { changed(); }\n",
18803        );
18804        consumer.relations.push(SymbolRelation {
18805            path: consumer_path.to_string(),
18806            source_name: "caller".to_string(),
18807            target_name: "bad\0target".to_string(),
18808            kind: RelationKind::Calls,
18809            line: 3,
18810            context: "incremental unchanged dependent fixture".to_string(),
18811            parser: ParserKind::TreeSitter,
18812        });
18813        let mut initial_symbols = symbol_build_stage_for_graphs(vec![provider, consumer]);
18814        let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
18815        initial_symbols.identity_admission =
18816            super::admit_symbol_build_stage(&mut initial_symbols, &initial_control)?;
18817        let persisted_graphs = initial_symbols
18818            .changes
18819            .iter()
18820            .filter_map(|change| match change {
18821                SymbolProjectionChange::Parsed(parsed) => Some(parsed.graph.clone()),
18822                SymbolProjectionChange::Clear { .. } => None,
18823            })
18824            .collect::<Vec<_>>();
18825        let policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
18826        let initial_stage = stage_full_repository_graph(
18827            &store,
18828            &root,
18829            IndexGeneration::ZERO,
18830            &nodes,
18831            &policy,
18832            &initial_symbols,
18833            &initial_control,
18834        )?;
18835        require(
18836            initial_stage
18837                .identity_rejections
18838                .iter()
18839                .any(|rejection| rejection.path.as_str() == consumer_path),
18840            "initial dependent rejection was not admitted",
18841        )?;
18842        publish_full_staged_graph(
18843            &mut store,
18844            &nodes,
18845            &initial_stage,
18846            &initial_control,
18847            "incremental-dependent-initial",
18848        )?;
18849        for graph in &persisted_graphs {
18850            store.replace_symbol_graph(graph)?;
18851        }
18852        let base_generation = store
18853            .index_publication()?
18854            .ok_or("incremental dependent initial publication is missing")?
18855            .generation;
18856        let consumer_key = RepositoryNodePath::new(Path::new(consumer_path))?;
18857        let initial_rows = store.repository_graph_identity_rejections(
18858            project,
18859            std::slice::from_ref(&consumer_key),
18860            16,
18861            None,
18862        )?;
18863        require_eq(
18864            &initial_rows.len(),
18865            &1,
18866            "initial unchanged dependent rejection rows",
18867        )?;
18868
18869        let replacement =
18870            extract_symbol_graph(provider_path, Some("rust"), "pub fn replacement() {}\n");
18871        let replacement_symbols = symbol_build_stage_for_graphs(vec![replacement]);
18872        let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
18873        let incremental_policy =
18874            RootScanPolicy::discover(&root, &ScanOptions::default(), &incremental_control)?;
18875        let incremental_stage = stage_incremental_repository_graph(
18876            &store,
18877            &root,
18878            base_generation,
18879            &nodes,
18880            std::slice::from_ref(&provider_path.to_string()),
18881            &incremental_policy,
18882            &replacement_symbols,
18883            &incremental_control,
18884        )?;
18885        require(
18886            incremental_stage
18887                .identity_rejections
18888                .iter()
18889                .any(|rejection| rejection.path.as_str() == consumer_path),
18890            "incremental affected dependent did not hydrate rejection details",
18891        )?;
18892        let staged_coverage = incremental_stage
18893            .coverage
18894            .iter()
18895            .find(|coverage| {
18896                matches!(
18897                    coverage.scope(),
18898                    CoverageScope::Path { path } if path.as_str() == consumer_path
18899                ) && coverage.relation().is_none()
18900            })
18901            .ok_or("incremental affected dependent coverage is missing")?;
18902        require_eq(
18903            &staged_coverage.omitted(),
18904            &1,
18905            "incremental affected dependent omission count",
18906        )?;
18907        {
18908            let mut publication = store.begin_index_publication("incremental-dependent-reuse")?;
18909            incremental_stage.apply(&mut publication, &incremental_control)?;
18910            publication.complete()?;
18911        }
18912        require_eq(
18913            &store.repository_graph_identity_rejections(
18914                project,
18915                std::slice::from_ref(&consumer_key),
18916                16,
18917                None,
18918            )?,
18919            &initial_rows,
18920            "incremental affected dependent persisted rejection rows",
18921        )?;
18922        Ok(())
18923    }
18924
18925    #[test]
18926    fn incremental_reuse_reconciles_markdown_and_semantic_rejections_once()
18927    -> Result<(), Box<dyn Error>> {
18928        let temp = tempfile::tempdir()?;
18929        let root = fs::canonicalize(temp.path())?;
18930        let semantic_components = (0..20)
18931            .map(|_| "s".repeat(200))
18932            .collect::<Vec<_>>()
18933            .join("/");
18934        let semantic_path = format!("src/semantic/{semantic_components}/graph.rs");
18935        fs::create_dir_all(root.join("src"))?;
18936        fs::create_dir_all(root.join("docs"))?;
18937        fs::create_dir_all(
18938            root.join(&semantic_path)
18939                .parent()
18940                .ok_or("semantic fixture parent is missing")?,
18941        )?;
18942        let guide_source = "[invalid](<../src/worker.rs#bad\u{1}>)\n[worker](../src/worker.rs)\n";
18943        fs::write(root.join("src/worker.rs"), "pub fn worker() {}\n")?;
18944        fs::write(
18945            root.join(&semantic_path),
18946            semantic_resolution_key_source(&semantic_path, true),
18947        )?;
18948        fs::write(root.join("docs/guide.md"), guide_source)?;
18949        let database = root.join(".projectatlas/projectatlas.db");
18950        fs::create_dir_all(root.join(".projectatlas"))?;
18951        let mut store = AtlasStore::open_for_project(&database, &root)?;
18952        let project = store
18953            .project_instance_id()?
18954            .ok_or("combined reuse project identity is missing")?;
18955        let mut nodes = vec![
18956            test_file_node("src/worker.rs", "rust"),
18957            test_file_node(&semantic_path, "rust"),
18958            test_file_node("docs/guide.md", "markdown"),
18959        ];
18960        capture_test_file_hashes(&root, &mut nodes)?;
18961        let worker_graph =
18962            extract_symbol_graph("src/worker.rs", Some("rust"), "pub fn worker() {}\n");
18963        let semantic_graph = semantic_resolution_key_graph(&semantic_path, true);
18964        let guide_facts = projectatlas_symbols::extract_markdown_facts(guide_source);
18965        let guide_graph = guide_facts.symbol_graph("docs/guide.md", Some("markdown"));
18966        let guide_bytes = guide_source.as_bytes();
18967        nodes[2].size_bytes = Some(u64::try_from(guide_bytes.len())?);
18968        nodes[2].content_hash = Some(blake3::hash(guide_bytes).to_hex().to_string());
18969        let mut initial_symbols = symbol_build_stage_for_graphs(vec![worker_graph, semantic_graph]);
18970        initial_symbols.report.candidates = initial_symbols.report.candidates.saturating_add(1);
18971        initial_symbols.report.parsed = initial_symbols.report.parsed.saturating_add(1);
18972        initial_symbols.report.summaries = initial_symbols.report.summaries.saturating_add(1);
18973        initial_symbols
18974            .changes
18975            .push(SymbolProjectionChange::Parsed(SymbolParseSuccess {
18976                path: "docs/guide.md".to_string(),
18977                graph: guide_graph,
18978                markdown_facts: Some(Box::new(guide_facts)),
18979                source_semantics: None,
18980                source_parser: ParserKind::Structural,
18981                summary: "combined reuse Markdown fixture".to_string(),
18982                summary_is_structural: true,
18983                purpose_suggestion: None,
18984            }));
18985        let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
18986        initial_symbols.identity_admission =
18987            super::admit_symbol_build_stage(&mut initial_symbols, &initial_control)?;
18988        let scan_policy =
18989            RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
18990        let initial_stage = stage_full_repository_graph(
18991            &store,
18992            &root,
18993            IndexGeneration::ZERO,
18994            &nodes,
18995            &scan_policy,
18996            &initial_symbols,
18997            &initial_control,
18998        )?;
18999        require(
19000            initial_stage
19001                .identity_rejections
19002                .iter()
19003                .any(|row| row.path.as_str() == "docs/guide.md"),
19004            "combined reuse fixture did not retain Markdown rejection",
19005        )?;
19006        require(
19007            initial_stage.identity_rejections.iter().any(|row| {
19008                row.path.as_str() == semantic_path && row.field == GraphIdentityField::ResolutionKey
19009            }),
19010            "combined reuse fixture did not retain semantic rejection",
19011        )?;
19012        publish_full_staged_graph(
19013            &mut store,
19014            &nodes,
19015            &initial_stage,
19016            &initial_control,
19017            "combined-reuse-initial",
19018        )?;
19019        for change in &initial_symbols.changes {
19020            if let SymbolProjectionChange::Parsed(parsed) = change {
19021                store.replace_symbol_graph(&parsed.graph)?;
19022            }
19023        }
19024        let base_generation = store
19025            .index_publication()?
19026            .ok_or("combined reuse initial publication is missing")?
19027            .generation;
19028        let paths = nodes
19029            .iter()
19030            .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
19031            .collect::<Result<Vec<_>, _>>()?;
19032        let initial_rows = store.repository_graph_identity_rejections(
19033            project,
19034            &paths,
19035            GraphLimits::MAX_ROWS,
19036            None,
19037        )?;
19038        let replacement =
19039            extract_symbol_graph("src/worker.rs", Some("rust"), "pub fn replacement() {}\n");
19040        let replacement_symbols = symbol_build_stage_for_graphs(vec![replacement]);
19041        let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
19042        let incremental_stage = stage_incremental_repository_graph(
19043            &store,
19044            &root,
19045            base_generation,
19046            &nodes,
19047            &["src/worker.rs".to_string()],
19048            &scan_policy,
19049            &replacement_symbols,
19050            &incremental_control,
19051        )?;
19052        let guide_coverage = incremental_stage
19053            .coverage
19054            .iter()
19055            .find(|coverage| {
19056                matches!(
19057                    coverage.scope(),
19058                    CoverageScope::Path { path } if path.as_str() == "docs/guide.md"
19059                ) && coverage.relation().is_none()
19060            })
19061            .ok_or("combined reuse Markdown coverage is missing")?;
19062        let semantic_coverage = incremental_stage
19063            .coverage
19064            .iter()
19065            .find(|coverage| {
19066                matches!(
19067                    coverage.scope(),
19068                    CoverageScope::Path { path } if path.as_str() == semantic_path
19069                ) && coverage.relation().is_none()
19070            })
19071            .ok_or("combined reuse semantic coverage is missing")?;
19072        let initial_guide_omitted = initial_rows
19073            .iter()
19074            .filter(|row| row.path.as_str() == "docs/guide.md")
19075            .count();
19076        let initial_semantic_omitted = initial_rows
19077            .iter()
19078            .filter(|row| row.path.as_str() == semantic_path)
19079            .count();
19080        require_eq(
19081            &guide_coverage.omitted(),
19082            &u64::try_from(initial_guide_omitted)?,
19083            "reused Markdown omission count",
19084        )?;
19085        require_eq(
19086            &semantic_coverage.omitted(),
19087            &u64::try_from(initial_semantic_omitted)?,
19088            "reused semantic omission count",
19089        )?;
19090        require_eq(
19091            &incremental_stage
19092                .identity_rejections
19093                .iter()
19094                .filter(|row| row.path.as_str() == "docs/guide.md")
19095                .count(),
19096            &initial_guide_omitted,
19097            "reused Markdown detail count",
19098        )?;
19099        require_eq(
19100            &incremental_stage
19101                .identity_rejections
19102                .iter()
19103                .filter(|row| row.path.as_str() == semantic_path)
19104                .count(),
19105            &initial_semantic_omitted,
19106            "reused semantic detail count",
19107        )?;
19108        let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
19109        canceled.cancel();
19110        {
19111            let mut publication = store.begin_index_publication("combined-reuse-cancel")?;
19112            require(
19113                incremental_stage
19114                    .apply(&mut publication, &canceled)
19115                    .is_err(),
19116                "combined reuse cancellation reached publication",
19117            )?;
19118        }
19119        require_eq(
19120            &store
19121                .index_publication()?
19122                .map(|publication| publication.generation),
19123            &Some(base_generation),
19124            "combined reuse cancellation changed generation",
19125        )?;
19126        {
19127            let mut publication = store.begin_index_publication("combined-reuse-incremental")?;
19128            incremental_stage.apply(&mut publication, &incremental_control)?;
19129            publication.complete()?;
19130        }
19131        let persisted_rows = store.repository_graph_identity_rejections(
19132            project,
19133            &paths,
19134            GraphLimits::MAX_ROWS,
19135            None,
19136        )?;
19137        require_eq(
19138            &persisted_rows,
19139            &initial_rows,
19140            "reused Markdown and semantic persisted rows",
19141        )?;
19142        let retry_generation = store
19143            .index_publication()?
19144            .ok_or("combined reuse incremental publication is missing")?
19145            .generation;
19146        let retry_stage = stage_incremental_repository_graph(
19147            &store,
19148            &root,
19149            retry_generation,
19150            &nodes,
19151            &["src/worker.rs".to_string()],
19152            &scan_policy,
19153            &replacement_symbols,
19154            &incremental_control,
19155        )?;
19156        require_eq(
19157            &retry_stage
19158                .identity_rejections
19159                .iter()
19160                .filter(|row| row.path.as_str() == "docs/guide.md")
19161                .count(),
19162            &initial_guide_omitted,
19163            "reused Markdown deterministic retry detail count",
19164        )?;
19165        require_eq(
19166            &retry_stage
19167                .identity_rejections
19168                .iter()
19169                .filter(|row| row.path.as_str() == semantic_path)
19170                .count(),
19171            &initial_semantic_omitted,
19172            "reused semantic deterministic retry detail count",
19173        )?;
19174        Ok(())
19175    }
19176
19177    #[test]
19178    fn incremental_stage_repairs_and_removes_only_affected_rejection_paths()
19179    -> Result<(), Box<dyn Error>> {
19180        let temp = tempfile::tempdir()?;
19181        let root = temp.path().join("incremental-identity-admission");
19182        for directory in ["src", "tests", "docs"] {
19183            fs::create_dir_all(root.join(directory))?;
19184        }
19185        for path in ["src/one.rs", "tests/two.rs", "docs/three.rs"] {
19186            fs::write(
19187                root.join(path),
19188                "pub fn caller() { helper(); }\nfn helper() {}\n",
19189            )?;
19190        }
19191        let database = root.join(".projectatlas/projectatlas.db");
19192        fs::create_dir_all(root.join(".projectatlas"))?;
19193        let mut store = AtlasStore::open_for_project(&database, &root)?;
19194        let project = store
19195            .project_instance_id()?
19196            .ok_or("incremental admission project identity is missing")?;
19197        let initial_nodes = [
19198            test_file_node("src/one.rs", "rust"),
19199            test_file_node("tests/two.rs", "rust"),
19200            test_file_node("docs/three.rs", "rust"),
19201        ];
19202        let initial_graphs = vec![
19203            identity_sibling_graph("src/one.rs", GraphIdentityField::RelationTarget),
19204            identity_sibling_graph("tests/two.rs", GraphIdentityField::RelationTarget),
19205            identity_sibling_graph("docs/three.rs", GraphIdentityField::RelationTarget),
19206        ];
19207        let initial_symbols = symbol_build_stage_for_graphs(initial_graphs);
19208        let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
19209        let initial_policy =
19210            RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
19211        let initial_stage = stage_full_repository_graph(
19212            &store,
19213            &root,
19214            IndexGeneration::ZERO,
19215            &initial_nodes,
19216            &initial_policy,
19217            &initial_symbols,
19218            &initial_control,
19219        )?;
19220        publish_full_staged_graph(
19221            &mut store,
19222            &initial_nodes,
19223            &initial_stage,
19224            &initial_control,
19225            "incremental-identity-initial",
19226        )?;
19227        let base_generation = store
19228            .index_publication()?
19229            .ok_or("incremental initial publication is missing")?
19230            .generation;
19231        let src_path = RepositoryNodePath::new(Path::new("src/one.rs"))?;
19232        let removed_path = RepositoryNodePath::new(Path::new("tests/two.rs"))?;
19233        let retained_path = RepositoryNodePath::new(Path::new("docs/three.rs"))?;
19234        let initial_rows = store.repository_graph_identity_rejections(
19235            project,
19236            &[
19237                src_path.clone(),
19238                removed_path.clone(),
19239                retained_path.clone(),
19240            ],
19241            16,
19242            None,
19243        )?;
19244        require_eq(
19245            &initial_rows.len(),
19246            &3,
19247            "initial incremental rejection rows",
19248        )?;
19249
19250        let repaired_graph = extract_symbol_graph(
19251            "src/one.rs",
19252            Some("rust"),
19253            "pub fn caller() { helper(); }\nfn helper() {}\n",
19254        );
19255        let repaired_symbols = symbol_build_stage_for_graphs(vec![repaired_graph]);
19256        let expected_nodes = vec![initial_nodes[0].clone(), initial_nodes[2].clone()];
19257        let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
19258        let incremental_policy =
19259            RootScanPolicy::discover(&root, &ScanOptions::default(), &incremental_control)?;
19260        let incremental_stage = stage_incremental_repository_graph(
19261            &store,
19262            &root,
19263            base_generation,
19264            &expected_nodes,
19265            &["src/one.rs".to_string(), "tests/two.rs".to_string()],
19266            &incremental_policy,
19267            &repaired_symbols,
19268            &incremental_control,
19269        )?;
19270        {
19271            let mut publication = store.begin_index_publication("incremental-identity-repair")?;
19272            incremental_stage.apply(&mut publication, &incremental_control)?;
19273            publication.complete()?;
19274        }
19275        let repaired_rows = store.repository_graph_identity_rejections(
19276            project,
19277            &[
19278                src_path.clone(),
19279                removed_path.clone(),
19280                retained_path.clone(),
19281            ],
19282            16,
19283            None,
19284        )?;
19285        require(
19286            repaired_rows
19287                .iter()
19288                .all(|row| row.path != src_path && row.path != removed_path),
19289            "incremental repair/removal retained affected rejection detail",
19290        )?;
19291        require(
19292            repaired_rows.iter().any(|row| row.path == retained_path),
19293            "incremental repair removed unrelated rejection detail",
19294        )?;
19295        require_eq(
19296            &repaired_rows.len(),
19297            &1,
19298            "incremental unaffected rejection rows",
19299        )?;
19300        Ok(())
19301    }
19302
19303    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
19304        if condition {
19305            Ok(())
19306        } else {
19307            Err(io::Error::other(message).into())
19308        }
19309    }
19310
19311    fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
19312    where
19313        T: Debug + PartialEq,
19314    {
19315        if actual == expected {
19316            Ok(())
19317        } else {
19318            Err(io::Error::other(format!(
19319                "{label} mismatch: actual={actual:?}, expected={expected:?}"
19320            ))
19321            .into())
19322        }
19323    }
19324
19325    fn publish_full_staged_graph(
19326        store: &mut AtlasStore,
19327        nodes: &[Node],
19328        staged: &StagedRepositoryGraph,
19329        control: &IndexWorkControl,
19330        label: &str,
19331    ) -> Result<(), Box<dyn Error>> {
19332        let mut publication = store.begin_index_publication(label)?;
19333        publication.begin_scan_replacement()?;
19334        publication.upsert_scan_node_batch(nodes)?;
19335        publication.finish_scan_replacement()?;
19336        staged.apply(&mut publication, control)?;
19337        publication.complete()?;
19338        Ok(())
19339    }
19340
19341    fn symbol_build_stage_for_markdown(
19342        graph: SymbolGraph,
19343        markdown_facts: projectatlas_symbols::MarkdownFacts,
19344    ) -> SymbolBuildStage {
19345        let mut stage = empty_symbol_build_stage();
19346        stage.report.candidates = 1;
19347        stage.report.parsed = 1;
19348        stage.report.summaries = 1;
19349        stage.changes = vec![SymbolProjectionChange::Parsed(SymbolParseSuccess {
19350            path: graph.path.clone(),
19351            source_semantics: None,
19352            source_parser: ParserKind::Structural,
19353            graph,
19354            markdown_facts: Some(Box::new(markdown_facts)),
19355            summary: "markdown identity admission fixture".to_string(),
19356            summary_is_structural: true,
19357            purpose_suggestion: None,
19358        })];
19359        stage
19360    }
19361
19362    fn symbol_build_stage_for_graphs(graphs: Vec<SymbolGraph>) -> SymbolBuildStage {
19363        let mut stage = empty_symbol_build_stage();
19364        stage.report.candidates = graphs.len();
19365        stage.report.parsed = graphs.len();
19366        stage.report.symbols = graphs.iter().map(|graph| graph.symbols.len()).sum();
19367        stage.report.relations = graphs.iter().map(|graph| graph.relations.len()).sum();
19368        stage.changes = graphs
19369            .into_iter()
19370            .map(|graph| {
19371                let path = graph.path.clone();
19372                let source_parser = graph.parser;
19373                SymbolProjectionChange::Parsed(SymbolParseSuccess {
19374                    path,
19375                    graph,
19376                    markdown_facts: None,
19377                    source_semantics: None,
19378                    source_parser,
19379                    summary: "identity admission fixture".to_string(),
19380                    summary_is_structural: false,
19381                    purpose_suggestion: None,
19382                })
19383            })
19384            .collect();
19385        stage
19386    }
19387
19388    fn identity_sibling_graph(path: &str, invalid_field: GraphIdentityField) -> SymbolGraph {
19389        let mut graph = extract_symbol_graph(
19390            path,
19391            Some("rust"),
19392            "pub fn caller() { helper(); }\nfn helper() {}\n",
19393        );
19394        graph.relations.push(SymbolRelation {
19395            path: path.to_string(),
19396            source_name: if invalid_field == GraphIdentityField::RelationSource {
19397                "bad\0source".to_string()
19398            } else {
19399                "caller".to_string()
19400            },
19401            target_name: if invalid_field == GraphIdentityField::RelationTarget {
19402                "bad\0target".to_string()
19403            } else {
19404                "helper".to_string()
19405            },
19406            kind: RelationKind::Calls,
19407            line: 3,
19408            context: "identity admission fixture".to_string(),
19409            parser: ParserKind::TreeSitter,
19410        });
19411        graph
19412    }
19413
19414    fn semantic_resolution_key_graph(path: &str, include_invalid: bool) -> SymbolGraph {
19415        extract_symbol_graph(
19416            path,
19417            Some("rust"),
19418            &semantic_resolution_key_source(path, include_invalid),
19419        )
19420    }
19421
19422    /// Keep recovered source bytes identical to the graph used by the fixture.
19423    fn semantic_resolution_key_source(path: &str, include_invalid: bool) -> String {
19424        let helper = if path.contains("sibling") {
19425            "sibling_helper"
19426        } else {
19427            "page_helper"
19428        };
19429        let parent = "LeakedIdentity".repeat(16);
19430        let invalid_methods = if include_invalid {
19431            format!(
19432                "pub struct {parent};\nimpl {parent} {{\n    pub fn first(&self) {{}}\n    pub fn second(&self) {{}}\n}}\n"
19433            )
19434        } else {
19435            String::new()
19436        };
19437        format!(
19438            "use crate::worker;\n{invalid_methods}pub fn caller() {{ {helper}(); worker(); }}\npub fn {helper}() {{}}\n"
19439        )
19440    }
19441
19442    #[test]
19443    fn source_recovery_preserves_parser_ordinals_counts_and_hash_guards()
19444    -> Result<(), Box<dyn Error>> {
19445        let temp = tempfile::tempdir()?;
19446        let root = temp.path();
19447        fs::create_dir_all(root.join("src"))?;
19448        let path = "src/main.rs";
19449        let content = "use crate::worker as handler; fn zeta() {} fn alpha() {} fn caller(zeta: fn(), std: Value) { zeta(); std::fs::read(\"x\"); } fn clean() { alpha(); zeta(); std::fs::read(\"x\"); true.then(handler::health_response); }";
19450        fs::write(root.join(path), content)?;
19451        let mut store = AtlasStore::open_for_project(&root.join("atlas.db"), root)?;
19452        let worker = "src/worker.rs";
19453        let worker_content = "pub fn health_response() {}";
19454        fs::write(root.join(worker), worker_content)?;
19455        let mut nodes = vec![test_file_node(path, "rust"), test_file_node(worker, "rust")];
19456        capture_test_file_hashes(root, &mut nodes)?;
19457        store.replace_scan(&nodes)?;
19458        let original = extract_symbol_graph(path, Some("rust"), content);
19459        store.replace_symbol_graph(&original)?;
19460        store.replace_symbol_graph(&extract_symbol_graph(worker, Some("rust"), worker_content))?;
19461        let loaded = store.load_symbol_graphs_for_paths(&[path.to_string()])?;
19462        require(
19463            original.symbols != loaded[0].symbols,
19464            "fixture did not reorder SQLite symbols",
19465        )?;
19466        let control = IndexWorkControl::new(IndexCancellation::new(), None);
19467        let policy = RootScanPolicy::discover(root, &ScanOptions::default(), &control)?;
19468        let mut reused = empty_symbol_build_stage();
19469        reused.report.candidates = 2;
19470        reused.report.unchanged = 2;
19471        let staged = stage_full_repository_graph(
19472            &store,
19473            root,
19474            IndexGeneration::ZERO,
19475            &nodes,
19476            &policy,
19477            &reused,
19478            &control,
19479        )?;
19480        require_eq(
19481            &staged.source_recovery.previously_unchanged,
19482            &1,
19483            "full recovered source count",
19484        )?;
19485        let verify = |stage: &StagedRepositoryGraph| -> Result<(), Box<dyn Error>> {
19486            let entities = stage
19487                .entities
19488                .iter()
19489                .map(|entity| (entity.key().digest(), entity))
19490                .collect::<BTreeMap<_, _>>();
19491            let mut resolved = Vec::new();
19492            let mut shadowed = 0;
19493            let mut external = 0;
19494            for relation in &stage.relations {
19495                if relation.kind() != GraphRelationKind::Legacy(RelationKind::Calls) {
19496                    continue;
19497                }
19498                let source = entities
19499                    .get(relation.source().digest())
19500                    .ok_or("missing source")?;
19501                let EntitySelector::Symbol { symbol } = source.selector() else {
19502                    return Err("call lost exact source owner".into());
19503                };
19504                match relation.resolution() {
19505                    RelationResolution::Resolved { selector, .. } => {
19506                        let ReusableTargetSelector::Symbol { symbol: target } = selector else {
19507                            return Err("wrong target kind".into());
19508                        };
19509                        resolved.push((
19510                            symbol.name.as_str().to_string(),
19511                            target.name.as_str().to_string(),
19512                        ));
19513                    }
19514                    RelationResolution::Unresolved { .. } if symbol.name.as_str() == "caller" => {
19515                        shadowed += 1;
19516                    }
19517                    RelationResolution::Unresolved { reference }
19518                        if symbol.name.as_str() == "clean" && reference.as_str() == "true.then" => {
19519                    }
19520                    RelationResolution::External { .. }
19521                        if matches!(symbol.name.as_str(), "clean" | "caller") =>
19522                    {
19523                        external += 1;
19524                    }
19525                    other => {
19526                        return Err(
19527                            format!("unexpected source-owned call: {symbol:?} {other:?}").into(),
19528                        );
19529                    }
19530                }
19531            }
19532            resolved.sort();
19533            require_eq(
19534                &resolved,
19535                &vec![
19536                    ("clean".to_string(), "alpha".to_string()),
19537                    ("clean".to_string(), "health_response".to_string()),
19538                    ("clean".to_string(), "zeta".to_string()),
19539                ],
19540                "source-owned resolved targets",
19541            )?;
19542            require_eq(&shadowed, &1, "shadowed value call")?;
19543            require_eq(
19544                &external,
19545                &2,
19546                "toolchain namespace calls ignore value parameters",
19547            )?;
19548            Ok(())
19549        };
19550        verify(&staged)?;
19551        let mut report = empty_symbol_build_stage().report;
19552        report.candidates = 2;
19553        report.unchanged = 2;
19554        staged.account_source_recovery(&mut report)?;
19555        require_eq(
19556            &(report.candidates, report.parsed, report.unchanged),
19557            &(2, 1, 1),
19558            "actual recovery accounting",
19559        )?;
19560        publish_full_staged_graph(
19561            &mut store,
19562            &nodes,
19563            &staged,
19564            &control,
19565            "source recovery full",
19566        )?;
19567        let base = store
19568            .repository_graph_generation()?
19569            .ok_or("no full generation")?;
19570        let incremental = stage_incremental_repository_graph(
19571            &store,
19572            root,
19573            base,
19574            &nodes,
19575            &[path.to_string()],
19576            &policy,
19577            &reused,
19578            &control,
19579        )?;
19580        verify(&incremental)?;
19581        require_eq(
19582            &incremental.source_recovery.previously_unchanged,
19583            &1,
19584            "incremental one parse",
19585        )?;
19586        require_eq(
19587            &incremental.source_recovery.additional_candidates,
19588            &0,
19589            "direct source not double counted",
19590        )?;
19591        let updated_worker = format!("{worker_content} // changed");
19592        fs::write(root.join(worker), &updated_worker)?;
19593        let mut updated_nodes = nodes.clone();
19594        capture_test_file_hashes(root, &mut updated_nodes)?;
19595        let changed = symbol_build_stage_for_graphs(vec![extract_symbol_graph(
19596            worker,
19597            Some("rust"),
19598            &updated_worker,
19599        )]);
19600        let inbound = stage_incremental_repository_graph(
19601            &store,
19602            root,
19603            base,
19604            &updated_nodes,
19605            &[worker.to_string()],
19606            &policy,
19607            &changed,
19608            &control,
19609        )?;
19610        verify(&inbound)?;
19611        require_eq(
19612            &inbound.source_recovery.previously_unchanged,
19613            &0,
19614            "fresh direct output was not reparsed",
19615        )?;
19616        require_eq(
19617            &inbound.source_recovery.additional_candidates,
19618            &1,
19619            "unchanged inbound source recovered once",
19620        )?;
19621        let mut report = empty_symbol_build_stage().report;
19622        report.candidates = 1;
19623        report.parsed = 1;
19624        inbound.account_source_recovery(&mut report)?;
19625        require_eq(
19626            &(report.candidates, report.parsed, report.unchanged),
19627            &(2, 2, 0),
19628            "actual inbound recovery accounting",
19629        )?;
19630        let mut cache = super::SourceBindingCache::new(root, &nodes, None);
19631        let first = cache.capture(path, &control)?;
19632        let second = cache.capture(path, &control)?;
19633        require(
19634            std::sync::Arc::ptr_eq(&first, &second),
19635            "capture reparsed a repeated path",
19636        )?;
19637        require_eq(
19638            &cache.work.previously_unchanged,
19639            &1,
19640            "cache actual parse count",
19641        )?;
19642        fs::write(root.join(path), format!("{content} // changed"))?;
19643        let mut cache = super::SourceBindingCache::new(root, &nodes, None);
19644        require(
19645            cache.capture(path, &control).is_err(),
19646            "changed captured hash admitted",
19647        )?;
19648        require_eq(
19649            &store.repository_graph_generation()?,
19650            &Some(base),
19651            "failed capture preserved publication",
19652        )?;
19653        Ok(())
19654    }
19655
19656    #[test]
19657    fn source_recovery_follows_the_consumed_import_and_export_contract() {
19658        for language in ["rust", "python", "javascript", "typescript", "tsx"] {
19659            let mut graph = function_graph("declarations", 1);
19660            graph.language = Some(language.to_string());
19661            graph.relations.clear();
19662            assert_eq!(
19663                super::graph_requires_source_bindings(&graph),
19664                matches!(language, "python" | "javascript" | "typescript" | "tsx"),
19665                "{language}: module export authority needs source binding roles"
19666            );
19667            graph.symbols[0].exported = false;
19668            assert!(
19669                super::graph_requires_source_bindings(&graph) == (language == "python"),
19670                "{language}: Python module exports do not depend on the display exported flag"
19671            );
19672            graph.relations.push(SymbolRelation {
19673                path: graph.path.clone(),
19674                source_name: "<module>".to_string(),
19675                target_name: "module".to_string(),
19676                kind: RelationKind::Imports,
19677                line: 1,
19678                context: String::new(),
19679                parser: ParserKind::TreeSitter,
19680            });
19681            assert!(super::graph_requires_source_bindings(&graph), "{language}");
19682            graph.relations[0].kind = RelationKind::Calls;
19683            assert!(super::graph_requires_source_bindings(&graph), "{language}");
19684            graph.relations.clear();
19685            graph.symbols.clear();
19686            assert!(
19687                !super::graph_requires_source_bindings(&graph),
19688                "{language}: empty graph needs no export proof"
19689            );
19690        }
19691    }
19692
19693    fn empty_symbol_build_stage() -> SymbolBuildStage {
19694        SymbolBuildStage {
19695            report: SymbolBuildReport {
19696                candidates: 0,
19697                parsed: 0,
19698                unchanged: 0,
19699                too_large: 0,
19700                binary_or_non_utf8: 0,
19701                timed_out: 0,
19702                max_workers: 1,
19703                timeout_seconds: None,
19704                symbols: 0,
19705                relations: 0,
19706                summaries: 0,
19707                purpose_suggestions: 0,
19708            },
19709            changes: Vec::new(),
19710            retained_bytes: 0,
19711            identity_admission: GraphIdentityAdmission::default(),
19712        }
19713    }
19714
19715    fn function_graph(path: &str, symbol_count: usize) -> SymbolGraph {
19716        SymbolGraph {
19717            path: path.to_string(),
19718            language: Some("rust".to_string()),
19719            parser: ParserKind::TreeSitter,
19720            symbols: (0..symbol_count)
19721                .map(|index| CodeSymbol {
19722                    path: path.to_string(),
19723                    language: Some("rust".to_string()),
19724                    name: format!("symbol_{index}"),
19725                    kind: SymbolKind::Function,
19726                    signature: format!("fn symbol_{index}()"),
19727                    exported: index == 0,
19728                    documentation: None,
19729                    line_start: index + 1,
19730                    line_end: index + 1,
19731                    source_selector: None,
19732                    parent: None,
19733                    parser: ParserKind::TreeSitter,
19734                    detail: Some("function_item".to_string()),
19735                })
19736                .collect(),
19737            relations: vec![SymbolRelation {
19738                path: path.to_string(),
19739                source_name: "symbol_0".to_string(),
19740                target_name: "dependency".to_string(),
19741                kind: RelationKind::Calls,
19742                line: 1,
19743                context: "dependency()".to_string(),
19744                parser: ParserKind::TreeSitter,
19745            }],
19746        }
19747    }
19748
19749    fn package_graph(path: &str, name: &str) -> SymbolGraph {
19750        SymbolGraph {
19751            path: path.to_string(),
19752            language: Some("cargo-manifest".to_string()),
19753            parser: ParserKind::Manifest,
19754            symbols: vec![CodeSymbol {
19755                path: path.to_string(),
19756                language: Some("cargo-manifest".to_string()),
19757                name: name.to_string(),
19758                kind: SymbolKind::Package,
19759                signature: format!("name = \"{name}\""),
19760                exported: true,
19761                documentation: None,
19762                line_start: 1,
19763                line_end: 1,
19764                source_selector: None,
19765                parent: None,
19766                parser: ParserKind::Manifest,
19767                detail: Some("cargo-package".to_string()),
19768            }],
19769            relations: Vec::new(),
19770        }
19771    }
19772}