Skip to main content

projectatlas_core/
graph.rs

1//! Typed, project-qualified repository graph contracts.
2
3use crate::symbols::{ParserKind, RelationKind, SymbolKind};
4use crate::{CoreError, IndexGeneration, validated_repo_file_key, validated_repo_node_key};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use std::num::NonZeroU32;
8use std::path::Path;
9use thiserror::Error;
10
11/// Canonical identity namespace for repository graph entities.
12const ENTITY_KEY_DOMAIN: &str = "projectatlas.graph.entity.v1";
13/// Canonical identity namespace for logical repository relationships.
14const RELATION_KEY_DOMAIN: &str = "projectatlas.graph.relation.v1";
15/// Canonical identity namespace for repository resolution keys.
16const RESOLUTION_KEY_DOMAIN: &str = "projectatlas.graph.resolution.v1";
17/// Largest accepted repository graph identity component in bytes.
18pub const MAX_GRAPH_IDENTITY_BYTES: usize = 4_096;
19/// Reserved namespace for compact graph-derived qualified symbol scopes.
20pub const QUALIFIED_SYMBOL_SCOPE_PREFIX: &str = "@projectatlas.scope.v1:";
21/// Maximum portable canonical resolver material retained in a derived snapshot.
22const MAX_PORTABLE_RESOLUTION_IDENTITY_BYTES: usize = 32 * 1_024;
23
24/// Failure while constructing or reconciling typed graph contracts.
25#[derive(Debug, Error)]
26pub enum GraphContractError {
27    /// A project instance identifier was malformed or used the zero sentinel.
28    #[error("invalid project instance identifier: {reason}")]
29    InvalidProjectInstanceId {
30        /// Stable explanation suitable for diagnostics.
31        reason: &'static str,
32    },
33    /// An identity component was blank, padded, too large, or contained control data.
34    #[error("invalid graph identity text: {reason}")]
35    InvalidIdentityText {
36        /// Stable explanation suitable for diagnostics.
37        reason: &'static str,
38    },
39    /// An existing repository path validator rejected a graph path.
40    #[error(transparent)]
41    InvalidRepositoryPath(#[from] CoreError),
42    /// A persisted stable key digest did not match its canonical identity.
43    #[error("stable graph key digest does not match its canonical identity")]
44    InvalidStableKeyDigest,
45    /// A persisted canonical resolution-key domain was not supported.
46    #[error("unsupported canonical resolution-key domain")]
47    InvalidResolutionKeyDomain,
48    /// A portable canonical resolution identity was malformed or oversized.
49    #[error("invalid portable canonical resolution-key identity")]
50    InvalidResolutionKeyIdentity,
51    /// Two distinct canonical identities claimed the same compact key.
52    #[error("stable graph key collision for digest {digest}")]
53    StableKeyCollision {
54        /// Compact digest that mapped to conflicting canonical material.
55        digest: String,
56    },
57    /// A persisted entity key did not retain its project-qualified prefix.
58    #[error("stable entity key is not qualified by its declared project")]
59    ProjectQualificationMismatch,
60    /// A canonical resolution key and its graph owner belonged to different projects.
61    #[error("canonical resolution key belongs to a different project than its graph owner")]
62    ResolutionKeyOwnerMismatch,
63    /// A resolved relationship crossed project identity without federation.
64    #[error("resolved graph relation target belongs to another project")]
65    CrossProjectRelation,
66    /// Related graph records did not belong to one complete publication.
67    #[error("graph generation mismatch for {context}")]
68    GenerationMismatch {
69        /// Record relationship whose generations disagreed.
70        context: &'static str,
71    },
72    /// A derived graph record claimed the pre-publication zero generation.
73    #[error("graph records require a complete nonzero publication generation")]
74    InvalidGeneration,
75    /// A resolution retained a target or selector that did not identify one entity.
76    #[error("invalid graph relation resolution: {reason}")]
77    InvalidResolution {
78        /// Stable explanation suitable for diagnostics.
79        reason: &'static str,
80    },
81    /// A source span used an invalid line or ordering.
82    #[error("invalid source span: {reason}")]
83    InvalidSourceSpan {
84        /// Stable explanation suitable for diagnostics.
85        reason: &'static str,
86    },
87    /// Coverage state and counts disagreed.
88    #[error("invalid graph coverage: {reason}")]
89    InvalidCoverage {
90        /// Stable explanation suitable for diagnostics.
91        reason: &'static str,
92    },
93    /// A query budget was zero or exceeded the absolute product ceiling.
94    #[error("invalid graph limits: {reason}")]
95    InvalidLimits {
96        /// Stable explanation suitable for diagnostics.
97        reason: &'static str,
98    },
99}
100
101/// Parser-derived identity field that failed strict graph admission.
102#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
103#[serde(rename_all = "kebab-case")]
104pub enum GraphIdentityField {
105    /// Cargo package name.
106    Package,
107    /// Symbol name.
108    Symbol,
109    /// Containing symbol name.
110    Parent,
111    /// Symbol signature used by the entity selector.
112    Signature,
113    /// Relation source name.
114    #[serde(rename = "relation.source")]
115    RelationSource,
116    /// Relation target name.
117    #[serde(rename = "relation.target")]
118    RelationTarget,
119    /// Derived canonical resolution key component.
120    ResolutionKey,
121}
122
123impl fmt::Display for GraphIdentityField {
124    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125        formatter.write_str(match self {
126            Self::Package => "package",
127            Self::Symbol => "symbol",
128            Self::Parent => "parent",
129            Self::Signature => "signature",
130            Self::RelationSource => "relation.source",
131            Self::RelationTarget => "relation.target",
132            Self::ResolutionKey => "resolution-key",
133        })
134    }
135}
136
137/// Stable coarse category for a rejected parser-derived graph identity.
138#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
139#[serde(rename_all = "kebab-case")]
140pub enum GraphIdentityRejectionReason {
141    /// The identity was empty or whitespace-only.
142    Empty,
143    /// The identity had surrounding whitespace.
144    SurroundingWhitespace,
145    /// The identity contained a control character.
146    ControlCharacters,
147    /// The identity exceeded the graph identity byte bound.
148    Oversized,
149    /// The identity used the reserved derived-scope namespace.
150    ReservedNamespace,
151    /// A derived identity contract failed for another reason.
152    Contract,
153}
154
155impl GraphIdentityRejectionReason {
156    /// Classify one graph-contract failure without retaining rejected text.
157    #[must_use]
158    pub fn from_error(error: &GraphContractError) -> Self {
159        match error {
160            GraphContractError::InvalidIdentityText { reason }
161                if *reason == "identity text must not be empty" =>
162            {
163                Self::Empty
164            }
165            GraphContractError::InvalidIdentityText { reason }
166                if *reason == "identity text must not contain surrounding whitespace" =>
167            {
168                Self::SurroundingWhitespace
169            }
170            GraphContractError::InvalidIdentityText { reason }
171                if *reason == "identity text contains control characters" =>
172            {
173                Self::ControlCharacters
174            }
175            GraphContractError::InvalidIdentityText { reason }
176                if *reason == "identity text exceeds the byte limit" =>
177            {
178                Self::Oversized
179            }
180            GraphContractError::InvalidIdentityText { reason }
181                if *reason
182                    == "source symbol identity uses the reserved derived-scope namespace" =>
183            {
184                Self::ReservedNamespace
185            }
186            _ => Self::Contract,
187        }
188    }
189}
190
191impl fmt::Display for GraphIdentityRejectionReason {
192    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193        formatter.write_str(match self {
194            Self::Empty => "empty",
195            Self::SurroundingWhitespace => "surrounding-whitespace",
196            Self::ControlCharacters => "control-characters",
197            Self::Oversized => "oversized",
198            Self::ReservedNamespace => "reserved-namespace",
199            Self::Contract => "contract",
200        })
201    }
202}
203
204/// One bounded, generation-owned parser identity rejection.
205#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206pub struct GraphIdentityRejection {
207    /// Repository-relative source path containing the rejected fact.
208    pub path: RepositoryNodePath,
209    /// Exact bounded source span of the rejected fact.
210    pub span: SourceSpan,
211    /// Parser strategy that produced the rejected fact.
212    pub parser: ParserKind,
213    /// Identity field that failed admission.
214    pub field: GraphIdentityField,
215    /// Stable rejection category without the rejected raw value.
216    pub reason: GraphIdentityRejectionReason,
217    /// Internal parser-fact ordinal used to distinguish same-span facts.
218    ///
219    /// This identity is durable but intentionally omitted from the public wire
220    /// shape; it only prevents distinct parser observations that share a line
221    /// and zero-column fallback from collapsing in storage.
222    #[serde(skip)]
223    pub fact_index: u64,
224}
225
226/// Stable identity of one `ProjectAtlas` index across supported moves and upgrades.
227#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
228pub struct ProjectInstanceId([u8; 16]);
229
230impl ProjectInstanceId {
231    /// Construct a project identity from its durable 16-byte representation.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error for the all-zero sentinel.
236    pub fn from_bytes(bytes: [u8; 16]) -> Result<Self, GraphContractError> {
237        if bytes == [0; 16] {
238            return Err(GraphContractError::InvalidProjectInstanceId {
239                reason: "the zero identifier is reserved",
240            });
241        }
242        Ok(Self(bytes))
243    }
244
245    /// Return the durable binary representation.
246    #[must_use]
247    pub const fn as_bytes(self) -> [u8; 16] {
248        self.0
249    }
250
251    /// Return the canonical lowercase hexadecimal representation.
252    #[must_use]
253    pub fn as_hex(self) -> String {
254        encode_hex(&self.0)
255    }
256}
257
258impl fmt::Display for ProjectInstanceId {
259    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
260        formatter.write_str(&self.as_hex())
261    }
262}
263
264impl TryFrom<&str> for ProjectInstanceId {
265    type Error = GraphContractError;
266
267    fn try_from(value: &str) -> Result<Self, Self::Error> {
268        let compact = match value.len() {
269            32 => value.to_string(),
270            36 if value.as_bytes().get(8) == Some(&b'-')
271                && value.as_bytes().get(13) == Some(&b'-')
272                && value.as_bytes().get(18) == Some(&b'-')
273                && value.as_bytes().get(23) == Some(&b'-') =>
274            {
275                value
276                    .chars()
277                    .filter(|character| *character != '-')
278                    .collect()
279            }
280            _ => {
281                return Err(GraphContractError::InvalidProjectInstanceId {
282                    reason: "expected 32 hexadecimal digits or a hyphenated UUID",
283                });
284            }
285        };
286        if compact.len() != 32 {
287            return Err(GraphContractError::InvalidProjectInstanceId {
288                reason: "hyphenated identifier must contain exactly 32 hexadecimal digits",
289            });
290        }
291        let mut bytes = [0_u8; 16];
292        for (index, pair) in compact.as_bytes().as_chunks::<2>().0.iter().enumerate() {
293            let high = decode_hex(pair[0]).ok_or(GraphContractError::InvalidProjectInstanceId {
294                reason: "identifier contains a non-hexadecimal digit",
295            })?;
296            let low = decode_hex(pair[1]).ok_or(GraphContractError::InvalidProjectInstanceId {
297                reason: "identifier contains a non-hexadecimal digit",
298            })?;
299            bytes[index] = (high << 4) | low;
300        }
301        Self::from_bytes(bytes)
302    }
303}
304
305impl TryFrom<String> for ProjectInstanceId {
306    type Error = GraphContractError;
307
308    fn try_from(value: String) -> Result<Self, Self::Error> {
309        Self::try_from(value.as_str())
310    }
311}
312
313impl Serialize for ProjectInstanceId {
314    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
315    where
316        S: serde::Serializer,
317    {
318        serializer.serialize_str(&self.as_hex())
319    }
320}
321
322impl<'de> Deserialize<'de> for ProjectInstanceId {
323    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
324    where
325        D: serde::Deserializer<'de>,
326    {
327        let value = String::deserialize(deserializer)?;
328        Self::try_from(value).map_err(serde::de::Error::custom)
329    }
330}
331
332/// Validated non-empty, unpadded identity component used by graph selectors.
333#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
334#[serde(try_from = "String", into = "String")]
335pub struct GraphIdentityText(String);
336
337impl GraphIdentityText {
338    /// Validate one borrowed graph identity without allocating or changing it.
339    ///
340    /// # Errors
341    ///
342    /// Returns an error for blank, padded, oversized, or control-bearing text.
343    pub fn validate(value: &str) -> Result<(), GraphContractError> {
344        if value.trim().is_empty() {
345            return Err(GraphContractError::InvalidIdentityText {
346                reason: "identity text must not be empty",
347            });
348        }
349        if value.trim() != value {
350            return Err(GraphContractError::InvalidIdentityText {
351                reason: "identity text must not contain surrounding whitespace",
352            });
353        }
354        if value.len() > MAX_GRAPH_IDENTITY_BYTES {
355            return Err(GraphContractError::InvalidIdentityText {
356                reason: "identity text exceeds the byte limit",
357            });
358        }
359        if value.chars().any(char::is_control) {
360            return Err(GraphContractError::InvalidIdentityText {
361                reason: "identity text contains control characters",
362            });
363        }
364        Ok(())
365    }
366
367    /// Validate one graph identity component.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error for blank, padded, oversized, or control-bearing text.
372    pub fn new(value: impl Into<String>) -> Result<Self, GraphContractError> {
373        let value = value.into();
374        Self::validate(&value)?;
375        Ok(Self(value))
376    }
377
378    /// Borrow the validated value.
379    #[must_use]
380    pub fn as_str(&self) -> &str {
381        &self.0
382    }
383}
384
385impl fmt::Display for GraphIdentityText {
386    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
387        formatter.write_str(self.as_str())
388    }
389}
390
391impl TryFrom<String> for GraphIdentityText {
392    type Error = GraphContractError;
393
394    fn try_from(value: String) -> Result<Self, Self::Error> {
395        Self::new(value)
396    }
397}
398
399impl TryFrom<&str> for GraphIdentityText {
400    type Error = GraphContractError;
401
402    fn try_from(value: &str) -> Result<Self, Self::Error> {
403        Self::new(value)
404    }
405}
406
407impl From<GraphIdentityText> for String {
408    fn from(value: GraphIdentityText) -> Self {
409        value.0
410    }
411}
412
413/// Normalized repository-relative node path, including the project root `.`.
414#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
415#[serde(try_from = "String", into = "String")]
416pub struct RepositoryNodePath(String);
417
418impl RepositoryNodePath {
419    /// Validate and normalize a repository node path through the shared validator.
420    ///
421    /// # Errors
422    ///
423    /// Returns an error for absolute, parent-traversing, empty, or non-UTF-8 paths.
424    pub fn new(path: &Path) -> Result<Self, GraphContractError> {
425        Ok(Self(validated_repo_node_key(path)?))
426    }
427
428    /// Borrow the normalized slash-separated path.
429    #[must_use]
430    pub fn as_str(&self) -> &str {
431        &self.0
432    }
433}
434
435impl TryFrom<String> for RepositoryNodePath {
436    type Error = GraphContractError;
437
438    fn try_from(value: String) -> Result<Self, Self::Error> {
439        Self::new(Path::new(&value))
440    }
441}
442
443impl From<RepositoryNodePath> for String {
444    fn from(value: RepositoryNodePath) -> Self {
445        value.0
446    }
447}
448
449/// Normalized repository-relative file path.
450#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
451#[serde(try_from = "String", into = "String")]
452pub struct RepositoryFilePath(String);
453
454impl RepositoryFilePath {
455    /// Validate and normalize a repository file path through the shared validator.
456    ///
457    /// # Errors
458    ///
459    /// Returns an error for root, absolute, parent-traversing, empty, or non-UTF-8 paths.
460    pub fn new(path: &Path) -> Result<Self, GraphContractError> {
461        Ok(Self(validated_repo_file_key(path)?))
462    }
463
464    /// Borrow the normalized slash-separated path.
465    #[must_use]
466    pub fn as_str(&self) -> &str {
467        &self.0
468    }
469}
470
471impl TryFrom<String> for RepositoryFilePath {
472    type Error = GraphContractError;
473
474    fn try_from(value: String) -> Result<Self, Self::Error> {
475        Self::new(Path::new(&value))
476    }
477}
478
479impl From<RepositoryFilePath> for String {
480    fn from(value: RepositoryFilePath) -> Self {
481        value.0
482    }
483}
484
485/// Package identity scoped by manager, name, and owning manifest.
486#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
487pub struct PackageSelector {
488    /// Package ecosystem or manifest family.
489    pub manager: GraphIdentityText,
490    /// Package name as declared by its manifest.
491    pub name: GraphIdentityText,
492    /// Repository-local manifest that owns the package.
493    pub manifest: RepositoryFilePath,
494}
495
496/// Declaration identity stable across source line movement.
497#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
498pub struct SymbolSelector {
499    /// Repository-local source file containing the declaration.
500    pub file: RepositoryFilePath,
501    /// Declaration name.
502    pub name: GraphIdentityText,
503    /// Existing `ProjectAtlas` symbol kind.
504    pub kind: SymbolKind,
505    /// Optional containing symbol or namespace.
506    pub parent: Option<GraphIdentityText>,
507    /// Normalized declaration signature that distinguishes overloads.
508    pub signature: GraphIdentityText,
509}
510
511/// External identity retained without fabricating a local target.
512#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
513pub struct ExternalSelector {
514    /// External namespace such as a package ecosystem or protocol.
515    pub system: GraphIdentityText,
516    /// Identity within the external namespace.
517    pub identity: GraphIdentityText,
518}
519
520/// Closed entity selectors owned by the repository graph domain.
521#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
522#[serde(tag = "kind", rename_all = "snake_case")]
523pub enum EntitySelector {
524    /// The selected project instance itself.
525    Project,
526    /// One repository folder.
527    Folder {
528        /// Normalized repository-relative folder path.
529        path: RepositoryNodePath,
530    },
531    /// One repository file.
532    File {
533        /// Normalized repository-relative file path.
534        path: RepositoryFilePath,
535    },
536    /// One package owned by a manifest.
537    Package {
538        /// Typed package identity.
539        package: PackageSelector,
540    },
541    /// One declaration stable across line movement.
542    Symbol {
543        /// Typed declaration identity.
544        symbol: SymbolSelector,
545    },
546    /// One target outside the selected local project.
547    External {
548        /// Typed external identity.
549        external: ExternalSelector,
550    },
551}
552
553/// Persisted digest plus the canonical material needed to detect collisions.
554#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
555#[serde(try_from = "StableKeyWire", into = "StableKeyWire")]
556struct StableKey {
557    /// Lowercase BLAKE3 digest.
558    digest: String,
559    /// Unambiguous canonical identity that produced the digest.
560    canonical_identity: String,
561}
562
563impl StableKey {
564    /// Derive a compact key while retaining its canonical collision witness.
565    fn new(canonical_identity: String) -> Self {
566        let digest = blake3::hash(canonical_identity.as_bytes())
567            .to_hex()
568            .as_str()
569            .to_owned();
570        Self {
571            digest,
572            canonical_identity,
573        }
574    }
575
576    /// Validate persisted key material.
577    fn from_persisted(
578        digest: &str,
579        canonical_identity: String,
580    ) -> Result<Self, GraphContractError> {
581        let key = Self::new(canonical_identity);
582        if key.digest != digest {
583            return Err(GraphContractError::InvalidStableKeyDigest);
584        }
585        Ok(key)
586    }
587
588    /// Compare compact keys without silently accepting a digest collision.
589    fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
590        if self.digest != other.digest {
591            return Ok(false);
592        }
593        if self.canonical_identity != other.canonical_identity {
594            return Err(GraphContractError::StableKeyCollision {
595                digest: self.digest.clone(),
596            });
597        }
598        Ok(true)
599    }
600
601    /// Decode the validated digest into its compact binary representation.
602    fn digest_bytes(&self) -> Result<[u8; 32], GraphContractError> {
603        let Ok(digest) = blake3::Hash::from_hex(&self.digest) else {
604            return Err(GraphContractError::InvalidStableKeyDigest);
605        };
606        Ok(*digest.as_bytes())
607    }
608}
609
610/// Serializable stable-key representation with validation on input.
611#[derive(Deserialize, Serialize)]
612struct StableKeyWire {
613    /// Lowercase BLAKE3 digest.
614    digest: String,
615    /// Canonical collision witness.
616    canonical_identity: String,
617}
618
619impl TryFrom<StableKeyWire> for StableKey {
620    type Error = GraphContractError;
621
622    fn try_from(value: StableKeyWire) -> Result<Self, Self::Error> {
623        Self::from_persisted(&value.digest, value.canonical_identity)
624    }
625}
626
627impl From<StableKey> for StableKeyWire {
628    fn from(value: StableKey) -> Self {
629        Self {
630            digest: value.digest,
631            canonical_identity: value.canonical_identity,
632        }
633    }
634}
635
636/// Stable project-qualified key for one graph entity.
637#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
638#[serde(try_from = "GraphEntityKeyWire", into = "GraphEntityKeyWire")]
639pub struct GraphEntityKey {
640    /// Project instance that owns this key.
641    project: ProjectInstanceId,
642    /// Compact digest and retained canonical identity.
643    stable: StableKey,
644}
645
646impl GraphEntityKey {
647    /// Derive a stable key from a project and typed selector.
648    #[must_use]
649    pub fn new(project: ProjectInstanceId, selector: &EntitySelector) -> Self {
650        let canonical_identity = entity_canonical_identity(project, selector);
651        Self {
652            project,
653            stable: StableKey::new(canonical_identity),
654        }
655    }
656
657    /// Return the owning project instance.
658    #[must_use]
659    pub const fn project(&self) -> ProjectInstanceId {
660        self.project
661    }
662
663    /// Borrow the lowercase compact digest.
664    #[must_use]
665    pub fn digest(&self) -> &str {
666        &self.stable.digest
667    }
668
669    /// Return the compact binary digest used by normalized persistence.
670    ///
671    /// # Errors
672    ///
673    /// Returns [`GraphContractError::InvalidStableKeyDigest`] if the private key
674    /// invariant was violated by incompatible persisted input.
675    pub fn digest_bytes(&self) -> Result<[u8; 32], GraphContractError> {
676        self.stable.digest_bytes()
677    }
678
679    /// Borrow the canonical identity retained for collision detection.
680    #[must_use]
681    pub fn canonical_identity(&self) -> &str {
682        &self.stable.canonical_identity
683    }
684
685    /// Determine whether two keys identify the same entity, failing on collision.
686    ///
687    /// # Errors
688    ///
689    /// Returns [`GraphContractError::StableKeyCollision`] when equal digests retain
690    /// different canonical identities.
691    pub fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
692        if self.project != other.project {
693            return Ok(false);
694        }
695        self.stable.reconcile(&other.stable)
696    }
697}
698
699/// Validated serialized entity-key representation.
700#[derive(Deserialize, Serialize)]
701struct GraphEntityKeyWire {
702    /// Owning project instance.
703    project: ProjectInstanceId,
704    /// Stable key material.
705    stable: StableKey,
706}
707
708impl TryFrom<GraphEntityKeyWire> for GraphEntityKey {
709    type Error = GraphContractError;
710
711    fn try_from(value: GraphEntityKeyWire) -> Result<Self, Self::Error> {
712        let prefix = entity_project_prefix(value.project);
713        if !has_canonical_prefix(&value.stable.canonical_identity, &prefix) {
714            return Err(GraphContractError::ProjectQualificationMismatch);
715        }
716        Ok(Self {
717            project: value.project,
718            stable: value.stable,
719        })
720    }
721}
722
723impl From<GraphEntityKey> for GraphEntityKeyWire {
724    fn from(value: GraphEntityKey) -> Self {
725        Self {
726            project: value.project,
727            stable: value.stable,
728        }
729    }
730}
731
732/// One typed graph entity at a complete index generation.
733#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
734pub struct GraphEntity {
735    /// Stable project-qualified key.
736    key: GraphEntityKey,
737    /// Typed identity material used to derive the key.
738    selector: EntitySelector,
739    /// Complete generation containing this entity.
740    generation: IndexGeneration,
741}
742
743impl GraphEntity {
744    /// Construct an entity and derive its stable key.
745    ///
746    /// # Errors
747    ///
748    /// Returns an error for the pre-publication zero generation.
749    pub fn new(
750        project: ProjectInstanceId,
751        selector: EntitySelector,
752        generation: IndexGeneration,
753    ) -> Result<Self, GraphContractError> {
754        if generation == IndexGeneration::ZERO {
755            return Err(GraphContractError::InvalidGeneration);
756        }
757        let key = GraphEntityKey::new(project, &selector);
758        Ok(Self {
759            key,
760            selector,
761            generation,
762        })
763    }
764
765    /// Borrow the stable project-qualified key.
766    #[must_use]
767    pub const fn key(&self) -> &GraphEntityKey {
768        &self.key
769    }
770
771    /// Borrow the typed identity selector.
772    #[must_use]
773    pub const fn selector(&self) -> &EntitySelector {
774        &self.selector
775    }
776
777    /// Return the complete generation containing this entity.
778    #[must_use]
779    pub const fn generation(&self) -> IndexGeneration {
780        self.generation
781    }
782}
783
784/// Additive graph-only relation families.
785#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
786#[serde(rename_all = "snake_case")]
787pub enum ExtendedRelationKind {
788    /// A source references a declaration or resource.
789    References,
790    /// Documentation explicitly describes a validated repository target.
791    Documents,
792    /// A test exercises a source target.
793    Tests,
794    /// A route or protocol entry reaches a handler.
795    RoutesTo,
796    /// Configuration selects or controls a target.
797    Configures,
798    /// Deployment or infrastructure configuration provisions a target.
799    Deploys,
800    /// A source performs a bounded static read.
801    Reads,
802    /// A source performs a bounded static write.
803    Writes,
804}
805
806/// Relation family that preserves the existing exhaustive legacy enum.
807#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
808#[serde(tag = "scope", content = "value", rename_all = "snake_case")]
809pub enum GraphRelationKind {
810    /// Existing `ProjectAtlas` relation value and payload spelling.
811    Legacy(RelationKind),
812    /// Additive repository-wide graph relation.
813    Extended(ExtendedRelationKind),
814}
815
816impl GraphRelationKind {
817    /// Complete stable persisted relation-kind set.
818    pub const ALL: [Self; 12] = [
819        Self::Legacy(RelationKind::Contains),
820        Self::Legacy(RelationKind::Imports),
821        Self::Legacy(RelationKind::Calls),
822        Self::Legacy(RelationKind::DependsOn),
823        Self::Extended(ExtendedRelationKind::References),
824        Self::Extended(ExtendedRelationKind::Documents),
825        Self::Extended(ExtendedRelationKind::Tests),
826        Self::Extended(ExtendedRelationKind::RoutesTo),
827        Self::Extended(ExtendedRelationKind::Configures),
828        Self::Extended(ExtendedRelationKind::Deploys),
829        Self::Extended(ExtendedRelationKind::Reads),
830        Self::Extended(ExtendedRelationKind::Writes),
831    ];
832
833    /// Wrap one legacy relation without changing its enum.
834    #[must_use]
835    pub const fn from_legacy(kind: RelationKind) -> Self {
836        Self::Legacy(kind)
837    }
838
839    /// Return the old projection when this is an existing relation family.
840    #[must_use]
841    pub const fn legacy_kind(self) -> Option<RelationKind> {
842        match self {
843            Self::Legacy(kind) => Some(kind),
844            Self::Extended(_) => None,
845        }
846    }
847
848    /// Return the stable canonical relation spelling.
849    #[must_use]
850    pub const fn as_str(self) -> &'static str {
851        match self {
852            Self::Legacy(RelationKind::Contains) => "legacy:contains",
853            Self::Legacy(RelationKind::Imports) => "legacy:imports",
854            Self::Legacy(RelationKind::Calls) => "legacy:calls",
855            Self::Legacy(RelationKind::DependsOn) => "legacy:depends-on",
856            Self::Extended(ExtendedRelationKind::References) => "extended:references",
857            Self::Extended(ExtendedRelationKind::Documents) => "extended:documents",
858            Self::Extended(ExtendedRelationKind::Tests) => "extended:tests",
859            Self::Extended(ExtendedRelationKind::RoutesTo) => "extended:routes-to",
860            Self::Extended(ExtendedRelationKind::Configures) => "extended:configures",
861            Self::Extended(ExtendedRelationKind::Deploys) => "extended:deploys",
862            Self::Extended(ExtendedRelationKind::Reads) => "extended:reads",
863            Self::Extended(ExtendedRelationKind::Writes) => "extended:writes",
864        }
865    }
866}
867
868/// Closed reason that an explicit document target did not resolve.
869#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
870#[serde(rename_all = "snake_case")]
871pub enum DocumentTargetUnresolvedReason {
872    /// No indexed target exists for the normalized selector.
873    Missing,
874    /// Effective ignore or admission policy excludes the target.
875    Ignored,
876    /// Normalization or canonicalization would leave the selected root.
877    OutsideRoot,
878    /// Exact-case identity is absent or conflicts under case folding.
879    CaseConflict,
880    /// The candidate names an unsupported target kind or syntax.
881    Unsupported,
882    /// No complete static repository selector could be extracted.
883    NoStaticTarget,
884}
885
886impl DocumentTargetUnresolvedReason {
887    /// Complete stable persisted reason set.
888    pub const ALL: [Self; 6] = [
889        Self::Missing,
890        Self::Ignored,
891        Self::OutsideRoot,
892        Self::CaseConflict,
893        Self::Unsupported,
894        Self::NoStaticTarget,
895    ];
896
897    /// Return the stable database and payload spelling.
898    #[must_use]
899    pub const fn as_str(self) -> &'static str {
900        match self {
901            Self::Missing => "missing",
902            Self::Ignored => "ignored",
903            Self::OutsideRoot => "outside_root",
904            Self::CaseConflict => "case_conflict",
905            Self::Unsupported => "unsupported",
906            Self::NoStaticTarget => "no_static_target",
907        }
908    }
909
910    /// Parse one stable persisted reason.
911    #[must_use]
912    pub fn from_db(value: &str) -> Option<Self> {
913        match value {
914            "missing" => Some(Self::Missing),
915            "ignored" => Some(Self::Ignored),
916            "outside_root" => Some(Self::OutsideRoot),
917            "case_conflict" => Some(Self::CaseConflict),
918            "unsupported" => Some(Self::Unsupported),
919            "no_static_target" => Some(Self::NoStaticTarget),
920            _ => None,
921        }
922    }
923}
924
925impl fmt::Display for DocumentTargetUnresolvedReason {
926    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
927        formatter.write_str(self.as_str())
928    }
929}
930
931/// One exact source range using one-based lines and zero-based columns.
932#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
933#[serde(try_from = "SourceSpanWire", into = "SourceSpanWire")]
934pub struct SourceSpan {
935    /// First one-based source line.
936    start_line: u32,
937    /// First zero-based source column.
938    start_column: u32,
939    /// Last one-based source line.
940    end_line: u32,
941    /// Exclusive zero-based end column.
942    end_column: u32,
943}
944
945impl SourceSpan {
946    /// Validate a non-reversed source range.
947    ///
948    /// # Errors
949    ///
950    /// Returns an error for zero lines or an end before the start.
951    pub fn new(
952        start_line: u32,
953        start_column: u32,
954        end_line: u32,
955        end_column: u32,
956    ) -> Result<Self, GraphContractError> {
957        if start_line == 0 || end_line == 0 {
958            return Err(GraphContractError::InvalidSourceSpan {
959                reason: "source lines are one-based",
960            });
961        }
962        if (end_line, end_column) < (start_line, start_column) {
963            return Err(GraphContractError::InvalidSourceSpan {
964                reason: "source span end precedes its start",
965            });
966        }
967        Ok(Self {
968            start_line,
969            start_column,
970            end_line,
971            end_column,
972        })
973    }
974
975    /// Return the first one-based line.
976    #[must_use]
977    pub const fn start_line(self) -> u32 {
978        self.start_line
979    }
980
981    /// Return the first zero-based column.
982    #[must_use]
983    pub const fn start_column(self) -> u32 {
984        self.start_column
985    }
986
987    /// Return the last one-based line.
988    #[must_use]
989    pub const fn end_line(self) -> u32 {
990        self.end_line
991    }
992
993    /// Return the exclusive zero-based end column.
994    #[must_use]
995    pub const fn end_column(self) -> u32 {
996        self.end_column
997    }
998}
999
1000/// Source-span wire shape validated during deserialization.
1001#[derive(Deserialize, Serialize)]
1002struct SourceSpanWire {
1003    /// First one-based line.
1004    start_line: u32,
1005    /// First zero-based column.
1006    start_column: u32,
1007    /// Last one-based line.
1008    end_line: u32,
1009    /// Exclusive zero-based end column.
1010    end_column: u32,
1011}
1012
1013impl TryFrom<SourceSpanWire> for SourceSpan {
1014    type Error = GraphContractError;
1015
1016    fn try_from(value: SourceSpanWire) -> Result<Self, Self::Error> {
1017        Self::new(
1018            value.start_line,
1019            value.start_column,
1020            value.end_line,
1021            value.end_column,
1022        )
1023    }
1024}
1025
1026impl From<SourceSpan> for SourceSpanWire {
1027    fn from(value: SourceSpan) -> Self {
1028        Self {
1029            start_line: value.start_line,
1030            start_column: value.start_column,
1031            end_line: value.end_line,
1032            end_column: value.end_column,
1033        }
1034    }
1035}
1036
1037/// Exact local selectors reusable by summary, relation, and source-slice adapters.
1038#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1039#[serde(tag = "kind", rename_all = "snake_case")]
1040pub enum ReusableTargetSelector {
1041    /// Select one repository folder.
1042    Folder {
1043        /// Exact normalized folder path.
1044        folder: RepositoryNodePath,
1045    },
1046    /// Select one repository file.
1047    File {
1048        /// Exact normalized file path.
1049        file: RepositoryFilePath,
1050    },
1051    /// Select one package through its exact manifest-owned identity.
1052    Package {
1053        /// Exact package identity whose manifest is directly reusable by file calls.
1054        package: PackageSelector,
1055    },
1056    /// Select one declaration without depending on its current line.
1057    Symbol {
1058        /// Stable declaration identity.
1059        symbol: SymbolSelector,
1060    },
1061}
1062
1063impl ReusableTargetSelector {
1064    /// Derive the exact reusable selector for one navigable local entity.
1065    fn for_entity(target: &GraphEntity) -> Result<Self, GraphContractError> {
1066        match &target.selector {
1067            EntitySelector::Project => Err(GraphContractError::InvalidResolution {
1068                reason: "the project aggregate is not a direct source target",
1069            }),
1070            EntitySelector::Folder { path } => Ok(Self::Folder {
1071                folder: path.clone(),
1072            }),
1073            EntitySelector::File { path } => Ok(Self::File { file: path.clone() }),
1074            EntitySelector::Package { package } => Ok(Self::Package {
1075                package: package.clone(),
1076            }),
1077            EntitySelector::Symbol { symbol } => Ok(Self::Symbol {
1078                symbol: symbol.clone(),
1079            }),
1080            EntitySelector::External { .. } => Err(GraphContractError::InvalidResolution {
1081                reason: "external entities do not have a local reusable selector",
1082            }),
1083        }
1084    }
1085
1086    /// Return the entity identity selected by this navigation target.
1087    fn entity_selector(&self) -> EntitySelector {
1088        match self {
1089            Self::Folder { folder } => EntitySelector::Folder {
1090                path: folder.clone(),
1091            },
1092            Self::File { file } => EntitySelector::File { path: file.clone() },
1093            Self::Package { package } => EntitySelector::Package {
1094                package: package.clone(),
1095            },
1096            Self::Symbol { symbol } => EntitySelector::Symbol {
1097                symbol: symbol.clone(),
1098            },
1099        }
1100    }
1101}
1102
1103/// Relationship resolution state and optional exact local jump target.
1104#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1105#[serde(tag = "status", rename_all = "snake_case")]
1106pub enum RelationResolution {
1107    /// Exactly one local entity was resolved.
1108    Resolved {
1109        /// Stable target entity.
1110        target: GraphEntityKey,
1111        /// Exact selector accepted by a later summary, relation, or slice adapter.
1112        selector: ReusableTargetSelector,
1113        /// Complete generation containing the resolved target.
1114        generation: IndexGeneration,
1115    },
1116    /// More than one statically valid target remains.
1117    Ambiguous {
1118        /// Original normalized reference text.
1119        reference: GraphIdentityText,
1120        /// Number of retained valid candidates before result limits.
1121        candidates: NonZeroU32,
1122    },
1123    /// No supported static target was found.
1124    Unresolved {
1125        /// Original normalized reference text.
1126        reference: GraphIdentityText,
1127    },
1128    /// The target is intentionally outside the selected local project.
1129    External {
1130        /// Project-qualified external entity key.
1131        target: GraphEntityKey,
1132        /// Typed external identity retained for fail-closed validation.
1133        external: ExternalSelector,
1134        /// Complete generation containing the external entity record.
1135        generation: IndexGeneration,
1136    },
1137}
1138
1139impl RelationResolution {
1140    /// Construct one exact local resolution from a graph entity.
1141    ///
1142    /// # Errors
1143    ///
1144    /// Returns an error when the target is not a navigable local entity.
1145    pub fn resolved(target: &GraphEntity) -> Result<Self, GraphContractError> {
1146        Ok(Self::Resolved {
1147            target: target.key.clone(),
1148            selector: ReusableTargetSelector::for_entity(target)?,
1149            generation: target.generation,
1150        })
1151    }
1152
1153    /// Construct one external resolution from a graph entity record.
1154    ///
1155    /// # Errors
1156    ///
1157    /// Returns an error when the target is not an external entity record.
1158    pub fn external(target: &GraphEntity) -> Result<Self, GraphContractError> {
1159        let EntitySelector::External { external } = &target.selector else {
1160            return Err(GraphContractError::InvalidResolution {
1161                reason: "external resolution requires an external entity",
1162            });
1163        };
1164        Ok(Self::External {
1165            target: target.key.clone(),
1166            external: external.clone(),
1167            generation: target.generation,
1168        })
1169    }
1170
1171    /// Return the exact traversable local target, if one exists.
1172    #[must_use]
1173    pub const fn resolved_target(&self) -> Option<&GraphEntityKey> {
1174        match self {
1175            Self::Resolved { target, .. } => Some(target),
1176            Self::Ambiguous { .. } | Self::Unresolved { .. } | Self::External { .. } => None,
1177        }
1178    }
1179
1180    /// Return any project-qualified target key retained by the resolution.
1181    const fn target_key(&self) -> Option<&GraphEntityKey> {
1182        match self {
1183            Self::Resolved { target, .. } | Self::External { target, .. } => Some(target),
1184            Self::Ambiguous { .. } | Self::Unresolved { .. } => None,
1185        }
1186    }
1187
1188    /// Return the generation of a retained target entity, when one exists.
1189    const fn target_generation(&self) -> Option<IndexGeneration> {
1190        match self {
1191            Self::Resolved { generation, .. } | Self::External { generation, .. } => {
1192                Some(*generation)
1193            }
1194            Self::Ambiguous { .. } | Self::Unresolved { .. } => None,
1195        }
1196    }
1197
1198    /// Append stable resolution material to a canonical relation identity.
1199    fn append_canonical(&self, canonical: &mut String) {
1200        match self {
1201            Self::Resolved { target, .. } => {
1202                append_canonical_field(canonical, "resolved");
1203                append_canonical_field(canonical, target.canonical_identity());
1204            }
1205            Self::Ambiguous { reference, .. } => {
1206                append_canonical_field(canonical, "ambiguous");
1207                append_canonical_field(canonical, reference.as_str());
1208            }
1209            Self::Unresolved { reference } => {
1210                append_canonical_field(canonical, "unresolved");
1211                append_canonical_field(canonical, reference.as_str());
1212            }
1213            Self::External { target, .. } => {
1214                append_canonical_field(canonical, "external");
1215                append_canonical_field(canonical, target.canonical_identity());
1216            }
1217        }
1218    }
1219}
1220
1221/// Coarse confidence that avoids unsupported numeric precision.
1222#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1223#[serde(rename_all = "snake_case")]
1224pub enum ConfidenceClass {
1225    /// Direct language or manifest semantics establish the fact.
1226    Exact,
1227    /// Strong deterministic evidence supports the fact.
1228    High,
1229    /// Multiple conservative signals support the fact.
1230    Medium,
1231    /// The fact is useful only as a weak candidate.
1232    Low,
1233}
1234
1235/// Whether the producer observed all supported facts for its bounded scope.
1236#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1237#[serde(rename_all = "snake_case")]
1238pub enum Completeness {
1239    /// Every supported fact in the scope was considered.
1240    Complete,
1241    /// A declared limit or unsupported region omitted some facts.
1242    Partial,
1243}
1244
1245/// Stable project-qualified key for a deduplicated logical relation.
1246#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1247#[serde(try_from = "LogicalRelationKeyWire", into = "LogicalRelationKeyWire")]
1248pub struct LogicalRelationKey {
1249    /// Project instance that owns this relation.
1250    project: ProjectInstanceId,
1251    /// Compact digest and retained canonical identity.
1252    stable: StableKey,
1253}
1254
1255/// Validated serialized logical-relation key representation.
1256#[derive(Deserialize, Serialize)]
1257struct LogicalRelationKeyWire {
1258    /// Owning project instance.
1259    project: ProjectInstanceId,
1260    /// Stable relation-key material.
1261    stable: StableKey,
1262}
1263
1264impl TryFrom<LogicalRelationKeyWire> for LogicalRelationKey {
1265    type Error = GraphContractError;
1266
1267    fn try_from(value: LogicalRelationKeyWire) -> Result<Self, Self::Error> {
1268        let prefix = relation_project_prefix(value.project);
1269        if !has_canonical_prefix(&value.stable.canonical_identity, &prefix) {
1270            return Err(GraphContractError::ProjectQualificationMismatch);
1271        }
1272        Ok(Self {
1273            project: value.project,
1274            stable: value.stable,
1275        })
1276    }
1277}
1278
1279impl From<LogicalRelationKey> for LogicalRelationKeyWire {
1280    fn from(value: LogicalRelationKey) -> Self {
1281        Self {
1282            project: value.project,
1283            stable: value.stable,
1284        }
1285    }
1286}
1287
1288impl LogicalRelationKey {
1289    /// Return the owning project instance.
1290    #[must_use]
1291    pub const fn project(&self) -> ProjectInstanceId {
1292        self.project
1293    }
1294
1295    /// Borrow the lowercase compact digest.
1296    #[must_use]
1297    pub fn digest(&self) -> &str {
1298        &self.stable.digest
1299    }
1300
1301    /// Return the compact binary digest used by normalized persistence.
1302    ///
1303    /// # Errors
1304    ///
1305    /// Returns [`GraphContractError::InvalidStableKeyDigest`] if the private key
1306    /// invariant was violated by incompatible persisted input.
1307    pub fn digest_bytes(&self) -> Result<[u8; 32], GraphContractError> {
1308        self.stable.digest_bytes()
1309    }
1310
1311    /// Borrow the canonical identity retained for collision detection.
1312    #[must_use]
1313    pub fn canonical_identity(&self) -> &str {
1314        &self.stable.canonical_identity
1315    }
1316
1317    /// Determine whether two keys identify the same logical relation.
1318    ///
1319    /// # Errors
1320    ///
1321    /// Returns [`GraphContractError::StableKeyCollision`] for conflicting
1322    /// canonical identities with the same digest.
1323    pub fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
1324        if self.project != other.project {
1325            return Ok(false);
1326        }
1327        self.stable.reconcile(&other.stable)
1328    }
1329}
1330
1331/// Closed target-identity families used by canonical resolution keys.
1332#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
1333#[serde(rename_all = "snake_case")]
1334pub enum ResolutionKeyDomain {
1335    /// A declaration, value, type, or other named source symbol.
1336    Declaration,
1337    /// A source module, namespace, or importable file identity.
1338    Module,
1339    /// A package or manifest dependency identity.
1340    Package,
1341}
1342
1343impl ResolutionKeyDomain {
1344    /// Return the stable `SQLite` and wire representation.
1345    #[must_use]
1346    pub const fn as_str(self) -> &'static str {
1347        match self {
1348            Self::Declaration => "declaration",
1349            Self::Module => "module",
1350            Self::Package => "package",
1351        }
1352    }
1353}
1354
1355impl TryFrom<&str> for ResolutionKeyDomain {
1356    type Error = GraphContractError;
1357
1358    fn try_from(value: &str) -> Result<Self, Self::Error> {
1359        match value {
1360            "declaration" => Ok(Self::Declaration),
1361            "module" => Ok(Self::Module),
1362            "package" => Ok(Self::Package),
1363            _ => Err(GraphContractError::InvalidResolutionKeyDomain),
1364        }
1365    }
1366}
1367
1368/// Project-qualified canonical identity used for export and dependency resolution.
1369///
1370/// The fixed digest is the indexed hot-path value. The canonical identity remains
1371/// alongside it as the collision witness and includes every identity-affecting
1372/// provider, language, package, scope, relation-family, and target field.
1373#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
1374#[serde(
1375    try_from = "CanonicalResolutionKeyWire",
1376    into = "CanonicalResolutionKeyWire"
1377)]
1378pub struct CanonicalResolutionKey {
1379    /// Project instance whose resolver namespace owns this key.
1380    project: ProjectInstanceId,
1381    /// Closed resolver family.
1382    domain: ResolutionKeyDomain,
1383    /// Fixed compact key used by `SQLite` indexes.
1384    digest: [u8; 32],
1385    /// Canonical collision witness.
1386    canonical_identity: String,
1387}
1388
1389impl CanonicalResolutionKey {
1390    /// Construct a deterministic canonical resolution key.
1391    #[allow(clippy::too_many_arguments)]
1392    #[must_use]
1393    pub fn new(
1394        project: ProjectInstanceId,
1395        domain: ResolutionKeyDomain,
1396        provider: &GraphIdentityText,
1397        language: &GraphIdentityText,
1398        package: Option<&GraphIdentityText>,
1399        scope: Option<&GraphIdentityText>,
1400        relation: Option<GraphRelationKind>,
1401        identity: &GraphIdentityText,
1402    ) -> Self {
1403        let mut canonical_identity = resolution_project_prefix(project, domain);
1404        append_canonical_field(&mut canonical_identity, provider.as_str());
1405        append_canonical_field(&mut canonical_identity, language.as_str());
1406        append_optional_canonical_field(&mut canonical_identity, package);
1407        append_optional_canonical_field(&mut canonical_identity, scope);
1408        append_optional_raw_canonical_field(
1409            &mut canonical_identity,
1410            relation.map(GraphRelationKind::as_str),
1411        );
1412        append_canonical_field(&mut canonical_identity, identity.as_str());
1413        let digest = *blake3::hash(canonical_identity.as_bytes()).as_bytes();
1414        Self {
1415            project,
1416            domain,
1417            digest,
1418            canonical_identity,
1419        }
1420    }
1421
1422    /// Reconstruct and validate persisted canonical key material.
1423    ///
1424    /// # Errors
1425    ///
1426    /// Returns an error when the witness is not qualified by the declared project
1427    /// and domain or its digest does not match.
1428    pub fn from_persisted(
1429        project: ProjectInstanceId,
1430        domain: ResolutionKeyDomain,
1431        digest: [u8; 32],
1432        canonical_identity: String,
1433    ) -> Result<Self, GraphContractError> {
1434        let prefix = resolution_project_prefix(project, domain);
1435        if !has_canonical_prefix(&canonical_identity, &prefix) {
1436            return Err(GraphContractError::ProjectQualificationMismatch);
1437        }
1438        if *blake3::hash(canonical_identity.as_bytes()).as_bytes() != digest {
1439            return Err(GraphContractError::InvalidStableKeyDigest);
1440        }
1441        Ok(Self {
1442            project,
1443            domain,
1444            digest,
1445            canonical_identity,
1446        })
1447    }
1448
1449    /// Return the owning project instance.
1450    #[must_use]
1451    pub const fn project(&self) -> ProjectInstanceId {
1452        self.project
1453    }
1454
1455    /// Return the closed resolver domain.
1456    #[must_use]
1457    pub const fn domain(&self) -> ResolutionKeyDomain {
1458        self.domain
1459    }
1460
1461    /// Return the fixed compact digest used by normalized persistence.
1462    #[must_use]
1463    pub const fn digest_bytes(&self) -> [u8; 32] {
1464        self.digest
1465    }
1466
1467    /// Borrow the canonical collision witness.
1468    #[must_use]
1469    pub fn canonical_identity(&self) -> &str {
1470        &self.canonical_identity
1471    }
1472
1473    /// Return the allocated collision-witness bytes owned by this key.
1474    ///
1475    /// This includes spare string capacity and excludes the inline key value,
1476    /// whose storage belongs to its owning vector or map.
1477    #[must_use]
1478    pub const fn retained_heap_bytes(&self) -> u64 {
1479        self.canonical_identity.capacity() as u64
1480    }
1481
1482    /// Remove project qualification while retaining the exact canonical
1483    /// resolver material needed to rebind a derived graph snapshot.
1484    ///
1485    /// # Errors
1486    ///
1487    /// Returns an error if the private canonical-key invariant was violated.
1488    pub fn portable(&self) -> Result<PortableResolutionKey, GraphContractError> {
1489        let prefix = resolution_project_prefix(self.project, self.domain);
1490        let Some(canonical_identity) = self.canonical_identity.strip_prefix(&prefix) else {
1491            return Err(GraphContractError::ProjectQualificationMismatch);
1492        };
1493        PortableResolutionKey::new(self.domain, canonical_identity.to_string())
1494    }
1495
1496    /// Compare compact keys without silently accepting a digest collision.
1497    ///
1498    /// # Errors
1499    ///
1500    /// Returns [`GraphContractError::StableKeyCollision`] when equal compact keys
1501    /// retain different canonical material.
1502    pub fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
1503        if self.project != other.project || self.domain != other.domain {
1504            return Ok(false);
1505        }
1506        if self.digest != other.digest {
1507            return Ok(false);
1508        }
1509        if self.canonical_identity != other.canonical_identity {
1510            return Err(GraphContractError::StableKeyCollision {
1511                digest: encode_hex(&self.digest),
1512            });
1513        }
1514        Ok(true)
1515    }
1516}
1517
1518/// Project-independent canonical resolution identity carried by a derived
1519/// graph snapshot.
1520#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
1521#[serde(
1522    try_from = "PortableResolutionKeyWire",
1523    into = "PortableResolutionKeyWire"
1524)]
1525pub struct PortableResolutionKey {
1526    /// Closed resolver family.
1527    domain: ResolutionKeyDomain,
1528    /// Exact length-prefixed canonical fields after project qualification.
1529    canonical_identity: String,
1530}
1531
1532impl PortableResolutionKey {
1533    /// Validate one portable resolver identity.
1534    ///
1535    /// # Errors
1536    ///
1537    /// Returns an error for oversized, malformed, or incomplete canonical
1538    /// field material.
1539    pub fn new(
1540        domain: ResolutionKeyDomain,
1541        canonical_identity: String,
1542    ) -> Result<Self, GraphContractError> {
1543        if canonical_identity.len() > MAX_PORTABLE_RESOLUTION_IDENTITY_BYTES
1544            || !valid_canonical_field_sequence(&canonical_identity)
1545        {
1546            return Err(GraphContractError::InvalidResolutionKeyIdentity);
1547        }
1548        Ok(Self {
1549            domain,
1550            canonical_identity,
1551        })
1552    }
1553
1554    /// Return the closed resolver family.
1555    #[must_use]
1556    pub const fn domain(&self) -> ResolutionKeyDomain {
1557        self.domain
1558    }
1559
1560    /// Borrow the project-independent canonical identity.
1561    #[must_use]
1562    pub fn canonical_identity(&self) -> &str {
1563        &self.canonical_identity
1564    }
1565
1566    /// Bind this portable identity to one destination project.
1567    #[must_use]
1568    pub fn bind(&self, project: ProjectInstanceId) -> CanonicalResolutionKey {
1569        let mut canonical_identity = resolution_project_prefix(project, self.domain);
1570        canonical_identity.push_str(&self.canonical_identity);
1571        let digest = *blake3::hash(canonical_identity.as_bytes()).as_bytes();
1572        CanonicalResolutionKey {
1573            project,
1574            domain: self.domain,
1575            digest,
1576            canonical_identity,
1577        }
1578    }
1579}
1580
1581/// Validated serialized portable resolution-key representation.
1582#[derive(Deserialize, Serialize)]
1583struct PortableResolutionKeyWire {
1584    /// Closed resolver family.
1585    domain: ResolutionKeyDomain,
1586    /// Exact project-independent canonical fields.
1587    canonical_identity: String,
1588}
1589
1590impl TryFrom<PortableResolutionKeyWire> for PortableResolutionKey {
1591    type Error = GraphContractError;
1592
1593    fn try_from(value: PortableResolutionKeyWire) -> Result<Self, Self::Error> {
1594        Self::new(value.domain, value.canonical_identity)
1595    }
1596}
1597
1598impl From<PortableResolutionKey> for PortableResolutionKeyWire {
1599    fn from(value: PortableResolutionKey) -> Self {
1600        Self {
1601            domain: value.domain,
1602            canonical_identity: value.canonical_identity,
1603        }
1604    }
1605}
1606
1607/// Validated serialized canonical resolution-key representation.
1608#[derive(Deserialize, Serialize)]
1609struct CanonicalResolutionKeyWire {
1610    /// Owning project instance.
1611    project: ProjectInstanceId,
1612    /// Closed resolver family.
1613    domain: ResolutionKeyDomain,
1614    /// Fixed compact digest.
1615    digest: [u8; 32],
1616    /// Canonical collision witness.
1617    canonical_identity: String,
1618}
1619
1620impl TryFrom<CanonicalResolutionKeyWire> for CanonicalResolutionKey {
1621    type Error = GraphContractError;
1622
1623    fn try_from(value: CanonicalResolutionKeyWire) -> Result<Self, Self::Error> {
1624        Self::from_persisted(
1625            value.project,
1626            value.domain,
1627            value.digest,
1628            value.canonical_identity,
1629        )
1630    }
1631}
1632
1633impl From<CanonicalResolutionKey> for CanonicalResolutionKeyWire {
1634    fn from(value: CanonicalResolutionKey) -> Self {
1635        Self {
1636            project: value.project,
1637            domain: value.domain,
1638            digest: value.digest,
1639            canonical_identity: value.canonical_identity,
1640        }
1641    }
1642}
1643
1644/// One exported canonical key bound to its owning graph entity.
1645#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1646pub struct EntityResolutionKey {
1647    /// Entity that exports the canonical identity.
1648    entity: GraphEntityKey,
1649    /// Canonical resolver identity exported by the entity.
1650    key: CanonicalResolutionKey,
1651}
1652
1653impl EntityResolutionKey {
1654    /// Bind one canonical key to its exported entity.
1655    ///
1656    /// # Errors
1657    ///
1658    /// Returns an error when the entity and key belong to different projects.
1659    pub fn new(
1660        entity: GraphEntityKey,
1661        key: CanonicalResolutionKey,
1662    ) -> Result<Self, GraphContractError> {
1663        if entity.project() != key.project() {
1664            return Err(GraphContractError::ResolutionKeyOwnerMismatch);
1665        }
1666        Ok(Self { entity, key })
1667    }
1668
1669    /// Borrow the owning entity key.
1670    #[must_use]
1671    pub const fn entity(&self) -> &GraphEntityKey {
1672        &self.entity
1673    }
1674
1675    /// Borrow the canonical resolver key.
1676    #[must_use]
1677    pub const fn key(&self) -> &CanonicalResolutionKey {
1678        &self.key
1679    }
1680}
1681
1682/// One canonical dependency identity bound to its owning logical relation.
1683#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1684pub struct RelationDependencyKey {
1685    /// Logical relation that depends on the canonical identity.
1686    relation: LogicalRelationKey,
1687    /// Canonical identity used to select candidate exports.
1688    key: CanonicalResolutionKey,
1689}
1690
1691impl RelationDependencyKey {
1692    /// Bind one dependency identity to its logical relation.
1693    ///
1694    /// # Errors
1695    ///
1696    /// Returns an error when the relation and key belong to different projects.
1697    pub fn new(
1698        relation: LogicalRelationKey,
1699        key: CanonicalResolutionKey,
1700    ) -> Result<Self, GraphContractError> {
1701        if relation.project() != key.project() {
1702            return Err(GraphContractError::ResolutionKeyOwnerMismatch);
1703        }
1704        Ok(Self { relation, key })
1705    }
1706
1707    /// Borrow the owning logical-relation key.
1708    #[must_use]
1709    pub const fn relation(&self) -> &LogicalRelationKey {
1710        &self.relation
1711    }
1712
1713    /// Borrow the canonical resolver key.
1714    #[must_use]
1715    pub const fn key(&self) -> &CanonicalResolutionKey {
1716        &self.key
1717    }
1718}
1719
1720/// One deduplicated source-kind-target relationship at a complete generation.
1721#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1722pub struct LogicalRelation {
1723    /// Stable key independent of individual source occurrences.
1724    key: LogicalRelationKey,
1725    /// Source entity.
1726    source: GraphEntityKey,
1727    /// Typed legacy or additive family.
1728    kind: GraphRelationKind,
1729    /// Resolution state and optional exact local target.
1730    resolution: RelationResolution,
1731    /// Coarse trust class.
1732    confidence: ConfidenceClass,
1733    /// Producer completeness for this relation scope.
1734    completeness: Completeness,
1735    /// Complete generation containing the relation.
1736    generation: IndexGeneration,
1737}
1738
1739impl LogicalRelation {
1740    /// Construct one logical relation and derive its stable key.
1741    ///
1742    /// # Errors
1743    ///
1744    /// Returns an error when the source or target belongs to another generation,
1745    /// a retained target belongs to another project, a reusable selector names a
1746    /// different entity, or the relation claims the zero generation.
1747    pub fn new(
1748        source: &GraphEntity,
1749        kind: GraphRelationKind,
1750        resolution: RelationResolution,
1751        confidence: ConfidenceClass,
1752        completeness: Completeness,
1753        generation: IndexGeneration,
1754    ) -> Result<Self, GraphContractError> {
1755        if generation == IndexGeneration::ZERO {
1756            return Err(GraphContractError::InvalidGeneration);
1757        }
1758        if source.generation != generation {
1759            return Err(GraphContractError::GenerationMismatch {
1760                context: "logical relation source",
1761            });
1762        }
1763        if resolution
1764            .target_key()
1765            .is_some_and(|target| target.project() != source.key.project())
1766        {
1767            return Err(GraphContractError::CrossProjectRelation);
1768        }
1769        if resolution
1770            .target_generation()
1771            .is_some_and(|target_generation| target_generation != generation)
1772        {
1773            return Err(GraphContractError::GenerationMismatch {
1774                context: "logical relation target",
1775            });
1776        }
1777        let selected = match &resolution {
1778            RelationResolution::Resolved {
1779                target, selector, ..
1780            } => Some((target, selector.entity_selector())),
1781            RelationResolution::External {
1782                target, external, ..
1783            } => Some((
1784                target,
1785                EntitySelector::External {
1786                    external: external.clone(),
1787                },
1788            )),
1789            RelationResolution::Ambiguous { .. } | RelationResolution::Unresolved { .. } => None,
1790        };
1791        if let Some((target, selector)) = selected {
1792            let selected = GraphEntityKey::new(target.project(), &selector);
1793            if !target.reconcile(&selected)? {
1794                return Err(GraphContractError::InvalidResolution {
1795                    reason: "resolution selector does not identify its retained target",
1796                });
1797            }
1798        }
1799        let mut canonical = relation_project_prefix(source.key.project());
1800        append_canonical_field(&mut canonical, source.key.canonical_identity());
1801        append_canonical_field(&mut canonical, kind.as_str());
1802        resolution.append_canonical(&mut canonical);
1803        let key = LogicalRelationKey {
1804            project: source.key.project(),
1805            stable: StableKey::new(canonical),
1806        };
1807        Ok(Self {
1808            key,
1809            source: source.key.clone(),
1810            kind,
1811            resolution,
1812            confidence,
1813            completeness,
1814            generation,
1815        })
1816    }
1817
1818    /// Borrow the stable logical-relation key.
1819    #[must_use]
1820    pub const fn key(&self) -> &LogicalRelationKey {
1821        &self.key
1822    }
1823
1824    /// Borrow the source entity key.
1825    #[must_use]
1826    pub const fn source(&self) -> &GraphEntityKey {
1827        &self.source
1828    }
1829
1830    /// Return the typed relation family.
1831    #[must_use]
1832    pub const fn kind(&self) -> GraphRelationKind {
1833        self.kind
1834    }
1835
1836    /// Borrow the relation resolution state.
1837    #[must_use]
1838    pub const fn resolution(&self) -> &RelationResolution {
1839        &self.resolution
1840    }
1841
1842    /// Return the coarse relation confidence.
1843    #[must_use]
1844    pub const fn confidence(&self) -> ConfidenceClass {
1845        self.confidence
1846    }
1847
1848    /// Return the producer completeness for this relation.
1849    #[must_use]
1850    pub const fn completeness(&self) -> Completeness {
1851        self.completeness
1852    }
1853
1854    /// Return the complete generation containing this relation.
1855    #[must_use]
1856    pub const fn generation(&self) -> IndexGeneration {
1857        self.generation
1858    }
1859}
1860
1861/// One exact source occurrence supporting a logical relationship.
1862#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1863pub struct RelationOccurrence {
1864    /// Deduplicated logical relation supported by this occurrence.
1865    relation: LogicalRelationKey,
1866    /// Repository-local file containing the evidence.
1867    file: RepositoryFilePath,
1868    /// Exact supporting source range.
1869    span: SourceSpan,
1870    /// Complete generation containing the occurrence.
1871    generation: IndexGeneration,
1872}
1873
1874impl RelationOccurrence {
1875    /// Construct one source occurrence for a logical relation.
1876    ///
1877    /// # Errors
1878    ///
1879    /// Returns an error when the occurrence and relation generations differ.
1880    pub fn new(
1881        relation: &LogicalRelation,
1882        file: RepositoryFilePath,
1883        span: SourceSpan,
1884        generation: IndexGeneration,
1885    ) -> Result<Self, GraphContractError> {
1886        if relation.generation != generation {
1887            return Err(GraphContractError::GenerationMismatch {
1888                context: "relation occurrence",
1889            });
1890        }
1891        Ok(Self {
1892            relation: relation.key.clone(),
1893            file,
1894            span,
1895            generation,
1896        })
1897    }
1898
1899    /// Borrow the supported logical-relation key.
1900    #[must_use]
1901    pub const fn relation(&self) -> &LogicalRelationKey {
1902        &self.relation
1903    }
1904
1905    /// Borrow the repository-local source file.
1906    #[must_use]
1907    pub const fn file(&self) -> &RepositoryFilePath {
1908        &self.file
1909    }
1910
1911    /// Return the exact supporting source span.
1912    #[must_use]
1913    pub const fn span(&self) -> SourceSpan {
1914        self.span
1915    }
1916
1917    /// Return the complete generation containing this occurrence.
1918    #[must_use]
1919    pub const fn generation(&self) -> IndexGeneration {
1920        self.generation
1921    }
1922}
1923
1924/// Coverage lifecycle state for one indexed graph scope.
1925#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1926#[serde(rename_all = "snake_case")]
1927pub enum CoverageState {
1928    /// Every supported item was covered.
1929    Complete,
1930    /// Complete document extraction found no supported static targets.
1931    NoCandidates,
1932    /// Some supported items were covered and some were omitted.
1933    Partial,
1934    /// Extraction failed for the whole scope.
1935    Failed,
1936    /// Configuration intentionally excluded the scope.
1937    Ignored,
1938    /// A declared size or work limit excluded the scope.
1939    Oversized,
1940    /// Trust policy isolated the scope.
1941    Quarantined,
1942    /// Previously derived coverage no longer matches current source state.
1943    Stale,
1944}
1945
1946/// Indexed scope whose graph coverage is being reported.
1947#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1948#[serde(tag = "kind", rename_all = "snake_case")]
1949pub enum CoverageScope {
1950    /// The selected project as a whole.
1951    Project,
1952    /// One repository path.
1953    Path {
1954        /// Normalized repository-relative path.
1955        path: RepositoryNodePath,
1956    },
1957}
1958
1959/// Define the closed graph-limit domain from one variant-to-spelling inventory.
1960macro_rules! define_graph_limit_kinds {
1961    ($( $(#[$variant_meta:meta])* $variant:ident => $stable_name:literal),+ $(,)?) => {
1962        /// Absolute product limit that a coverage or query row reached.
1963        #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1964        pub enum GraphLimitKind {
1965            $(
1966                $(#[$variant_meta])*
1967                #[serde(rename = $stable_name)]
1968                $variant,
1969            )+
1970        }
1971
1972        impl GraphLimitKind {
1973            /// Closed ordered inventory of every supported graph limit kind.
1974            pub const ALL: [Self; define_graph_limit_kinds!(@count $($variant),+)] = [
1975                $(Self::$variant,)+
1976            ];
1977
1978            /// Return the stable persistence and serialization spelling.
1979            #[must_use]
1980            pub const fn as_str(self) -> &'static str {
1981                match self {
1982                    $(Self::$variant => $stable_name,)+
1983                }
1984            }
1985
1986            /// Parse one stable persistence and serialization spelling.
1987            #[must_use]
1988            pub fn from_stable_name(value: &str) -> Option<Self> {
1989                Self::ALL.into_iter().find(|kind| kind.as_str() == value)
1990            }
1991        }
1992    };
1993    (@count $($variant:ident),+) => {
1994        <[()]>::len(&[$(define_graph_limit_kinds!(@unit $variant)),+])
1995    };
1996    (@unit $variant:ident) => { () };
1997}
1998
1999define_graph_limit_kinds! {
2000    /// Result-row limit.
2001    Rows => "rows",
2002    /// Unique or active node limit.
2003    Nodes => "nodes",
2004    /// Inspected logical-edge limit.
2005    Edges => "edges",
2006    /// Per-relation source-occurrence limit.
2007    Occurrences => "occurrences",
2008    /// Node-simple visited-state limit.
2009    Visited => "visited",
2010    /// Decoded or retained intermediate-memory byte limit.
2011    IntermediateBytes => "intermediate_bytes",
2012    /// Elapsed request deadline.
2013    Deadline => "deadline",
2014    /// Traversal-depth limit.
2015    Depth => "depth",
2016    /// Encoded-output byte limit.
2017    OutputBytes => "output_bytes",
2018}
2019
2020/// Coverage report with counts consistent with its lifecycle state.
2021#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
2022pub struct CoverageRecord {
2023    /// Covered project or path.
2024    scope: CoverageScope,
2025    /// Optional relation family when the row is relation-specific.
2026    relation: Option<GraphRelationKind>,
2027    /// Coverage lifecycle state.
2028    state: CoverageState,
2029    /// Supported items in the scope.
2030    total: u64,
2031    /// Successfully covered items.
2032    covered: u64,
2033    /// Omitted, failed, or untrusted items.
2034    omitted: u64,
2035    /// Complete generation associated with this row.
2036    generation: IndexGeneration,
2037    /// Actionable non-complete explanation.
2038    reason: Option<GraphIdentityText>,
2039    /// Product limit reached, when applicable.
2040    reached_limit: Option<GraphLimitKind>,
2041}
2042
2043impl CoverageRecord {
2044    /// Construct a coverage row and enforce count/state consistency.
2045    ///
2046    /// # Errors
2047    ///
2048    /// Returns an error when counts overflow, contradict the selected state, omit
2049    /// a required reason, or claim the pre-publication zero generation.
2050    pub fn new(
2051        scope: CoverageScope,
2052        relation: Option<GraphRelationKind>,
2053        state: CoverageState,
2054        covered: u64,
2055        omitted: u64,
2056        generation: IndexGeneration,
2057        reason: Option<GraphIdentityText>,
2058        reached_limit: Option<GraphLimitKind>,
2059    ) -> Result<Self, GraphContractError> {
2060        if generation == IndexGeneration::ZERO {
2061            return Err(GraphContractError::InvalidGeneration);
2062        }
2063        let total = covered
2064            .checked_add(omitted)
2065            .ok_or(GraphContractError::InvalidCoverage {
2066                reason: "coverage counts overflow",
2067            })?;
2068        if state == CoverageState::NoCandidates
2069            && relation != Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents))
2070        {
2071            return Err(GraphContractError::InvalidCoverage {
2072                reason: "no-candidates coverage requires the documents relation",
2073            });
2074        }
2075        let valid_counts = match state {
2076            CoverageState::Complete => omitted == 0,
2077            CoverageState::NoCandidates => covered == 0 && omitted == 0,
2078            CoverageState::Partial => covered > 0 && omitted > 0,
2079            CoverageState::Failed
2080            | CoverageState::Ignored
2081            | CoverageState::Oversized
2082            | CoverageState::Quarantined
2083            | CoverageState::Stale => covered == 0 && omitted > 0,
2084        };
2085        if !valid_counts {
2086            return Err(GraphContractError::InvalidCoverage {
2087                reason: "coverage state contradicts covered and omitted counts",
2088            });
2089        }
2090        if matches!(state, CoverageState::Complete | CoverageState::NoCandidates)
2091            && (reason.is_some() || reached_limit.is_some())
2092        {
2093            return Err(GraphContractError::InvalidCoverage {
2094                reason: "trusted coverage cannot report an omission reason or limit",
2095            });
2096        }
2097        if !matches!(state, CoverageState::Complete | CoverageState::NoCandidates)
2098            && reason.is_none()
2099        {
2100            return Err(GraphContractError::InvalidCoverage {
2101                reason: "non-complete coverage requires an actionable reason",
2102            });
2103        }
2104        Ok(Self {
2105            scope,
2106            relation,
2107            state,
2108            total,
2109            covered,
2110            omitted,
2111            generation,
2112            reason,
2113            reached_limit,
2114        })
2115    }
2116
2117    /// Borrow the covered project or path scope.
2118    #[must_use]
2119    pub const fn scope(&self) -> &CoverageScope {
2120        &self.scope
2121    }
2122
2123    /// Return the optional relation family covered by this row.
2124    #[must_use]
2125    pub const fn relation(&self) -> Option<GraphRelationKind> {
2126        self.relation
2127    }
2128
2129    /// Return the coverage lifecycle state.
2130    #[must_use]
2131    pub const fn state(&self) -> CoverageState {
2132        self.state
2133    }
2134
2135    /// Return the total number of supported items in scope.
2136    #[must_use]
2137    pub const fn total(&self) -> u64 {
2138        self.total
2139    }
2140
2141    /// Return the number of successfully covered items.
2142    #[must_use]
2143    pub const fn covered(&self) -> u64 {
2144        self.covered
2145    }
2146
2147    /// Return the number of omitted, failed, or untrusted items.
2148    #[must_use]
2149    pub const fn omitted(&self) -> u64 {
2150        self.omitted
2151    }
2152
2153    /// Return the complete generation associated with this row.
2154    #[must_use]
2155    pub const fn generation(&self) -> IndexGeneration {
2156        self.generation
2157    }
2158
2159    /// Borrow the actionable non-complete explanation, when present.
2160    #[must_use]
2161    pub const fn reason(&self) -> Option<&GraphIdentityText> {
2162        self.reason.as_ref()
2163    }
2164
2165    /// Return the product limit reached, when applicable.
2166    #[must_use]
2167    pub const fn reached_limit(&self) -> Option<GraphLimitKind> {
2168        self.reached_limit
2169    }
2170}
2171
2172/// Hard bounded retrieval limits accepted by repository graph consumers.
2173#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2174#[serde(try_from = "GraphLimitsWire", into = "GraphLimitsWire")]
2175pub struct GraphLimits {
2176    /// Maximum returned logical rows.
2177    rows: NonZeroU32,
2178    /// Maximum returned occurrences for one logical relation.
2179    occurrences: NonZeroU32,
2180    /// Maximum traversal depth.
2181    depth: NonZeroU32,
2182    /// Maximum encoded result bytes.
2183    output_bytes: NonZeroU32,
2184}
2185
2186impl GraphLimits {
2187    /// Absolute row ceiling for any one graph request.
2188    pub const MAX_ROWS: u32 = 10_000;
2189    /// Absolute occurrence ceiling for any one logical relation.
2190    pub const MAX_OCCURRENCES: u32 = 1_024;
2191    /// Absolute traversal-depth ceiling.
2192    pub const MAX_DEPTH: u32 = 64;
2193    /// Absolute encoded-output ceiling.
2194    pub const MAX_OUTPUT_BYTES: u32 = 16 * 1_024 * 1_024;
2195
2196    /// Validate result-defining hard limits.
2197    ///
2198    /// # Errors
2199    ///
2200    /// Returns an error for zero values or values above absolute ceilings.
2201    pub fn new(
2202        rows: u32,
2203        occurrences: u32,
2204        depth: u32,
2205        output_bytes: u32,
2206    ) -> Result<Self, GraphContractError> {
2207        if rows == 0 || rows > Self::MAX_ROWS {
2208            return Err(GraphContractError::InvalidLimits {
2209                reason: "row limit is zero or above the absolute ceiling",
2210            });
2211        }
2212        if occurrences == 0 || occurrences > Self::MAX_OCCURRENCES {
2213            return Err(GraphContractError::InvalidLimits {
2214                reason: "occurrence limit is zero or above the absolute ceiling",
2215            });
2216        }
2217        if depth == 0 || depth > Self::MAX_DEPTH {
2218            return Err(GraphContractError::InvalidLimits {
2219                reason: "depth limit is zero or above the absolute ceiling",
2220            });
2221        }
2222        if output_bytes == 0 || output_bytes > Self::MAX_OUTPUT_BYTES {
2223            return Err(GraphContractError::InvalidLimits {
2224                reason: "output limit is zero or above the absolute ceiling",
2225            });
2226        }
2227        Ok(Self {
2228            rows: NonZeroU32::new(rows).ok_or(GraphContractError::InvalidLimits {
2229                reason: "row limit must be nonzero",
2230            })?,
2231            occurrences: NonZeroU32::new(occurrences).ok_or(GraphContractError::InvalidLimits {
2232                reason: "occurrence limit must be nonzero",
2233            })?,
2234            depth: NonZeroU32::new(depth).ok_or(GraphContractError::InvalidLimits {
2235                reason: "depth limit must be nonzero",
2236            })?,
2237            output_bytes: NonZeroU32::new(output_bytes).ok_or(
2238                GraphContractError::InvalidLimits {
2239                    reason: "output limit must be nonzero",
2240                },
2241            )?,
2242        })
2243    }
2244
2245    /// Return the logical-row limit.
2246    #[must_use]
2247    pub const fn rows(self) -> u32 {
2248        self.rows.get()
2249    }
2250
2251    /// Return the per-relation occurrence limit.
2252    #[must_use]
2253    pub const fn occurrences(self) -> u32 {
2254        self.occurrences.get()
2255    }
2256
2257    /// Return the traversal-depth limit.
2258    #[must_use]
2259    pub const fn depth(self) -> u32 {
2260        self.depth.get()
2261    }
2262
2263    /// Return the encoded-output byte limit.
2264    #[must_use]
2265    pub const fn output_bytes(self) -> u32 {
2266        self.output_bytes.get()
2267    }
2268}
2269
2270/// Graph-limit wire shape validated during deserialization.
2271#[derive(Deserialize, Serialize)]
2272struct GraphLimitsWire {
2273    /// Logical-row limit.
2274    rows: u32,
2275    /// Per-relation occurrence limit.
2276    occurrences: u32,
2277    /// Traversal-depth limit.
2278    depth: u32,
2279    /// Encoded-output byte limit.
2280    output_bytes: u32,
2281}
2282
2283impl TryFrom<GraphLimitsWire> for GraphLimits {
2284    type Error = GraphContractError;
2285
2286    fn try_from(value: GraphLimitsWire) -> Result<Self, Self::Error> {
2287        Self::new(
2288            value.rows,
2289            value.occurrences,
2290            value.depth,
2291            value.output_bytes,
2292        )
2293    }
2294}
2295
2296impl From<GraphLimits> for GraphLimitsWire {
2297    fn from(value: GraphLimits) -> Self {
2298        Self {
2299            rows: value.rows(),
2300            occurrences: value.occurrences(),
2301            depth: value.depth(),
2302            output_bytes: value.output_bytes(),
2303        }
2304    }
2305}
2306
2307/// Start a canonical identity with a stable domain and project prefix.
2308fn project_canonical_prefix(domain: &str, project: ProjectInstanceId) -> String {
2309    let mut canonical = domain.to_string();
2310    append_canonical_field(&mut canonical, &project.as_hex());
2311    canonical
2312}
2313
2314/// Return the canonical prefix required for one entity key.
2315fn entity_project_prefix(project: ProjectInstanceId) -> String {
2316    project_canonical_prefix(ENTITY_KEY_DOMAIN, project)
2317}
2318
2319/// Return the canonical prefix required for one relation key.
2320fn relation_project_prefix(project: ProjectInstanceId) -> String {
2321    project_canonical_prefix(RELATION_KEY_DOMAIN, project)
2322}
2323
2324/// Return the canonical prefix required for one resolution key.
2325fn resolution_project_prefix(project: ProjectInstanceId, domain: ResolutionKeyDomain) -> String {
2326    let mut canonical = project_canonical_prefix(RESOLUTION_KEY_DOMAIN, project);
2327    append_canonical_field(&mut canonical, domain.as_str());
2328    canonical
2329}
2330
2331/// Return whether canonical material continues after one complete field prefix.
2332fn has_canonical_prefix(canonical: &str, prefix: &str) -> bool {
2333    canonical
2334        .strip_prefix(prefix)
2335        .is_some_and(|remainder| remainder.starts_with('|'))
2336}
2337
2338/// Encode a typed selector into unambiguous project-qualified material.
2339fn entity_canonical_identity(project: ProjectInstanceId, selector: &EntitySelector) -> String {
2340    let mut canonical = entity_project_prefix(project);
2341    match selector {
2342        EntitySelector::Project => append_canonical_field(&mut canonical, "project"),
2343        EntitySelector::Folder { path } => {
2344            append_canonical_field(&mut canonical, "folder");
2345            append_canonical_field(&mut canonical, path.as_str());
2346        }
2347        EntitySelector::File { path } => {
2348            append_canonical_field(&mut canonical, "file");
2349            append_canonical_field(&mut canonical, path.as_str());
2350        }
2351        EntitySelector::Package { package } => {
2352            append_canonical_field(&mut canonical, "package");
2353            append_canonical_field(&mut canonical, package.manager.as_str());
2354            append_canonical_field(&mut canonical, package.name.as_str());
2355            append_canonical_field(&mut canonical, package.manifest.as_str());
2356        }
2357        EntitySelector::Symbol { symbol } => {
2358            append_canonical_field(&mut canonical, "symbol");
2359            append_canonical_field(&mut canonical, symbol.file.as_str());
2360            append_canonical_field(&mut canonical, symbol.name.as_str());
2361            append_canonical_field(&mut canonical, &symbol.kind.to_string());
2362            append_optional_canonical_field(&mut canonical, symbol.parent.as_ref());
2363            append_canonical_field(&mut canonical, symbol.signature.as_str());
2364        }
2365        EntitySelector::External { external } => {
2366            append_canonical_field(&mut canonical, "external");
2367            append_canonical_field(&mut canonical, external.system.as_str());
2368            append_canonical_field(&mut canonical, external.identity.as_str());
2369        }
2370    }
2371    canonical
2372}
2373
2374/// Append one byte-length-prefixed field to canonical identity material.
2375fn append_canonical_field(canonical: &mut String, value: &str) {
2376    canonical.push('|');
2377    canonical.push_str(&value.len().to_string());
2378    canonical.push(':');
2379    canonical.push_str(value);
2380}
2381
2382/// Validate the exact project-independent field sequence produced by
2383/// [`CanonicalResolutionKey::new`].
2384fn valid_canonical_field_sequence(canonical: &str) -> bool {
2385    let Some(fields) = decode_canonical_fields(canonical) else {
2386        return false;
2387    };
2388    let mut index = 0;
2389    for _required in 0..2 {
2390        let Some(field) = fields.get(index) else {
2391            return false;
2392        };
2393        if !valid_canonical_identity_field(field) {
2394            return false;
2395        }
2396        index += 1;
2397    }
2398    for _optional in 0..3 {
2399        let Some(marker) = fields.get(index) else {
2400            return false;
2401        };
2402        index += 1;
2403        match *marker {
2404            "none" => {}
2405            "some" => {
2406                let Some(field) = fields.get(index) else {
2407                    return false;
2408                };
2409                if !valid_canonical_identity_field(field) {
2410                    return false;
2411                }
2412                index += 1;
2413            }
2414            _ => return false,
2415        }
2416    }
2417    fields
2418        .get(index)
2419        .is_some_and(|field| valid_canonical_identity_field(field))
2420        && index + 1 == fields.len()
2421}
2422
2423/// Decode one complete sequence of byte-length-prefixed canonical fields.
2424fn decode_canonical_fields(mut canonical: &str) -> Option<Vec<&str>> {
2425    let mut fields = Vec::new();
2426    while !canonical.is_empty() {
2427        canonical = canonical.strip_prefix('|')?;
2428        let separator = canonical.find(':')?;
2429        let length_text = canonical.get(..separator)?;
2430        let length = length_text.parse::<usize>().ok()?;
2431        if length_text != length.to_string() {
2432            return None;
2433        }
2434        canonical = canonical.get(separator + 1..)?;
2435        if length == 0
2436            || length > MAX_GRAPH_IDENTITY_BYTES
2437            || length > canonical.len()
2438            || !canonical.is_char_boundary(length)
2439        {
2440            return None;
2441        }
2442        let (field, remainder) = canonical.split_at(length);
2443        fields.push(field);
2444        canonical = remainder;
2445    }
2446    Some(fields)
2447}
2448
2449/// Apply the original identity-field constraints to portable canonical data.
2450fn valid_canonical_identity_field(value: &str) -> bool {
2451    !value.is_empty()
2452        && value.len() <= MAX_GRAPH_IDENTITY_BYTES
2453        && !value.chars().any(char::is_control)
2454}
2455
2456/// Append an optional identity field without conflating absent and empty values.
2457fn append_optional_canonical_field(canonical: &mut String, value: Option<&GraphIdentityText>) {
2458    match value {
2459        Some(value) => {
2460            append_canonical_field(canonical, "some");
2461            append_canonical_field(canonical, value.as_str());
2462        }
2463        None => append_canonical_field(canonical, "none"),
2464    }
2465}
2466
2467/// Append optional validated contract text without conflating absent and empty values.
2468fn append_optional_raw_canonical_field(canonical: &mut String, value: Option<&str>) {
2469    match value {
2470        Some(value) => {
2471            append_canonical_field(canonical, "some");
2472            append_canonical_field(canonical, value);
2473        }
2474        None => append_canonical_field(canonical, "none"),
2475    }
2476}
2477
2478/// Encode bytes as lowercase hexadecimal without another dependency.
2479fn encode_hex(bytes: &[u8]) -> String {
2480    const HEX: &[u8; 16] = b"0123456789abcdef";
2481    let mut encoded = String::with_capacity(bytes.len() * 2);
2482    for byte in bytes {
2483        encoded.push(char::from(HEX[usize::from(byte >> 4)]));
2484        encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
2485    }
2486    encoded
2487}
2488
2489/// Decode one ASCII hexadecimal digit.
2490const fn decode_hex(value: u8) -> Option<u8> {
2491    match value {
2492        b'0'..=b'9' => Some(value - b'0'),
2493        b'a'..=b'f' => Some(value - b'a' + 10),
2494        b'A'..=b'F' => Some(value - b'A' + 10),
2495        _ => None,
2496    }
2497}
2498
2499#[cfg(test)]
2500mod tests {
2501    use super::{
2502        CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
2503        CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
2504        ExtendedRelationKind, ExternalSelector, GraphContractError, GraphEntity, GraphEntityKey,
2505        GraphIdentityField, GraphIdentityRejection, GraphIdentityRejectionReason,
2506        GraphIdentityText, GraphLimitKind, GraphLimits, GraphRelationKind, LogicalRelation,
2507        PackageSelector, PortableResolutionKey, ProjectInstanceId, RelationDependencyKey,
2508        RelationOccurrence, RelationResolution, RepositoryFilePath, RepositoryNodePath,
2509        ResolutionKeyDomain, ReusableTargetSelector, SourceSpan, StableKey, SymbolSelector,
2510    };
2511    use crate::IndexGeneration;
2512    use crate::symbols::{ParserKind, RelationKind, SymbolKind};
2513    use std::io;
2514    use std::num::NonZeroU32;
2515    use std::path::Path;
2516
2517    /// Return a stable nonzero project identity for unit tests.
2518    fn project() -> Result<ProjectInstanceId, GraphContractError> {
2519        ProjectInstanceId::try_from("00112233445566778899aabbccddeeff")
2520    }
2521
2522    /// Return one stable function selector for unit tests.
2523    fn symbol_selector(path: &str) -> Result<SymbolSelector, GraphContractError> {
2524        Ok(SymbolSelector {
2525            file: RepositoryFilePath::new(Path::new(path))?,
2526            name: GraphIdentityText::new("répond")?,
2527            kind: SymbolKind::Function,
2528            parent: Some(GraphIdentityText::new("Service")?),
2529            signature: GraphIdentityText::new("fn répond(input: &str)")?,
2530        })
2531    }
2532
2533    /// Return an ordinary test error when a behavior condition is false.
2534    fn require(condition: bool, message: &'static str) -> Result<(), Box<dyn std::error::Error>> {
2535        if condition {
2536            Ok(())
2537        } else {
2538            Err(io::Error::other(message).into())
2539        }
2540    }
2541
2542    #[test]
2543    fn project_identity_validates_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
2544        let id = ProjectInstanceId::try_from("00112233-4455-6677-8899-AABBCCDDEEFF")?;
2545        require(
2546            id.as_hex() == "00112233445566778899aabbccddeeff",
2547            "project identity did not normalize to lowercase hexadecimal",
2548        )?;
2549        let encoded = serde_json::to_string(&id)?;
2550        require(
2551            encoded == "\"00112233445566778899aabbccddeeff\"",
2552            "project identity serialized with an unstable shape",
2553        )?;
2554        require(
2555            serde_json::from_str::<ProjectInstanceId>(&encoded)? == id,
2556            "project identity did not round-trip",
2557        )?;
2558        require(
2559            ProjectInstanceId::try_from("00000000000000000000000000000000").is_err(),
2560            "zero project identity was accepted",
2561        )?;
2562        require(
2563            ProjectInstanceId::try_from("not-an-id").is_err(),
2564            "malformed project identity was accepted",
2565        )?;
2566        require(
2567            ProjectInstanceId::try_from("00112233-4455-6677-8899-AABBCCDDEEF-").is_err(),
2568            "hyphenated project identity with missing hexadecimal data was accepted",
2569        )?;
2570        Ok(())
2571    }
2572
2573    #[test]
2574    fn entity_keys_are_stable_line_independent_and_project_scoped()
2575    -> Result<(), Box<dyn std::error::Error>> {
2576        let selector = EntitySelector::Symbol {
2577            symbol: symbol_selector("src\\service.rs")?,
2578        };
2579        let first = GraphEntity::new(project()?, selector.clone(), IndexGeneration::new(4))?;
2580        let rescanned = GraphEntity::new(project()?, selector, IndexGeneration::new(5))?;
2581        require(
2582            first.key().reconcile(rescanned.key())?,
2583            "unchanged selector did not retain identity",
2584        )?;
2585        require(
2586            first.key().digest() == rescanned.key().digest(),
2587            "unchanged selector changed its compact key",
2588        )?;
2589        require(
2590            first.key().digest_bytes()?
2591                == *blake3::hash(first.key().canonical_identity().as_bytes()).as_bytes(),
2592            "binary persistence key did not match the validated digest",
2593        )?;
2594
2595        let other_project = ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?;
2596        let other = GraphEntityKey::new(other_project, rescanned.selector());
2597        require(
2598            !first.key().reconcile(&other)?,
2599            "independent projects shared an entity identity",
2600        )?;
2601        require(
2602            first.key().digest() != other.digest(),
2603            "independent projects shared a compact entity key",
2604        )?;
2605        Ok(())
2606    }
2607
2608    #[test]
2609    fn stable_key_collisions_fail_closed() -> Result<(), Box<dyn std::error::Error>> {
2610        let selector = EntitySelector::File {
2611            path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
2612        };
2613        let first = GraphEntityKey::new(project()?, &selector);
2614        let conflicting = GraphEntityKey {
2615            project: first.project,
2616            stable: StableKey {
2617                digest: first.stable.digest.clone(),
2618                canonical_identity: "different canonical identity".to_string(),
2619            },
2620        };
2621        require(
2622            matches!(
2623                first.reconcile(&conflicting),
2624                Err(GraphContractError::StableKeyCollision { .. })
2625            ),
2626            "conflicting canonical identity did not fail closed",
2627        )?;
2628
2629        let mut serialized = serde_json::to_value(&first)?;
2630        serialized["stable"]["canonical_identity"] = serde_json::json!("tampered");
2631        require(
2632            serde_json::from_value::<GraphEntityKey>(serialized).is_err(),
2633            "tampered stable-key material was accepted",
2634        )?;
2635        Ok(())
2636    }
2637
2638    #[test]
2639    fn canonical_resolution_keys_are_stable_qualified_and_collision_checked()
2640    -> Result<(), Box<dyn std::error::Error>> {
2641        let provider = GraphIdentityText::new("tree-sitter")?;
2642        let language = GraphIdentityText::new("rust")?;
2643        let package = GraphIdentityText::new("auth")?;
2644        let scope = GraphIdentityText::new("crate")?;
2645        let identity = GraphIdentityText::new("répond")?;
2646        let key = CanonicalResolutionKey::new(
2647            project()?,
2648            ResolutionKeyDomain::Declaration,
2649            &provider,
2650            &language,
2651            Some(&package),
2652            Some(&scope),
2653            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2654            &identity,
2655        );
2656        let repeated = CanonicalResolutionKey::new(
2657            project()?,
2658            ResolutionKeyDomain::Declaration,
2659            &provider,
2660            &language,
2661            Some(&package),
2662            Some(&scope),
2663            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2664            &identity,
2665        );
2666        require(
2667            key.reconcile(&repeated)?,
2668            "equal canonical resolver input changed identity",
2669        )?;
2670        require(
2671            key.digest_bytes() == *blake3::hash(key.canonical_identity().as_bytes()).as_bytes(),
2672            "fixed resolver digest did not match its collision witness",
2673        )?;
2674        let persisted = CanonicalResolutionKey::from_persisted(
2675            key.project(),
2676            key.domain(),
2677            key.digest_bytes(),
2678            key.canonical_identity().to_string(),
2679        )?;
2680        require(
2681            key.reconcile(&persisted)?,
2682            "validated persisted resolver key changed identity",
2683        )?;
2684
2685        let other_project = ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?;
2686        let case_distinct = GraphIdentityText::new("Répond")?;
2687        let variants = [
2688            CanonicalResolutionKey::new(
2689                other_project,
2690                ResolutionKeyDomain::Declaration,
2691                &provider,
2692                &language,
2693                Some(&package),
2694                Some(&scope),
2695                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2696                &identity,
2697            ),
2698            CanonicalResolutionKey::new(
2699                project()?,
2700                ResolutionKeyDomain::Module,
2701                &provider,
2702                &language,
2703                Some(&package),
2704                Some(&scope),
2705                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2706                &identity,
2707            ),
2708            CanonicalResolutionKey::new(
2709                project()?,
2710                ResolutionKeyDomain::Declaration,
2711                &GraphIdentityText::new("manifest")?,
2712                &language,
2713                Some(&package),
2714                Some(&scope),
2715                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2716                &identity,
2717            ),
2718            CanonicalResolutionKey::new(
2719                project()?,
2720                ResolutionKeyDomain::Declaration,
2721                &provider,
2722                &GraphIdentityText::new("typescript")?,
2723                Some(&package),
2724                Some(&scope),
2725                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2726                &identity,
2727            ),
2728            CanonicalResolutionKey::new(
2729                project()?,
2730                ResolutionKeyDomain::Declaration,
2731                &provider,
2732                &language,
2733                Some(&GraphIdentityText::new("billing")?),
2734                Some(&scope),
2735                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2736                &identity,
2737            ),
2738            CanonicalResolutionKey::new(
2739                project()?,
2740                ResolutionKeyDomain::Declaration,
2741                &provider,
2742                &language,
2743                Some(&package),
2744                Some(&GraphIdentityText::new("module")?),
2745                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2746                &identity,
2747            ),
2748            CanonicalResolutionKey::new(
2749                project()?,
2750                ResolutionKeyDomain::Declaration,
2751                &provider,
2752                &language,
2753                Some(&package),
2754                Some(&scope),
2755                Some(GraphRelationKind::Legacy(RelationKind::Imports)),
2756                &identity,
2757            ),
2758            CanonicalResolutionKey::new(
2759                project()?,
2760                ResolutionKeyDomain::Declaration,
2761                &provider,
2762                &language,
2763                Some(&package),
2764                Some(&scope),
2765                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2766                &case_distinct,
2767            ),
2768        ];
2769        for variant in variants {
2770            require(
2771                !key.reconcile(&variant)?,
2772                "identity-affecting resolver dimension was ignored",
2773            )?;
2774        }
2775        require(
2776            matches!(
2777                ResolutionKeyDomain::try_from("declaration"),
2778                Ok(ResolutionKeyDomain::Declaration)
2779            ) && ResolutionKeyDomain::try_from("unknown").is_err(),
2780            "closed resolver-domain persistence accepted an unsupported value",
2781        )?;
2782
2783        let conflicting = CanonicalResolutionKey {
2784            canonical_identity: "different canonical identity".to_string(),
2785            ..key
2786        };
2787        require(
2788            matches!(
2789                key.reconcile(&conflicting),
2790                Err(GraphContractError::StableKeyCollision { .. })
2791            ),
2792            "equal resolver digest with a different witness did not fail closed",
2793        )?;
2794        let mut serialized = serde_json::to_value(&key)?;
2795        serialized["canonical_identity"] = serde_json::json!("tampered");
2796        require(
2797            serde_json::from_value::<CanonicalResolutionKey>(serialized).is_err(),
2798            "tampered persisted resolver key was accepted",
2799        )?;
2800        Ok(())
2801    }
2802
2803    #[test]
2804    fn portable_resolution_keys_rebind_without_project_identity()
2805    -> Result<(), Box<dyn std::error::Error>> {
2806        let provider = GraphIdentityText::new("tree-sitter")?;
2807        let language = GraphIdentityText::new("rust")?;
2808        let package = GraphIdentityText::new("auth")?;
2809        let scope = GraphIdentityText::new("crate")?;
2810        let identity = GraphIdentityText::new("répond")?;
2811        let source = CanonicalResolutionKey::new(
2812            project()?,
2813            ResolutionKeyDomain::Declaration,
2814            &provider,
2815            &language,
2816            Some(&package),
2817            Some(&scope),
2818            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2819            &identity,
2820        );
2821        let portable = source.portable()?;
2822        let encoded = serde_json::to_string(&portable)?;
2823        require(
2824            !encoded.contains(&project()?.as_hex()),
2825            "portable resolver key leaked its source project identity",
2826        )?;
2827        let decoded = serde_json::from_str::<PortableResolutionKey>(&encoded)?;
2828        let destination = ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?;
2829        let rebound = decoded.bind(destination);
2830        let expected = CanonicalResolutionKey::new(
2831            destination,
2832            ResolutionKeyDomain::Declaration,
2833            &provider,
2834            &language,
2835            Some(&package),
2836            Some(&scope),
2837            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2838            &identity,
2839        );
2840        require(
2841            rebound.reconcile(&expected)?,
2842            "portable resolver key did not rebind to the destination project",
2843        )?;
2844
2845        let mut invalid = serde_json::to_value(&portable)?;
2846        invalid["canonical_identity"] = serde_json::json!("|04:rust");
2847        require(
2848            serde_json::from_value::<PortableResolutionKey>(invalid).is_err(),
2849            "non-canonical portable resolver material was accepted",
2850        )?;
2851        Ok(())
2852    }
2853
2854    #[test]
2855    fn resolution_key_bindings_preserve_dependency_identity_across_states()
2856    -> Result<(), Box<dyn std::error::Error>> {
2857        let generation = IndexGeneration::new(7);
2858        let source = GraphEntity::new(
2859            project()?,
2860            EntitySelector::Symbol {
2861                symbol: symbol_selector("src/caller.rs")?,
2862            },
2863            generation,
2864        )?;
2865        let target = GraphEntity::new(
2866            project()?,
2867            EntitySelector::Symbol {
2868                symbol: symbol_selector("src/service.rs")?,
2869            },
2870            generation,
2871        )?;
2872        let dependency = CanonicalResolutionKey::new(
2873            project()?,
2874            ResolutionKeyDomain::Declaration,
2875            &GraphIdentityText::new("tree-sitter")?,
2876            &GraphIdentityText::new("rust")?,
2877            None,
2878            None,
2879            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2880            &GraphIdentityText::new("répond")?,
2881        );
2882        let export = EntityResolutionKey::new(target.key().clone(), dependency.clone())?;
2883        require(
2884            export.key().reconcile(&dependency)?,
2885            "export binding changed its canonical resolver identity",
2886        )?;
2887
2888        let relations = [
2889            LogicalRelation::new(
2890                &source,
2891                GraphRelationKind::Legacy(RelationKind::Calls),
2892                RelationResolution::resolved(&target)?,
2893                ConfidenceClass::Exact,
2894                Completeness::Complete,
2895                generation,
2896            )?,
2897            LogicalRelation::new(
2898                &source,
2899                GraphRelationKind::Legacy(RelationKind::Calls),
2900                RelationResolution::Ambiguous {
2901                    reference: GraphIdentityText::new("répond")?,
2902                    candidates: NonZeroU32::new(2).ok_or("nonzero candidate fixture")?,
2903                },
2904                ConfidenceClass::High,
2905                Completeness::Complete,
2906                generation,
2907            )?,
2908            LogicalRelation::new(
2909                &source,
2910                GraphRelationKind::Legacy(RelationKind::Calls),
2911                RelationResolution::Unresolved {
2912                    reference: GraphIdentityText::new("répond")?,
2913                },
2914                ConfidenceClass::Low,
2915                Completeness::Complete,
2916                generation,
2917            )?,
2918        ];
2919        for relation in relations {
2920            let binding = RelationDependencyKey::new(relation.key().clone(), dependency.clone())?;
2921            require(
2922                binding.key().reconcile(&dependency)?,
2923                "resolution state changed the retained dependency identity",
2924            )?;
2925        }
2926
2927        let foreign = CanonicalResolutionKey::new(
2928            ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?,
2929            ResolutionKeyDomain::Declaration,
2930            &GraphIdentityText::new("tree-sitter")?,
2931            &GraphIdentityText::new("rust")?,
2932            None,
2933            None,
2934            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2935            &GraphIdentityText::new("répond")?,
2936        );
2937        require(
2938            matches!(
2939                EntityResolutionKey::new(target.key().clone(), foreign),
2940                Err(GraphContractError::ResolutionKeyOwnerMismatch)
2941            ),
2942            "cross-project resolver binding was accepted",
2943        )?;
2944        Ok(())
2945    }
2946
2947    #[test]
2948    fn selectors_normalize_slashes_and_preserve_unicode_and_case()
2949    -> Result<(), Box<dyn std::error::Error>> {
2950        let upper = EntitySelector::Symbol {
2951            symbol: symbol_selector("Src\\Über.rs")?,
2952        };
2953        let lower = EntitySelector::Symbol {
2954            symbol: symbol_selector("src/Über.rs")?,
2955        };
2956        let encoded = serde_json::to_string(&upper)?;
2957        let decoded: EntitySelector = serde_json::from_str(&encoded)?;
2958        require(decoded == upper, "Unicode selector did not round-trip")?;
2959        require(
2960            encoded.contains("Src/Über.rs"),
2961            "selector changed Unicode or path case",
2962        )?;
2963        require(
2964            GraphEntityKey::new(project()?, &upper) != GraphEntityKey::new(project()?, &lower),
2965            "case-distinct repository paths collapsed",
2966        )?;
2967
2968        let slash_variant = EntitySelector::File {
2969            path: RepositoryFilePath::new(Path::new("src\\lib.rs"))?,
2970        };
2971        let normalized = EntitySelector::File {
2972            path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
2973        };
2974        require(
2975            GraphEntityKey::new(project()?, &slash_variant)
2976                == GraphEntityKey::new(project()?, &normalized),
2977            "slash variants did not normalize to one entity",
2978        )?;
2979        Ok(())
2980    }
2981
2982    #[test]
2983    fn invalid_selector_inputs_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
2984        require(
2985            RepositoryFilePath::new(Path::new("../secret.rs")).is_err(),
2986            "parent traversal was accepted",
2987        )?;
2988        require(
2989            RepositoryFilePath::new(Path::new(".")).is_err(),
2990            "project root was accepted as a file",
2991        )?;
2992        require(
2993            RepositoryNodePath::new(Path::new("C:/repo")).is_err(),
2994            "absolute Windows path was accepted",
2995        )?;
2996        require(
2997            GraphIdentityText::new(" ").is_err(),
2998            "blank identity text was accepted",
2999        )?;
3000        require(
3001            GraphIdentityText::new(" padded").is_err()
3002                && GraphIdentityText::new("padded ").is_err(),
3003            "surrounding identity whitespace was accepted",
3004        )?;
3005        require(
3006            GraphIdentityText::new("bad\nidentity").is_err(),
3007            "control-bearing identity text was accepted",
3008        )?;
3009        require(
3010            GraphIdentityText::new("x".repeat(super::MAX_GRAPH_IDENTITY_BYTES + 1)).is_err(),
3011            "oversized identity text was accepted",
3012        )?;
3013        require(
3014            SourceSpan::new(0, 0, 1, 0).is_err(),
3015            "zero source line was accepted",
3016        )?;
3017        require(
3018            SourceSpan::new(4, 2, 3, 9).is_err(),
3019            "reversed source span was accepted",
3020        )?;
3021        require(
3022            GraphEntity::new(
3023                project()?,
3024                EntitySelector::File {
3025                    path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3026                },
3027                IndexGeneration::ZERO,
3028            )
3029            .is_err(),
3030            "pre-publication generation was accepted for a graph entity",
3031        )?;
3032
3033        let package = EntitySelector::Package {
3034            package: PackageSelector {
3035                manager: GraphIdentityText::new("cargo")?,
3036                name: GraphIdentityText::new("projectatlas-core")?,
3037                manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
3038            },
3039        };
3040        require(
3041            package == serde_json::from_str(&serde_json::to_string(&package)?)?,
3042            "package selector did not round-trip",
3043        )?;
3044        Ok(())
3045    }
3046
3047    #[test]
3048    fn graph_identity_rejection_wire_names_are_typed_and_stable()
3049    -> Result<(), Box<dyn std::error::Error>> {
3050        require(
3051            serde_json::to_string(&GraphIdentityField::RelationSource)? == r#""relation.source""#,
3052            "relation-source field changed its dotted wire name",
3053        )?;
3054        require(
3055            serde_json::from_str::<GraphIdentityField>(r#""relation.target""#)?
3056                == GraphIdentityField::RelationTarget,
3057            "relation-target field lost its dotted wire name",
3058        )?;
3059        let rejection = GraphIdentityRejection {
3060            path: RepositoryNodePath::new(Path::new("src/lib.rs"))?,
3061            span: SourceSpan::new(2, 0, 2, 8)?,
3062            parser: ParserKind::TreeSitter,
3063            field: GraphIdentityField::RelationTarget,
3064            reason: GraphIdentityRejectionReason::ControlCharacters,
3065            fact_index: 0,
3066        };
3067        require(
3068            serde_json::from_str::<GraphIdentityRejection>(&serde_json::to_string(&rejection)?)?
3069                == rejection,
3070            "typed graph rejection did not round-trip",
3071        )?;
3072        Ok(())
3073    }
3074
3075    #[test]
3076    fn graph_limit_kind_inventory_and_stable_names_stay_exhaustive()
3077    -> Result<(), Box<dyn std::error::Error>> {
3078        let serialized = serde_json::to_string(&GraphLimitKind::ALL)?;
3079        require(
3080            serialized
3081                == r#"["rows","nodes","edges","occurrences","visited","intermediate_bytes","deadline","depth","output_bytes"]"#,
3082            "graph limit stable protocol drifted without an explicit migration",
3083        )?;
3084        let mut names = std::collections::BTreeSet::new();
3085        for kind in GraphLimitKind::ALL {
3086            let stable_name = kind.as_str();
3087            require(
3088                names.insert(stable_name),
3089                "graph limit inventory contains a duplicate stable name",
3090            )?;
3091            let encoded = serde_json::to_string(&kind)?;
3092            require(
3093                encoded == format!("\"{stable_name}\""),
3094                "graph limit serde spelling drifted from its stable name",
3095            )?;
3096            require(
3097                serde_json::from_str::<GraphLimitKind>(&encoded)? == kind,
3098                "graph limit serde value did not round-trip",
3099            )?;
3100            require(
3101                GraphLimitKind::from_stable_name(stable_name) == Some(kind),
3102                "graph limit stable-name parsing did not round-trip",
3103            )?;
3104        }
3105        require(
3106            GraphLimitKind::from_stable_name("unknown").is_none(),
3107            "unknown graph limit stable name was accepted",
3108        )?;
3109        Ok(())
3110    }
3111
3112    #[test]
3113    fn logical_relations_deduplicate_distinct_source_occurrences()
3114    -> Result<(), Box<dyn std::error::Error>> {
3115        let source = GraphEntity::new(
3116            project()?,
3117            EntitySelector::Symbol {
3118                symbol: symbol_selector("src/caller.rs")?,
3119            },
3120            IndexGeneration::new(7),
3121        )?;
3122        let target_symbol = symbol_selector("src/target.rs")?;
3123        let target = GraphEntity::new(
3124            project()?,
3125            EntitySelector::Symbol {
3126                symbol: target_symbol,
3127            },
3128            IndexGeneration::new(7),
3129        )?;
3130        let relation = LogicalRelation::new(
3131            &source,
3132            GraphRelationKind::from_legacy(RelationKind::Calls),
3133            RelationResolution::resolved(&target)?,
3134            ConfidenceClass::Exact,
3135            Completeness::Complete,
3136            IndexGeneration::new(7),
3137        )?;
3138        let first = RelationOccurrence::new(
3139            &relation,
3140            RepositoryFilePath::new(Path::new("src/caller.rs"))?,
3141            SourceSpan::new(10, 4, 10, 18)?,
3142            IndexGeneration::new(7),
3143        )?;
3144        let second = RelationOccurrence::new(
3145            &relation,
3146            RepositoryFilePath::new(Path::new("src/caller.rs"))?,
3147            SourceSpan::new(20, 4, 20, 18)?,
3148            IndexGeneration::new(7),
3149        )?;
3150        require(
3151            first.relation() == second.relation(),
3152            "occurrences did not retain one logical relation",
3153        )?;
3154        require(
3155            first.span() != second.span(),
3156            "distinct relation occurrences lost their source spans",
3157        )?;
3158        Ok(())
3159    }
3160
3161    #[test]
3162    fn relation_records_reject_inconsistent_identity_resolution_and_generation()
3163    -> Result<(), Box<dyn std::error::Error>> {
3164        let source = GraphEntity::new(
3165            project()?,
3166            EntitySelector::Symbol {
3167                symbol: symbol_selector("src/caller.rs")?,
3168            },
3169            IndexGeneration::new(3),
3170        )?;
3171        let target_symbol = symbol_selector("src/target.rs")?;
3172        let target = GraphEntity::new(
3173            project()?,
3174            EntitySelector::Symbol {
3175                symbol: target_symbol.clone(),
3176            },
3177            IndexGeneration::new(4),
3178        )?;
3179        require(
3180            matches!(
3181                LogicalRelation::new(
3182                    &source,
3183                    GraphRelationKind::from_legacy(RelationKind::Calls),
3184                    RelationResolution::Unresolved {
3185                        reference: GraphIdentityText::new("missing")?,
3186                    },
3187                    ConfidenceClass::Low,
3188                    Completeness::Complete,
3189                    IndexGeneration::new(4),
3190                ),
3191                Err(GraphContractError::GenerationMismatch { .. })
3192            ),
3193            "mixed source generation was accepted",
3194        )?;
3195        require(
3196            matches!(
3197                LogicalRelation::new(
3198                    &source,
3199                    GraphRelationKind::from_legacy(RelationKind::Calls),
3200                    RelationResolution::resolved(&target)?,
3201                    ConfidenceClass::Exact,
3202                    Completeness::Complete,
3203                    IndexGeneration::new(3),
3204                ),
3205                Err(GraphContractError::GenerationMismatch { .. })
3206            ),
3207            "mixed target generation was accepted",
3208        )?;
3209        let target = GraphEntity::new(
3210            project()?,
3211            EntitySelector::Symbol {
3212                symbol: target_symbol,
3213            },
3214            IndexGeneration::new(3),
3215        )?;
3216        let relation = LogicalRelation::new(
3217            &source,
3218            GraphRelationKind::from_legacy(RelationKind::Calls),
3219            RelationResolution::resolved(&target)?,
3220            ConfidenceClass::Exact,
3221            Completeness::Complete,
3222            IndexGeneration::new(3),
3223        )?;
3224        let encoded = serde_json::to_string(relation.key())?;
3225        let decoded = serde_json::from_str::<super::LogicalRelationKey>(&encoded)?;
3226        require(
3227            relation.key().reconcile(&decoded)?,
3228            "validated logical relation key did not round-trip",
3229        )?;
3230        require(
3231            matches!(
3232                RelationOccurrence::new(
3233                    &relation,
3234                    RepositoryFilePath::new(Path::new("src/caller.rs"))?,
3235                    SourceSpan::new(2, 0, 2, 8)?,
3236                    IndexGeneration::new(4),
3237                ),
3238                Err(GraphContractError::GenerationMismatch { .. })
3239            ),
3240            "mixed occurrence generation was accepted",
3241        )?;
3242        require(
3243            matches!(
3244                LogicalRelation::new(
3245                    &source,
3246                    GraphRelationKind::from_legacy(RelationKind::Calls),
3247                    RelationResolution::Resolved {
3248                        target: target.key().clone(),
3249                        selector: ReusableTargetSelector::Symbol {
3250                            symbol: symbol_selector("src/wrong.rs")?,
3251                        },
3252                        generation: IndexGeneration::new(3),
3253                    },
3254                    ConfidenceClass::Exact,
3255                    Completeness::Complete,
3256                    IndexGeneration::new(3),
3257                ),
3258                Err(GraphContractError::InvalidResolution { .. })
3259            ),
3260            "selector for an unrelated entity was accepted",
3261        )?;
3262
3263        let other_project_target = GraphEntity::new(
3264            ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?,
3265            EntitySelector::Symbol {
3266                symbol: symbol_selector("src/target.rs")?,
3267            },
3268            IndexGeneration::new(3),
3269        )?;
3270        require(
3271            matches!(
3272                LogicalRelation::new(
3273                    &source,
3274                    GraphRelationKind::from_legacy(RelationKind::Calls),
3275                    RelationResolution::resolved(&other_project_target)?,
3276                    ConfidenceClass::Exact,
3277                    Completeness::Complete,
3278                    IndexGeneration::new(3),
3279                ),
3280                Err(GraphContractError::CrossProjectRelation)
3281            ),
3282            "cross-project relation was accepted without federation",
3283        )?;
3284
3285        let external_selector = ExternalSelector {
3286            system: GraphIdentityText::new("cargo")?,
3287            identity: GraphIdentityText::new("serde")?,
3288        };
3289        let external_target = GraphEntity::new(
3290            project()?,
3291            EntitySelector::External {
3292                external: external_selector.clone(),
3293            },
3294            IndexGeneration::new(3),
3295        )?;
3296        LogicalRelation::new(
3297            &source,
3298            GraphRelationKind::from_legacy(RelationKind::DependsOn),
3299            RelationResolution::external(&external_target)?,
3300            ConfidenceClass::Exact,
3301            Completeness::Complete,
3302            IndexGeneration::new(3),
3303        )?;
3304        require(
3305            matches!(
3306                LogicalRelation::new(
3307                    &source,
3308                    GraphRelationKind::from_legacy(RelationKind::DependsOn),
3309                    RelationResolution::External {
3310                        target: external_target.key().clone(),
3311                        external: ExternalSelector {
3312                            system: external_selector.system,
3313                            identity: GraphIdentityText::new("different")?,
3314                        },
3315                        generation: IndexGeneration::new(3),
3316                    },
3317                    ConfidenceClass::Exact,
3318                    Completeness::Complete,
3319                    IndexGeneration::new(3),
3320                ),
3321                Err(GraphContractError::InvalidResolution { .. })
3322            ),
3323            "mismatched external identity was accepted",
3324        )?;
3325
3326        let mut serialized = serde_json::to_value(relation.key())?;
3327        serialized["stable"]["canonical_identity"] =
3328            serde_json::json!(source.key().canonical_identity());
3329        serialized["stable"]["digest"] = serde_json::json!(
3330            blake3::hash(source.key().canonical_identity().as_bytes())
3331                .to_hex()
3332                .as_str()
3333        );
3334        require(
3335            serde_json::from_value::<super::LogicalRelationKey>(serialized).is_err(),
3336            "entity namespace was accepted as a relation key",
3337        )?;
3338
3339        let file_key = GraphEntityKey::new(
3340            project()?,
3341            &EntitySelector::File {
3342                path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3343            },
3344        );
3345        let mut serialized = serde_json::to_value(&file_key)?;
3346        let malformed = format!("{}suffix", super::entity_project_prefix(project()?));
3347        serialized["stable"]["canonical_identity"] = serde_json::json!(&malformed);
3348        serialized["stable"]["digest"] =
3349            serde_json::json!(blake3::hash(malformed.as_bytes()).to_hex().as_str());
3350        require(
3351            serde_json::from_value::<GraphEntityKey>(serialized).is_err(),
3352            "partial project prefix was accepted as typed entity identity",
3353        )?;
3354        Ok(())
3355    }
3356
3357    #[test]
3358    fn ambiguous_candidate_counts_do_not_change_logical_relation_identity()
3359    -> Result<(), Box<dyn std::error::Error>> {
3360        let source = GraphEntity::new(
3361            project()?,
3362            EntitySelector::Symbol {
3363                symbol: symbol_selector("src/caller.rs")?,
3364            },
3365            IndexGeneration::new(11),
3366        )?;
3367        let relation = |candidates| {
3368            LogicalRelation::new(
3369                &source,
3370                GraphRelationKind::Extended(ExtendedRelationKind::References),
3371                RelationResolution::Ambiguous {
3372                    reference: GraphIdentityText::new("handler")?,
3373                    candidates: NonZeroU32::new(candidates).ok_or(
3374                        GraphContractError::InvalidCoverage {
3375                            reason: "test candidate count must be nonzero",
3376                        },
3377                    )?,
3378                },
3379                ConfidenceClass::High,
3380                Completeness::Complete,
3381                IndexGeneration::new(11),
3382            )
3383        };
3384        let first = relation(2)?;
3385        let second = relation(3)?;
3386        require(
3387            first.key().reconcile(second.key())?,
3388            "candidate-count metadata changed logical relation identity",
3389        )?;
3390        Ok(())
3391    }
3392
3393    #[test]
3394    fn relation_resolution_and_target_selectors_round_trip()
3395    -> Result<(), Box<dyn std::error::Error>> {
3396        let selector = ReusableTargetSelector::Symbol {
3397            symbol: symbol_selector("src/lib.rs")?,
3398        };
3399        let encoded = serde_json::to_string(&selector)?;
3400        require(
3401            serde_json::from_str::<ReusableTargetSelector>(&encoded)? == selector,
3402            "exact target selector did not round-trip",
3403        )?;
3404
3405        let cases = [
3406            (
3407                EntitySelector::Folder {
3408                    path: RepositoryNodePath::new(Path::new("src"))?,
3409                },
3410                ReusableTargetSelector::Folder {
3411                    folder: RepositoryNodePath::new(Path::new("src"))?,
3412                },
3413            ),
3414            (
3415                EntitySelector::File {
3416                    path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3417                },
3418                ReusableTargetSelector::File {
3419                    file: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3420                },
3421            ),
3422            (
3423                EntitySelector::Package {
3424                    package: PackageSelector {
3425                        manager: GraphIdentityText::new("cargo")?,
3426                        name: GraphIdentityText::new("projectatlas-core")?,
3427                        manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
3428                    },
3429                },
3430                ReusableTargetSelector::Package {
3431                    package: PackageSelector {
3432                        manager: GraphIdentityText::new("cargo")?,
3433                        name: GraphIdentityText::new("projectatlas-core")?,
3434                        manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
3435                    },
3436                },
3437            ),
3438            (
3439                EntitySelector::Symbol {
3440                    symbol: symbol_selector("src/lib.rs")?,
3441                },
3442                selector,
3443            ),
3444        ];
3445        for (entity_selector, expected) in cases {
3446            let entity = GraphEntity::new(project()?, entity_selector, IndexGeneration::new(5))?;
3447            require(
3448                ReusableTargetSelector::for_entity(&entity)? == expected,
3449                "entity-derived reusable selector drifted",
3450            )?;
3451        }
3452
3453        let project_entity =
3454            GraphEntity::new(project()?, EntitySelector::Project, IndexGeneration::new(5))?;
3455        require(
3456            RelationResolution::resolved(&project_entity).is_err(),
3457            "project aggregate was exposed as a direct source target",
3458        )?;
3459        let external_entity = GraphEntity::new(
3460            project()?,
3461            EntitySelector::External {
3462                external: ExternalSelector {
3463                    system: GraphIdentityText::new("cargo")?,
3464                    identity: GraphIdentityText::new("serde")?,
3465                },
3466            },
3467            IndexGeneration::new(5),
3468        )?;
3469        let external = RelationResolution::external(&external_entity)?;
3470        require(
3471            external.resolved_target().is_none(),
3472            "external resolution exposed a traversable local target",
3473        )?;
3474        require(
3475            RelationResolution::external(&project_entity).is_err(),
3476            "non-external entity was accepted as an external resolution",
3477        )?;
3478
3479        let unresolved = RelationResolution::Unresolved {
3480            reference: GraphIdentityText::new("missing::target")?,
3481        };
3482        require(
3483            unresolved.resolved_target().is_none(),
3484            "unresolved relation exposed a traversable target",
3485        )?;
3486        let ambiguous = RelationResolution::Ambiguous {
3487            reference: GraphIdentityText::new("handler")?,
3488            candidates: NonZeroU32::new(2).ok_or("candidate count must be nonzero")?,
3489        };
3490        require(
3491            ambiguous.resolved_target().is_none(),
3492            "ambiguous relation exposed a traversable target",
3493        )?;
3494        Ok(())
3495    }
3496
3497    #[test]
3498    fn legacy_relation_projection_remains_compatible() {
3499        for kind in [
3500            RelationKind::Contains,
3501            RelationKind::Imports,
3502            RelationKind::Calls,
3503            RelationKind::DependsOn,
3504        ] {
3505            assert_eq!(
3506                GraphRelationKind::from_legacy(kind).legacy_kind(),
3507                Some(kind)
3508            );
3509        }
3510        assert_eq!(
3511            GraphRelationKind::Extended(ExtendedRelationKind::Tests).legacy_kind(),
3512            None
3513        );
3514    }
3515
3516    #[test]
3517    fn documents_relation_and_unresolved_reasons_are_closed()
3518    -> Result<(), Box<dyn std::error::Error>> {
3519        let documents = GraphRelationKind::Extended(ExtendedRelationKind::Documents);
3520        require(
3521            GraphRelationKind::ALL.contains(&documents)
3522                && documents.as_str() == "extended:documents"
3523                && documents.legacy_kind().is_none(),
3524            "documents relation was not added as an extended persisted family",
3525        )?;
3526        require(
3527            serde_json::to_string(&documents)?
3528                == "{\"scope\":\"extended\",\"value\":\"documents\"}",
3529            "documents relation wire spelling drifted",
3530        )?;
3531
3532        for reason in DocumentTargetUnresolvedReason::ALL {
3533            require(
3534                DocumentTargetUnresolvedReason::from_db(reason.as_str()) == Some(reason),
3535                "document unresolved reason did not round-trip",
3536            )?;
3537            require(
3538                serde_json::from_str::<DocumentTargetUnresolvedReason>(&serde_json::to_string(
3539                    &reason,
3540                )?)? == reason,
3541                "document unresolved reason wire spelling did not round-trip",
3542            )?;
3543        }
3544        require(
3545            DocumentTargetUnresolvedReason::from_db("external").is_none(),
3546            "unsupported document unresolved reason was accepted",
3547        )
3548    }
3549
3550    #[test]
3551    fn coverage_state_enforces_consistent_counts_and_reasons()
3552    -> Result<(), Box<dyn std::error::Error>> {
3553        let complete = CoverageRecord::new(
3554            CoverageScope::Project,
3555            None,
3556            CoverageState::Complete,
3557            8,
3558            0,
3559            IndexGeneration::new(9),
3560            None,
3561            None,
3562        )?;
3563        require(complete.total() == 8, "complete coverage total drifted")?;
3564
3565        let no_candidates = CoverageRecord::new(
3566            CoverageScope::Project,
3567            Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
3568            CoverageState::NoCandidates,
3569            0,
3570            0,
3571            IndexGeneration::new(9),
3572            None,
3573            None,
3574        )?;
3575        require(
3576            no_candidates.total() == 0,
3577            "zero-candidate coverage total drifted",
3578        )?;
3579        require(
3580            CoverageRecord::new(
3581                CoverageScope::Project,
3582                Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
3583                CoverageState::NoCandidates,
3584                1,
3585                0,
3586                IndexGeneration::new(9),
3587                None,
3588                None,
3589            )
3590            .is_err(),
3591            "zero-candidate coverage accepted a covered row",
3592        )?;
3593        require(
3594            CoverageRecord::new(
3595                CoverageScope::Project,
3596                Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
3597                CoverageState::NoCandidates,
3598                0,
3599                0,
3600                IndexGeneration::new(9),
3601                Some(GraphIdentityText::new("unexpected reason")?),
3602                None,
3603            )
3604            .is_err(),
3605            "zero-candidate coverage accepted an omission reason",
3606        )?;
3607        require(
3608            CoverageRecord::new(
3609                CoverageScope::Project,
3610                None,
3611                CoverageState::NoCandidates,
3612                0,
3613                0,
3614                IndexGeneration::new(9),
3615                None,
3616                None,
3617            )
3618            .is_err(),
3619            "zero-candidate coverage accepted a non-document relation",
3620        )?;
3621
3622        let partial = CoverageRecord::new(
3623            CoverageScope::Path {
3624                path: RepositoryNodePath::new(Path::new("src"))?,
3625            },
3626            Some(GraphRelationKind::Extended(
3627                ExtendedRelationKind::References,
3628            )),
3629            CoverageState::Partial,
3630            5,
3631            3,
3632            IndexGeneration::new(9),
3633            Some(GraphIdentityText::new("parser limit reached")?),
3634            Some(GraphLimitKind::Rows),
3635        )?;
3636        require(
3637            (partial.covered(), partial.omitted(), partial.total()) == (5, 3, 8),
3638            "partial coverage counts are inconsistent",
3639        )?;
3640
3641        require(
3642            CoverageRecord::new(
3643                CoverageScope::Project,
3644                None,
3645                CoverageState::Complete,
3646                7,
3647                1,
3648                IndexGeneration::new(9),
3649                None,
3650                None,
3651            )
3652            .is_err(),
3653            "complete coverage accepted omitted rows",
3654        )?;
3655        require(
3656            CoverageRecord::new(
3657                CoverageScope::Project,
3658                None,
3659                CoverageState::Failed,
3660                0,
3661                8,
3662                IndexGeneration::new(9),
3663                None,
3664                None,
3665            )
3666            .is_err(),
3667            "failed coverage accepted a missing reason",
3668        )?;
3669        require(
3670            CoverageRecord::new(
3671                CoverageScope::Project,
3672                None,
3673                CoverageState::Complete,
3674                1,
3675                0,
3676                IndexGeneration::ZERO,
3677                None,
3678                None,
3679            )
3680            .is_err(),
3681            "pre-publication coverage generation was accepted",
3682        )?;
3683        Ok(())
3684    }
3685
3686    #[test]
3687    fn graph_limits_are_nonzero_bounded_and_validated_on_input()
3688    -> Result<(), Box<dyn std::error::Error>> {
3689        let limits = GraphLimits::new(100, 20, 4, 64 * 1_024)?;
3690        let encoded = serde_json::to_string(&limits)?;
3691        require(
3692            serde_json::from_str::<GraphLimits>(&encoded)? == limits,
3693            "graph limits did not round-trip",
3694        )?;
3695        require(
3696            GraphLimits::new(0, 20, 4, 1024).is_err(),
3697            "zero graph row limit was accepted",
3698        )?;
3699        require(
3700            GraphLimits::new(GraphLimits::MAX_ROWS + 1, 20, 4, 1024).is_err(),
3701            "graph row limit exceeded its hard ceiling",
3702        )?;
3703        require(
3704            serde_json::from_str::<GraphLimits>(
3705                r#"{"rows":1,"occurrences":1,"depth":65,"output_bytes":1}"#,
3706            )
3707            .is_err(),
3708            "deserialization bypassed graph depth limits",
3709        )?;
3710        Ok(())
3711    }
3712}