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    /// Remove project qualification while retaining the exact canonical
1474    /// resolver material needed to rebind a derived graph snapshot.
1475    ///
1476    /// # Errors
1477    ///
1478    /// Returns an error if the private canonical-key invariant was violated.
1479    pub fn portable(&self) -> Result<PortableResolutionKey, GraphContractError> {
1480        let prefix = resolution_project_prefix(self.project, self.domain);
1481        let Some(canonical_identity) = self.canonical_identity.strip_prefix(&prefix) else {
1482            return Err(GraphContractError::ProjectQualificationMismatch);
1483        };
1484        PortableResolutionKey::new(self.domain, canonical_identity.to_string())
1485    }
1486
1487    /// Compare compact keys without silently accepting a digest collision.
1488    ///
1489    /// # Errors
1490    ///
1491    /// Returns [`GraphContractError::StableKeyCollision`] when equal compact keys
1492    /// retain different canonical material.
1493    pub fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
1494        if self.project != other.project || self.domain != other.domain {
1495            return Ok(false);
1496        }
1497        if self.digest != other.digest {
1498            return Ok(false);
1499        }
1500        if self.canonical_identity != other.canonical_identity {
1501            return Err(GraphContractError::StableKeyCollision {
1502                digest: encode_hex(&self.digest),
1503            });
1504        }
1505        Ok(true)
1506    }
1507}
1508
1509/// Project-independent canonical resolution identity carried by a derived
1510/// graph snapshot.
1511#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
1512#[serde(
1513    try_from = "PortableResolutionKeyWire",
1514    into = "PortableResolutionKeyWire"
1515)]
1516pub struct PortableResolutionKey {
1517    /// Closed resolver family.
1518    domain: ResolutionKeyDomain,
1519    /// Exact length-prefixed canonical fields after project qualification.
1520    canonical_identity: String,
1521}
1522
1523impl PortableResolutionKey {
1524    /// Validate one portable resolver identity.
1525    ///
1526    /// # Errors
1527    ///
1528    /// Returns an error for oversized, malformed, or incomplete canonical
1529    /// field material.
1530    pub fn new(
1531        domain: ResolutionKeyDomain,
1532        canonical_identity: String,
1533    ) -> Result<Self, GraphContractError> {
1534        if canonical_identity.len() > MAX_PORTABLE_RESOLUTION_IDENTITY_BYTES
1535            || !valid_canonical_field_sequence(&canonical_identity)
1536        {
1537            return Err(GraphContractError::InvalidResolutionKeyIdentity);
1538        }
1539        Ok(Self {
1540            domain,
1541            canonical_identity,
1542        })
1543    }
1544
1545    /// Return the closed resolver family.
1546    #[must_use]
1547    pub const fn domain(&self) -> ResolutionKeyDomain {
1548        self.domain
1549    }
1550
1551    /// Borrow the project-independent canonical identity.
1552    #[must_use]
1553    pub fn canonical_identity(&self) -> &str {
1554        &self.canonical_identity
1555    }
1556
1557    /// Bind this portable identity to one destination project.
1558    #[must_use]
1559    pub fn bind(&self, project: ProjectInstanceId) -> CanonicalResolutionKey {
1560        let mut canonical_identity = resolution_project_prefix(project, self.domain);
1561        canonical_identity.push_str(&self.canonical_identity);
1562        let digest = *blake3::hash(canonical_identity.as_bytes()).as_bytes();
1563        CanonicalResolutionKey {
1564            project,
1565            domain: self.domain,
1566            digest,
1567            canonical_identity,
1568        }
1569    }
1570}
1571
1572/// Validated serialized portable resolution-key representation.
1573#[derive(Deserialize, Serialize)]
1574struct PortableResolutionKeyWire {
1575    /// Closed resolver family.
1576    domain: ResolutionKeyDomain,
1577    /// Exact project-independent canonical fields.
1578    canonical_identity: String,
1579}
1580
1581impl TryFrom<PortableResolutionKeyWire> for PortableResolutionKey {
1582    type Error = GraphContractError;
1583
1584    fn try_from(value: PortableResolutionKeyWire) -> Result<Self, Self::Error> {
1585        Self::new(value.domain, value.canonical_identity)
1586    }
1587}
1588
1589impl From<PortableResolutionKey> for PortableResolutionKeyWire {
1590    fn from(value: PortableResolutionKey) -> Self {
1591        Self {
1592            domain: value.domain,
1593            canonical_identity: value.canonical_identity,
1594        }
1595    }
1596}
1597
1598/// Validated serialized canonical resolution-key representation.
1599#[derive(Deserialize, Serialize)]
1600struct CanonicalResolutionKeyWire {
1601    /// Owning project instance.
1602    project: ProjectInstanceId,
1603    /// Closed resolver family.
1604    domain: ResolutionKeyDomain,
1605    /// Fixed compact digest.
1606    digest: [u8; 32],
1607    /// Canonical collision witness.
1608    canonical_identity: String,
1609}
1610
1611impl TryFrom<CanonicalResolutionKeyWire> for CanonicalResolutionKey {
1612    type Error = GraphContractError;
1613
1614    fn try_from(value: CanonicalResolutionKeyWire) -> Result<Self, Self::Error> {
1615        Self::from_persisted(
1616            value.project,
1617            value.domain,
1618            value.digest,
1619            value.canonical_identity,
1620        )
1621    }
1622}
1623
1624impl From<CanonicalResolutionKey> for CanonicalResolutionKeyWire {
1625    fn from(value: CanonicalResolutionKey) -> Self {
1626        Self {
1627            project: value.project,
1628            domain: value.domain,
1629            digest: value.digest,
1630            canonical_identity: value.canonical_identity,
1631        }
1632    }
1633}
1634
1635/// One exported canonical key bound to its owning graph entity.
1636#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1637pub struct EntityResolutionKey {
1638    /// Entity that exports the canonical identity.
1639    entity: GraphEntityKey,
1640    /// Canonical resolver identity exported by the entity.
1641    key: CanonicalResolutionKey,
1642}
1643
1644impl EntityResolutionKey {
1645    /// Bind one canonical key to its exported entity.
1646    ///
1647    /// # Errors
1648    ///
1649    /// Returns an error when the entity and key belong to different projects.
1650    pub fn new(
1651        entity: GraphEntityKey,
1652        key: CanonicalResolutionKey,
1653    ) -> Result<Self, GraphContractError> {
1654        if entity.project() != key.project() {
1655            return Err(GraphContractError::ResolutionKeyOwnerMismatch);
1656        }
1657        Ok(Self { entity, key })
1658    }
1659
1660    /// Borrow the owning entity key.
1661    #[must_use]
1662    pub const fn entity(&self) -> &GraphEntityKey {
1663        &self.entity
1664    }
1665
1666    /// Borrow the canonical resolver key.
1667    #[must_use]
1668    pub const fn key(&self) -> &CanonicalResolutionKey {
1669        &self.key
1670    }
1671}
1672
1673/// One canonical dependency identity bound to its owning logical relation.
1674#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1675pub struct RelationDependencyKey {
1676    /// Logical relation that depends on the canonical identity.
1677    relation: LogicalRelationKey,
1678    /// Canonical identity used to select candidate exports.
1679    key: CanonicalResolutionKey,
1680}
1681
1682impl RelationDependencyKey {
1683    /// Bind one dependency identity to its logical relation.
1684    ///
1685    /// # Errors
1686    ///
1687    /// Returns an error when the relation and key belong to different projects.
1688    pub fn new(
1689        relation: LogicalRelationKey,
1690        key: CanonicalResolutionKey,
1691    ) -> Result<Self, GraphContractError> {
1692        if relation.project() != key.project() {
1693            return Err(GraphContractError::ResolutionKeyOwnerMismatch);
1694        }
1695        Ok(Self { relation, key })
1696    }
1697
1698    /// Borrow the owning logical-relation key.
1699    #[must_use]
1700    pub const fn relation(&self) -> &LogicalRelationKey {
1701        &self.relation
1702    }
1703
1704    /// Borrow the canonical resolver key.
1705    #[must_use]
1706    pub const fn key(&self) -> &CanonicalResolutionKey {
1707        &self.key
1708    }
1709}
1710
1711/// One deduplicated source-kind-target relationship at a complete generation.
1712#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1713pub struct LogicalRelation {
1714    /// Stable key independent of individual source occurrences.
1715    key: LogicalRelationKey,
1716    /// Source entity.
1717    source: GraphEntityKey,
1718    /// Typed legacy or additive family.
1719    kind: GraphRelationKind,
1720    /// Resolution state and optional exact local target.
1721    resolution: RelationResolution,
1722    /// Coarse trust class.
1723    confidence: ConfidenceClass,
1724    /// Producer completeness for this relation scope.
1725    completeness: Completeness,
1726    /// Complete generation containing the relation.
1727    generation: IndexGeneration,
1728}
1729
1730impl LogicalRelation {
1731    /// Construct one logical relation and derive its stable key.
1732    ///
1733    /// # Errors
1734    ///
1735    /// Returns an error when the source or target belongs to another generation,
1736    /// a retained target belongs to another project, a reusable selector names a
1737    /// different entity, or the relation claims the zero generation.
1738    pub fn new(
1739        source: &GraphEntity,
1740        kind: GraphRelationKind,
1741        resolution: RelationResolution,
1742        confidence: ConfidenceClass,
1743        completeness: Completeness,
1744        generation: IndexGeneration,
1745    ) -> Result<Self, GraphContractError> {
1746        if generation == IndexGeneration::ZERO {
1747            return Err(GraphContractError::InvalidGeneration);
1748        }
1749        if source.generation != generation {
1750            return Err(GraphContractError::GenerationMismatch {
1751                context: "logical relation source",
1752            });
1753        }
1754        if resolution
1755            .target_key()
1756            .is_some_and(|target| target.project() != source.key.project())
1757        {
1758            return Err(GraphContractError::CrossProjectRelation);
1759        }
1760        if resolution
1761            .target_generation()
1762            .is_some_and(|target_generation| target_generation != generation)
1763        {
1764            return Err(GraphContractError::GenerationMismatch {
1765                context: "logical relation target",
1766            });
1767        }
1768        let selected = match &resolution {
1769            RelationResolution::Resolved {
1770                target, selector, ..
1771            } => Some((target, selector.entity_selector())),
1772            RelationResolution::External {
1773                target, external, ..
1774            } => Some((
1775                target,
1776                EntitySelector::External {
1777                    external: external.clone(),
1778                },
1779            )),
1780            RelationResolution::Ambiguous { .. } | RelationResolution::Unresolved { .. } => None,
1781        };
1782        if let Some((target, selector)) = selected {
1783            let selected = GraphEntityKey::new(target.project(), &selector);
1784            if !target.reconcile(&selected)? {
1785                return Err(GraphContractError::InvalidResolution {
1786                    reason: "resolution selector does not identify its retained target",
1787                });
1788            }
1789        }
1790        let mut canonical = relation_project_prefix(source.key.project());
1791        append_canonical_field(&mut canonical, source.key.canonical_identity());
1792        append_canonical_field(&mut canonical, kind.as_str());
1793        resolution.append_canonical(&mut canonical);
1794        let key = LogicalRelationKey {
1795            project: source.key.project(),
1796            stable: StableKey::new(canonical),
1797        };
1798        Ok(Self {
1799            key,
1800            source: source.key.clone(),
1801            kind,
1802            resolution,
1803            confidence,
1804            completeness,
1805            generation,
1806        })
1807    }
1808
1809    /// Borrow the stable logical-relation key.
1810    #[must_use]
1811    pub const fn key(&self) -> &LogicalRelationKey {
1812        &self.key
1813    }
1814
1815    /// Borrow the source entity key.
1816    #[must_use]
1817    pub const fn source(&self) -> &GraphEntityKey {
1818        &self.source
1819    }
1820
1821    /// Return the typed relation family.
1822    #[must_use]
1823    pub const fn kind(&self) -> GraphRelationKind {
1824        self.kind
1825    }
1826
1827    /// Borrow the relation resolution state.
1828    #[must_use]
1829    pub const fn resolution(&self) -> &RelationResolution {
1830        &self.resolution
1831    }
1832
1833    /// Return the coarse relation confidence.
1834    #[must_use]
1835    pub const fn confidence(&self) -> ConfidenceClass {
1836        self.confidence
1837    }
1838
1839    /// Return the producer completeness for this relation.
1840    #[must_use]
1841    pub const fn completeness(&self) -> Completeness {
1842        self.completeness
1843    }
1844
1845    /// Return the complete generation containing this relation.
1846    #[must_use]
1847    pub const fn generation(&self) -> IndexGeneration {
1848        self.generation
1849    }
1850}
1851
1852/// One exact source occurrence supporting a logical relationship.
1853#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1854pub struct RelationOccurrence {
1855    /// Deduplicated logical relation supported by this occurrence.
1856    relation: LogicalRelationKey,
1857    /// Repository-local file containing the evidence.
1858    file: RepositoryFilePath,
1859    /// Exact supporting source range.
1860    span: SourceSpan,
1861    /// Complete generation containing the occurrence.
1862    generation: IndexGeneration,
1863}
1864
1865impl RelationOccurrence {
1866    /// Construct one source occurrence for a logical relation.
1867    ///
1868    /// # Errors
1869    ///
1870    /// Returns an error when the occurrence and relation generations differ.
1871    pub fn new(
1872        relation: &LogicalRelation,
1873        file: RepositoryFilePath,
1874        span: SourceSpan,
1875        generation: IndexGeneration,
1876    ) -> Result<Self, GraphContractError> {
1877        if relation.generation != generation {
1878            return Err(GraphContractError::GenerationMismatch {
1879                context: "relation occurrence",
1880            });
1881        }
1882        Ok(Self {
1883            relation: relation.key.clone(),
1884            file,
1885            span,
1886            generation,
1887        })
1888    }
1889
1890    /// Borrow the supported logical-relation key.
1891    #[must_use]
1892    pub const fn relation(&self) -> &LogicalRelationKey {
1893        &self.relation
1894    }
1895
1896    /// Borrow the repository-local source file.
1897    #[must_use]
1898    pub const fn file(&self) -> &RepositoryFilePath {
1899        &self.file
1900    }
1901
1902    /// Return the exact supporting source span.
1903    #[must_use]
1904    pub const fn span(&self) -> SourceSpan {
1905        self.span
1906    }
1907
1908    /// Return the complete generation containing this occurrence.
1909    #[must_use]
1910    pub const fn generation(&self) -> IndexGeneration {
1911        self.generation
1912    }
1913}
1914
1915/// Coverage lifecycle state for one indexed graph scope.
1916#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1917#[serde(rename_all = "snake_case")]
1918pub enum CoverageState {
1919    /// Every supported item was covered.
1920    Complete,
1921    /// Complete document extraction found no supported static targets.
1922    NoCandidates,
1923    /// Some supported items were covered and some were omitted.
1924    Partial,
1925    /// Extraction failed for the whole scope.
1926    Failed,
1927    /// Configuration intentionally excluded the scope.
1928    Ignored,
1929    /// A declared size or work limit excluded the scope.
1930    Oversized,
1931    /// Trust policy isolated the scope.
1932    Quarantined,
1933    /// Previously derived coverage no longer matches current source state.
1934    Stale,
1935}
1936
1937/// Indexed scope whose graph coverage is being reported.
1938#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1939#[serde(tag = "kind", rename_all = "snake_case")]
1940pub enum CoverageScope {
1941    /// The selected project as a whole.
1942    Project,
1943    /// One repository path.
1944    Path {
1945        /// Normalized repository-relative path.
1946        path: RepositoryNodePath,
1947    },
1948}
1949
1950/// Define the closed graph-limit domain from one variant-to-spelling inventory.
1951macro_rules! define_graph_limit_kinds {
1952    ($( $(#[$variant_meta:meta])* $variant:ident => $stable_name:literal),+ $(,)?) => {
1953        /// Absolute product limit that a coverage or query row reached.
1954        #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1955        pub enum GraphLimitKind {
1956            $(
1957                $(#[$variant_meta])*
1958                #[serde(rename = $stable_name)]
1959                $variant,
1960            )+
1961        }
1962
1963        impl GraphLimitKind {
1964            /// Closed ordered inventory of every supported graph limit kind.
1965            pub const ALL: [Self; define_graph_limit_kinds!(@count $($variant),+)] = [
1966                $(Self::$variant,)+
1967            ];
1968
1969            /// Return the stable persistence and serialization spelling.
1970            #[must_use]
1971            pub const fn as_str(self) -> &'static str {
1972                match self {
1973                    $(Self::$variant => $stable_name,)+
1974                }
1975            }
1976
1977            /// Parse one stable persistence and serialization spelling.
1978            #[must_use]
1979            pub fn from_stable_name(value: &str) -> Option<Self> {
1980                Self::ALL.into_iter().find(|kind| kind.as_str() == value)
1981            }
1982        }
1983    };
1984    (@count $($variant:ident),+) => {
1985        <[()]>::len(&[$(define_graph_limit_kinds!(@unit $variant)),+])
1986    };
1987    (@unit $variant:ident) => { () };
1988}
1989
1990define_graph_limit_kinds! {
1991    /// Result-row limit.
1992    Rows => "rows",
1993    /// Unique or active node limit.
1994    Nodes => "nodes",
1995    /// Inspected logical-edge limit.
1996    Edges => "edges",
1997    /// Per-relation source-occurrence limit.
1998    Occurrences => "occurrences",
1999    /// Node-simple visited-state limit.
2000    Visited => "visited",
2001    /// Decoded or retained intermediate-memory byte limit.
2002    IntermediateBytes => "intermediate_bytes",
2003    /// Elapsed request deadline.
2004    Deadline => "deadline",
2005    /// Traversal-depth limit.
2006    Depth => "depth",
2007    /// Encoded-output byte limit.
2008    OutputBytes => "output_bytes",
2009}
2010
2011/// Coverage report with counts consistent with its lifecycle state.
2012#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
2013pub struct CoverageRecord {
2014    /// Covered project or path.
2015    scope: CoverageScope,
2016    /// Optional relation family when the row is relation-specific.
2017    relation: Option<GraphRelationKind>,
2018    /// Coverage lifecycle state.
2019    state: CoverageState,
2020    /// Supported items in the scope.
2021    total: u64,
2022    /// Successfully covered items.
2023    covered: u64,
2024    /// Omitted, failed, or untrusted items.
2025    omitted: u64,
2026    /// Complete generation associated with this row.
2027    generation: IndexGeneration,
2028    /// Actionable non-complete explanation.
2029    reason: Option<GraphIdentityText>,
2030    /// Product limit reached, when applicable.
2031    reached_limit: Option<GraphLimitKind>,
2032}
2033
2034impl CoverageRecord {
2035    /// Construct a coverage row and enforce count/state consistency.
2036    ///
2037    /// # Errors
2038    ///
2039    /// Returns an error when counts overflow, contradict the selected state, omit
2040    /// a required reason, or claim the pre-publication zero generation.
2041    pub fn new(
2042        scope: CoverageScope,
2043        relation: Option<GraphRelationKind>,
2044        state: CoverageState,
2045        covered: u64,
2046        omitted: u64,
2047        generation: IndexGeneration,
2048        reason: Option<GraphIdentityText>,
2049        reached_limit: Option<GraphLimitKind>,
2050    ) -> Result<Self, GraphContractError> {
2051        if generation == IndexGeneration::ZERO {
2052            return Err(GraphContractError::InvalidGeneration);
2053        }
2054        let total = covered
2055            .checked_add(omitted)
2056            .ok_or(GraphContractError::InvalidCoverage {
2057                reason: "coverage counts overflow",
2058            })?;
2059        if state == CoverageState::NoCandidates
2060            && relation != Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents))
2061        {
2062            return Err(GraphContractError::InvalidCoverage {
2063                reason: "no-candidates coverage requires the documents relation",
2064            });
2065        }
2066        let valid_counts = match state {
2067            CoverageState::Complete => omitted == 0,
2068            CoverageState::NoCandidates => covered == 0 && omitted == 0,
2069            CoverageState::Partial => covered > 0 && omitted > 0,
2070            CoverageState::Failed
2071            | CoverageState::Ignored
2072            | CoverageState::Oversized
2073            | CoverageState::Quarantined
2074            | CoverageState::Stale => covered == 0 && omitted > 0,
2075        };
2076        if !valid_counts {
2077            return Err(GraphContractError::InvalidCoverage {
2078                reason: "coverage state contradicts covered and omitted counts",
2079            });
2080        }
2081        if matches!(state, CoverageState::Complete | CoverageState::NoCandidates)
2082            && (reason.is_some() || reached_limit.is_some())
2083        {
2084            return Err(GraphContractError::InvalidCoverage {
2085                reason: "trusted coverage cannot report an omission reason or limit",
2086            });
2087        }
2088        if !matches!(state, CoverageState::Complete | CoverageState::NoCandidates)
2089            && reason.is_none()
2090        {
2091            return Err(GraphContractError::InvalidCoverage {
2092                reason: "non-complete coverage requires an actionable reason",
2093            });
2094        }
2095        Ok(Self {
2096            scope,
2097            relation,
2098            state,
2099            total,
2100            covered,
2101            omitted,
2102            generation,
2103            reason,
2104            reached_limit,
2105        })
2106    }
2107
2108    /// Borrow the covered project or path scope.
2109    #[must_use]
2110    pub const fn scope(&self) -> &CoverageScope {
2111        &self.scope
2112    }
2113
2114    /// Return the optional relation family covered by this row.
2115    #[must_use]
2116    pub const fn relation(&self) -> Option<GraphRelationKind> {
2117        self.relation
2118    }
2119
2120    /// Return the coverage lifecycle state.
2121    #[must_use]
2122    pub const fn state(&self) -> CoverageState {
2123        self.state
2124    }
2125
2126    /// Return the total number of supported items in scope.
2127    #[must_use]
2128    pub const fn total(&self) -> u64 {
2129        self.total
2130    }
2131
2132    /// Return the number of successfully covered items.
2133    #[must_use]
2134    pub const fn covered(&self) -> u64 {
2135        self.covered
2136    }
2137
2138    /// Return the number of omitted, failed, or untrusted items.
2139    #[must_use]
2140    pub const fn omitted(&self) -> u64 {
2141        self.omitted
2142    }
2143
2144    /// Return the complete generation associated with this row.
2145    #[must_use]
2146    pub const fn generation(&self) -> IndexGeneration {
2147        self.generation
2148    }
2149
2150    /// Borrow the actionable non-complete explanation, when present.
2151    #[must_use]
2152    pub const fn reason(&self) -> Option<&GraphIdentityText> {
2153        self.reason.as_ref()
2154    }
2155
2156    /// Return the product limit reached, when applicable.
2157    #[must_use]
2158    pub const fn reached_limit(&self) -> Option<GraphLimitKind> {
2159        self.reached_limit
2160    }
2161}
2162
2163/// Hard bounded retrieval limits accepted by repository graph consumers.
2164#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2165#[serde(try_from = "GraphLimitsWire", into = "GraphLimitsWire")]
2166pub struct GraphLimits {
2167    /// Maximum returned logical rows.
2168    rows: NonZeroU32,
2169    /// Maximum returned occurrences for one logical relation.
2170    occurrences: NonZeroU32,
2171    /// Maximum traversal depth.
2172    depth: NonZeroU32,
2173    /// Maximum encoded result bytes.
2174    output_bytes: NonZeroU32,
2175}
2176
2177impl GraphLimits {
2178    /// Absolute row ceiling for any one graph request.
2179    pub const MAX_ROWS: u32 = 10_000;
2180    /// Absolute occurrence ceiling for any one logical relation.
2181    pub const MAX_OCCURRENCES: u32 = 1_024;
2182    /// Absolute traversal-depth ceiling.
2183    pub const MAX_DEPTH: u32 = 64;
2184    /// Absolute encoded-output ceiling.
2185    pub const MAX_OUTPUT_BYTES: u32 = 16 * 1_024 * 1_024;
2186
2187    /// Validate result-defining hard limits.
2188    ///
2189    /// # Errors
2190    ///
2191    /// Returns an error for zero values or values above absolute ceilings.
2192    pub fn new(
2193        rows: u32,
2194        occurrences: u32,
2195        depth: u32,
2196        output_bytes: u32,
2197    ) -> Result<Self, GraphContractError> {
2198        if rows == 0 || rows > Self::MAX_ROWS {
2199            return Err(GraphContractError::InvalidLimits {
2200                reason: "row limit is zero or above the absolute ceiling",
2201            });
2202        }
2203        if occurrences == 0 || occurrences > Self::MAX_OCCURRENCES {
2204            return Err(GraphContractError::InvalidLimits {
2205                reason: "occurrence limit is zero or above the absolute ceiling",
2206            });
2207        }
2208        if depth == 0 || depth > Self::MAX_DEPTH {
2209            return Err(GraphContractError::InvalidLimits {
2210                reason: "depth limit is zero or above the absolute ceiling",
2211            });
2212        }
2213        if output_bytes == 0 || output_bytes > Self::MAX_OUTPUT_BYTES {
2214            return Err(GraphContractError::InvalidLimits {
2215                reason: "output limit is zero or above the absolute ceiling",
2216            });
2217        }
2218        Ok(Self {
2219            rows: NonZeroU32::new(rows).ok_or(GraphContractError::InvalidLimits {
2220                reason: "row limit must be nonzero",
2221            })?,
2222            occurrences: NonZeroU32::new(occurrences).ok_or(GraphContractError::InvalidLimits {
2223                reason: "occurrence limit must be nonzero",
2224            })?,
2225            depth: NonZeroU32::new(depth).ok_or(GraphContractError::InvalidLimits {
2226                reason: "depth limit must be nonzero",
2227            })?,
2228            output_bytes: NonZeroU32::new(output_bytes).ok_or(
2229                GraphContractError::InvalidLimits {
2230                    reason: "output limit must be nonzero",
2231                },
2232            )?,
2233        })
2234    }
2235
2236    /// Return the logical-row limit.
2237    #[must_use]
2238    pub const fn rows(self) -> u32 {
2239        self.rows.get()
2240    }
2241
2242    /// Return the per-relation occurrence limit.
2243    #[must_use]
2244    pub const fn occurrences(self) -> u32 {
2245        self.occurrences.get()
2246    }
2247
2248    /// Return the traversal-depth limit.
2249    #[must_use]
2250    pub const fn depth(self) -> u32 {
2251        self.depth.get()
2252    }
2253
2254    /// Return the encoded-output byte limit.
2255    #[must_use]
2256    pub const fn output_bytes(self) -> u32 {
2257        self.output_bytes.get()
2258    }
2259}
2260
2261/// Graph-limit wire shape validated during deserialization.
2262#[derive(Deserialize, Serialize)]
2263struct GraphLimitsWire {
2264    /// Logical-row limit.
2265    rows: u32,
2266    /// Per-relation occurrence limit.
2267    occurrences: u32,
2268    /// Traversal-depth limit.
2269    depth: u32,
2270    /// Encoded-output byte limit.
2271    output_bytes: u32,
2272}
2273
2274impl TryFrom<GraphLimitsWire> for GraphLimits {
2275    type Error = GraphContractError;
2276
2277    fn try_from(value: GraphLimitsWire) -> Result<Self, Self::Error> {
2278        Self::new(
2279            value.rows,
2280            value.occurrences,
2281            value.depth,
2282            value.output_bytes,
2283        )
2284    }
2285}
2286
2287impl From<GraphLimits> for GraphLimitsWire {
2288    fn from(value: GraphLimits) -> Self {
2289        Self {
2290            rows: value.rows(),
2291            occurrences: value.occurrences(),
2292            depth: value.depth(),
2293            output_bytes: value.output_bytes(),
2294        }
2295    }
2296}
2297
2298/// Start a canonical identity with a stable domain and project prefix.
2299fn project_canonical_prefix(domain: &str, project: ProjectInstanceId) -> String {
2300    let mut canonical = domain.to_string();
2301    append_canonical_field(&mut canonical, &project.as_hex());
2302    canonical
2303}
2304
2305/// Return the canonical prefix required for one entity key.
2306fn entity_project_prefix(project: ProjectInstanceId) -> String {
2307    project_canonical_prefix(ENTITY_KEY_DOMAIN, project)
2308}
2309
2310/// Return the canonical prefix required for one relation key.
2311fn relation_project_prefix(project: ProjectInstanceId) -> String {
2312    project_canonical_prefix(RELATION_KEY_DOMAIN, project)
2313}
2314
2315/// Return the canonical prefix required for one resolution key.
2316fn resolution_project_prefix(project: ProjectInstanceId, domain: ResolutionKeyDomain) -> String {
2317    let mut canonical = project_canonical_prefix(RESOLUTION_KEY_DOMAIN, project);
2318    append_canonical_field(&mut canonical, domain.as_str());
2319    canonical
2320}
2321
2322/// Return whether canonical material continues after one complete field prefix.
2323fn has_canonical_prefix(canonical: &str, prefix: &str) -> bool {
2324    canonical
2325        .strip_prefix(prefix)
2326        .is_some_and(|remainder| remainder.starts_with('|'))
2327}
2328
2329/// Encode a typed selector into unambiguous project-qualified material.
2330fn entity_canonical_identity(project: ProjectInstanceId, selector: &EntitySelector) -> String {
2331    let mut canonical = entity_project_prefix(project);
2332    match selector {
2333        EntitySelector::Project => append_canonical_field(&mut canonical, "project"),
2334        EntitySelector::Folder { path } => {
2335            append_canonical_field(&mut canonical, "folder");
2336            append_canonical_field(&mut canonical, path.as_str());
2337        }
2338        EntitySelector::File { path } => {
2339            append_canonical_field(&mut canonical, "file");
2340            append_canonical_field(&mut canonical, path.as_str());
2341        }
2342        EntitySelector::Package { package } => {
2343            append_canonical_field(&mut canonical, "package");
2344            append_canonical_field(&mut canonical, package.manager.as_str());
2345            append_canonical_field(&mut canonical, package.name.as_str());
2346            append_canonical_field(&mut canonical, package.manifest.as_str());
2347        }
2348        EntitySelector::Symbol { symbol } => {
2349            append_canonical_field(&mut canonical, "symbol");
2350            append_canonical_field(&mut canonical, symbol.file.as_str());
2351            append_canonical_field(&mut canonical, symbol.name.as_str());
2352            append_canonical_field(&mut canonical, &symbol.kind.to_string());
2353            append_optional_canonical_field(&mut canonical, symbol.parent.as_ref());
2354            append_canonical_field(&mut canonical, symbol.signature.as_str());
2355        }
2356        EntitySelector::External { external } => {
2357            append_canonical_field(&mut canonical, "external");
2358            append_canonical_field(&mut canonical, external.system.as_str());
2359            append_canonical_field(&mut canonical, external.identity.as_str());
2360        }
2361    }
2362    canonical
2363}
2364
2365/// Append one byte-length-prefixed field to canonical identity material.
2366fn append_canonical_field(canonical: &mut String, value: &str) {
2367    canonical.push('|');
2368    canonical.push_str(&value.len().to_string());
2369    canonical.push(':');
2370    canonical.push_str(value);
2371}
2372
2373/// Validate the exact project-independent field sequence produced by
2374/// [`CanonicalResolutionKey::new`].
2375fn valid_canonical_field_sequence(canonical: &str) -> bool {
2376    let Some(fields) = decode_canonical_fields(canonical) else {
2377        return false;
2378    };
2379    let mut index = 0;
2380    for _required in 0..2 {
2381        let Some(field) = fields.get(index) else {
2382            return false;
2383        };
2384        if !valid_canonical_identity_field(field) {
2385            return false;
2386        }
2387        index += 1;
2388    }
2389    for _optional in 0..3 {
2390        let Some(marker) = fields.get(index) else {
2391            return false;
2392        };
2393        index += 1;
2394        match *marker {
2395            "none" => {}
2396            "some" => {
2397                let Some(field) = fields.get(index) else {
2398                    return false;
2399                };
2400                if !valid_canonical_identity_field(field) {
2401                    return false;
2402                }
2403                index += 1;
2404            }
2405            _ => return false,
2406        }
2407    }
2408    fields
2409        .get(index)
2410        .is_some_and(|field| valid_canonical_identity_field(field))
2411        && index + 1 == fields.len()
2412}
2413
2414/// Decode one complete sequence of byte-length-prefixed canonical fields.
2415fn decode_canonical_fields(mut canonical: &str) -> Option<Vec<&str>> {
2416    let mut fields = Vec::new();
2417    while !canonical.is_empty() {
2418        canonical = canonical.strip_prefix('|')?;
2419        let separator = canonical.find(':')?;
2420        let length_text = canonical.get(..separator)?;
2421        let length = length_text.parse::<usize>().ok()?;
2422        if length_text != length.to_string() {
2423            return None;
2424        }
2425        canonical = canonical.get(separator + 1..)?;
2426        if length == 0
2427            || length > MAX_GRAPH_IDENTITY_BYTES
2428            || length > canonical.len()
2429            || !canonical.is_char_boundary(length)
2430        {
2431            return None;
2432        }
2433        let (field, remainder) = canonical.split_at(length);
2434        fields.push(field);
2435        canonical = remainder;
2436    }
2437    Some(fields)
2438}
2439
2440/// Apply the original identity-field constraints to portable canonical data.
2441fn valid_canonical_identity_field(value: &str) -> bool {
2442    !value.is_empty()
2443        && value.len() <= MAX_GRAPH_IDENTITY_BYTES
2444        && !value.chars().any(char::is_control)
2445}
2446
2447/// Append an optional identity field without conflating absent and empty values.
2448fn append_optional_canonical_field(canonical: &mut String, value: Option<&GraphIdentityText>) {
2449    match value {
2450        Some(value) => {
2451            append_canonical_field(canonical, "some");
2452            append_canonical_field(canonical, value.as_str());
2453        }
2454        None => append_canonical_field(canonical, "none"),
2455    }
2456}
2457
2458/// Append optional validated contract text without conflating absent and empty values.
2459fn append_optional_raw_canonical_field(canonical: &mut String, value: Option<&str>) {
2460    match value {
2461        Some(value) => {
2462            append_canonical_field(canonical, "some");
2463            append_canonical_field(canonical, value);
2464        }
2465        None => append_canonical_field(canonical, "none"),
2466    }
2467}
2468
2469/// Encode bytes as lowercase hexadecimal without another dependency.
2470fn encode_hex(bytes: &[u8]) -> String {
2471    const HEX: &[u8; 16] = b"0123456789abcdef";
2472    let mut encoded = String::with_capacity(bytes.len() * 2);
2473    for byte in bytes {
2474        encoded.push(char::from(HEX[usize::from(byte >> 4)]));
2475        encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
2476    }
2477    encoded
2478}
2479
2480/// Decode one ASCII hexadecimal digit.
2481const fn decode_hex(value: u8) -> Option<u8> {
2482    match value {
2483        b'0'..=b'9' => Some(value - b'0'),
2484        b'a'..=b'f' => Some(value - b'a' + 10),
2485        b'A'..=b'F' => Some(value - b'A' + 10),
2486        _ => None,
2487    }
2488}
2489
2490#[cfg(test)]
2491mod tests {
2492    use super::{
2493        CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
2494        CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
2495        ExtendedRelationKind, ExternalSelector, GraphContractError, GraphEntity, GraphEntityKey,
2496        GraphIdentityField, GraphIdentityRejection, GraphIdentityRejectionReason,
2497        GraphIdentityText, GraphLimitKind, GraphLimits, GraphRelationKind, LogicalRelation,
2498        PackageSelector, PortableResolutionKey, ProjectInstanceId, RelationDependencyKey,
2499        RelationOccurrence, RelationResolution, RepositoryFilePath, RepositoryNodePath,
2500        ResolutionKeyDomain, ReusableTargetSelector, SourceSpan, StableKey, SymbolSelector,
2501    };
2502    use crate::IndexGeneration;
2503    use crate::symbols::{ParserKind, RelationKind, SymbolKind};
2504    use std::io;
2505    use std::num::NonZeroU32;
2506    use std::path::Path;
2507
2508    /// Return a stable nonzero project identity for unit tests.
2509    fn project() -> Result<ProjectInstanceId, GraphContractError> {
2510        ProjectInstanceId::try_from("00112233445566778899aabbccddeeff")
2511    }
2512
2513    /// Return one stable function selector for unit tests.
2514    fn symbol_selector(path: &str) -> Result<SymbolSelector, GraphContractError> {
2515        Ok(SymbolSelector {
2516            file: RepositoryFilePath::new(Path::new(path))?,
2517            name: GraphIdentityText::new("répond")?,
2518            kind: SymbolKind::Function,
2519            parent: Some(GraphIdentityText::new("Service")?),
2520            signature: GraphIdentityText::new("fn répond(input: &str)")?,
2521        })
2522    }
2523
2524    /// Return an ordinary test error when a behavior condition is false.
2525    fn require(condition: bool, message: &'static str) -> Result<(), Box<dyn std::error::Error>> {
2526        if condition {
2527            Ok(())
2528        } else {
2529            Err(io::Error::other(message).into())
2530        }
2531    }
2532
2533    #[test]
2534    fn project_identity_validates_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
2535        let id = ProjectInstanceId::try_from("00112233-4455-6677-8899-AABBCCDDEEFF")?;
2536        require(
2537            id.as_hex() == "00112233445566778899aabbccddeeff",
2538            "project identity did not normalize to lowercase hexadecimal",
2539        )?;
2540        let encoded = serde_json::to_string(&id)?;
2541        require(
2542            encoded == "\"00112233445566778899aabbccddeeff\"",
2543            "project identity serialized with an unstable shape",
2544        )?;
2545        require(
2546            serde_json::from_str::<ProjectInstanceId>(&encoded)? == id,
2547            "project identity did not round-trip",
2548        )?;
2549        require(
2550            ProjectInstanceId::try_from("00000000000000000000000000000000").is_err(),
2551            "zero project identity was accepted",
2552        )?;
2553        require(
2554            ProjectInstanceId::try_from("not-an-id").is_err(),
2555            "malformed project identity was accepted",
2556        )?;
2557        require(
2558            ProjectInstanceId::try_from("00112233-4455-6677-8899-AABBCCDDEEF-").is_err(),
2559            "hyphenated project identity with missing hexadecimal data was accepted",
2560        )?;
2561        Ok(())
2562    }
2563
2564    #[test]
2565    fn entity_keys_are_stable_line_independent_and_project_scoped()
2566    -> Result<(), Box<dyn std::error::Error>> {
2567        let selector = EntitySelector::Symbol {
2568            symbol: symbol_selector("src\\service.rs")?,
2569        };
2570        let first = GraphEntity::new(project()?, selector.clone(), IndexGeneration::new(4))?;
2571        let rescanned = GraphEntity::new(project()?, selector, IndexGeneration::new(5))?;
2572        require(
2573            first.key().reconcile(rescanned.key())?,
2574            "unchanged selector did not retain identity",
2575        )?;
2576        require(
2577            first.key().digest() == rescanned.key().digest(),
2578            "unchanged selector changed its compact key",
2579        )?;
2580        require(
2581            first.key().digest_bytes()?
2582                == *blake3::hash(first.key().canonical_identity().as_bytes()).as_bytes(),
2583            "binary persistence key did not match the validated digest",
2584        )?;
2585
2586        let other_project = ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?;
2587        let other = GraphEntityKey::new(other_project, rescanned.selector());
2588        require(
2589            !first.key().reconcile(&other)?,
2590            "independent projects shared an entity identity",
2591        )?;
2592        require(
2593            first.key().digest() != other.digest(),
2594            "independent projects shared a compact entity key",
2595        )?;
2596        Ok(())
2597    }
2598
2599    #[test]
2600    fn stable_key_collisions_fail_closed() -> Result<(), Box<dyn std::error::Error>> {
2601        let selector = EntitySelector::File {
2602            path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
2603        };
2604        let first = GraphEntityKey::new(project()?, &selector);
2605        let conflicting = GraphEntityKey {
2606            project: first.project,
2607            stable: StableKey {
2608                digest: first.stable.digest.clone(),
2609                canonical_identity: "different canonical identity".to_string(),
2610            },
2611        };
2612        require(
2613            matches!(
2614                first.reconcile(&conflicting),
2615                Err(GraphContractError::StableKeyCollision { .. })
2616            ),
2617            "conflicting canonical identity did not fail closed",
2618        )?;
2619
2620        let mut serialized = serde_json::to_value(&first)?;
2621        serialized["stable"]["canonical_identity"] = serde_json::json!("tampered");
2622        require(
2623            serde_json::from_value::<GraphEntityKey>(serialized).is_err(),
2624            "tampered stable-key material was accepted",
2625        )?;
2626        Ok(())
2627    }
2628
2629    #[test]
2630    fn canonical_resolution_keys_are_stable_qualified_and_collision_checked()
2631    -> Result<(), Box<dyn std::error::Error>> {
2632        let provider = GraphIdentityText::new("tree-sitter")?;
2633        let language = GraphIdentityText::new("rust")?;
2634        let package = GraphIdentityText::new("auth")?;
2635        let scope = GraphIdentityText::new("crate")?;
2636        let identity = GraphIdentityText::new("répond")?;
2637        let key = CanonicalResolutionKey::new(
2638            project()?,
2639            ResolutionKeyDomain::Declaration,
2640            &provider,
2641            &language,
2642            Some(&package),
2643            Some(&scope),
2644            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2645            &identity,
2646        );
2647        let repeated = CanonicalResolutionKey::new(
2648            project()?,
2649            ResolutionKeyDomain::Declaration,
2650            &provider,
2651            &language,
2652            Some(&package),
2653            Some(&scope),
2654            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2655            &identity,
2656        );
2657        require(
2658            key.reconcile(&repeated)?,
2659            "equal canonical resolver input changed identity",
2660        )?;
2661        require(
2662            key.digest_bytes() == *blake3::hash(key.canonical_identity().as_bytes()).as_bytes(),
2663            "fixed resolver digest did not match its collision witness",
2664        )?;
2665        let persisted = CanonicalResolutionKey::from_persisted(
2666            key.project(),
2667            key.domain(),
2668            key.digest_bytes(),
2669            key.canonical_identity().to_string(),
2670        )?;
2671        require(
2672            key.reconcile(&persisted)?,
2673            "validated persisted resolver key changed identity",
2674        )?;
2675
2676        let other_project = ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?;
2677        let case_distinct = GraphIdentityText::new("Répond")?;
2678        let variants = [
2679            CanonicalResolutionKey::new(
2680                other_project,
2681                ResolutionKeyDomain::Declaration,
2682                &provider,
2683                &language,
2684                Some(&package),
2685                Some(&scope),
2686                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2687                &identity,
2688            ),
2689            CanonicalResolutionKey::new(
2690                project()?,
2691                ResolutionKeyDomain::Module,
2692                &provider,
2693                &language,
2694                Some(&package),
2695                Some(&scope),
2696                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2697                &identity,
2698            ),
2699            CanonicalResolutionKey::new(
2700                project()?,
2701                ResolutionKeyDomain::Declaration,
2702                &GraphIdentityText::new("manifest")?,
2703                &language,
2704                Some(&package),
2705                Some(&scope),
2706                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2707                &identity,
2708            ),
2709            CanonicalResolutionKey::new(
2710                project()?,
2711                ResolutionKeyDomain::Declaration,
2712                &provider,
2713                &GraphIdentityText::new("typescript")?,
2714                Some(&package),
2715                Some(&scope),
2716                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2717                &identity,
2718            ),
2719            CanonicalResolutionKey::new(
2720                project()?,
2721                ResolutionKeyDomain::Declaration,
2722                &provider,
2723                &language,
2724                Some(&GraphIdentityText::new("billing")?),
2725                Some(&scope),
2726                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2727                &identity,
2728            ),
2729            CanonicalResolutionKey::new(
2730                project()?,
2731                ResolutionKeyDomain::Declaration,
2732                &provider,
2733                &language,
2734                Some(&package),
2735                Some(&GraphIdentityText::new("module")?),
2736                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2737                &identity,
2738            ),
2739            CanonicalResolutionKey::new(
2740                project()?,
2741                ResolutionKeyDomain::Declaration,
2742                &provider,
2743                &language,
2744                Some(&package),
2745                Some(&scope),
2746                Some(GraphRelationKind::Legacy(RelationKind::Imports)),
2747                &identity,
2748            ),
2749            CanonicalResolutionKey::new(
2750                project()?,
2751                ResolutionKeyDomain::Declaration,
2752                &provider,
2753                &language,
2754                Some(&package),
2755                Some(&scope),
2756                Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2757                &case_distinct,
2758            ),
2759        ];
2760        for variant in variants {
2761            require(
2762                !key.reconcile(&variant)?,
2763                "identity-affecting resolver dimension was ignored",
2764            )?;
2765        }
2766        require(
2767            matches!(
2768                ResolutionKeyDomain::try_from("declaration"),
2769                Ok(ResolutionKeyDomain::Declaration)
2770            ) && ResolutionKeyDomain::try_from("unknown").is_err(),
2771            "closed resolver-domain persistence accepted an unsupported value",
2772        )?;
2773
2774        let conflicting = CanonicalResolutionKey {
2775            canonical_identity: "different canonical identity".to_string(),
2776            ..key
2777        };
2778        require(
2779            matches!(
2780                key.reconcile(&conflicting),
2781                Err(GraphContractError::StableKeyCollision { .. })
2782            ),
2783            "equal resolver digest with a different witness did not fail closed",
2784        )?;
2785        let mut serialized = serde_json::to_value(&key)?;
2786        serialized["canonical_identity"] = serde_json::json!("tampered");
2787        require(
2788            serde_json::from_value::<CanonicalResolutionKey>(serialized).is_err(),
2789            "tampered persisted resolver key was accepted",
2790        )?;
2791        Ok(())
2792    }
2793
2794    #[test]
2795    fn portable_resolution_keys_rebind_without_project_identity()
2796    -> Result<(), Box<dyn std::error::Error>> {
2797        let provider = GraphIdentityText::new("tree-sitter")?;
2798        let language = GraphIdentityText::new("rust")?;
2799        let package = GraphIdentityText::new("auth")?;
2800        let scope = GraphIdentityText::new("crate")?;
2801        let identity = GraphIdentityText::new("répond")?;
2802        let source = CanonicalResolutionKey::new(
2803            project()?,
2804            ResolutionKeyDomain::Declaration,
2805            &provider,
2806            &language,
2807            Some(&package),
2808            Some(&scope),
2809            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2810            &identity,
2811        );
2812        let portable = source.portable()?;
2813        let encoded = serde_json::to_string(&portable)?;
2814        require(
2815            !encoded.contains(&project()?.as_hex()),
2816            "portable resolver key leaked its source project identity",
2817        )?;
2818        let decoded = serde_json::from_str::<PortableResolutionKey>(&encoded)?;
2819        let destination = ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?;
2820        let rebound = decoded.bind(destination);
2821        let expected = CanonicalResolutionKey::new(
2822            destination,
2823            ResolutionKeyDomain::Declaration,
2824            &provider,
2825            &language,
2826            Some(&package),
2827            Some(&scope),
2828            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2829            &identity,
2830        );
2831        require(
2832            rebound.reconcile(&expected)?,
2833            "portable resolver key did not rebind to the destination project",
2834        )?;
2835
2836        let mut invalid = serde_json::to_value(&portable)?;
2837        invalid["canonical_identity"] = serde_json::json!("|04:rust");
2838        require(
2839            serde_json::from_value::<PortableResolutionKey>(invalid).is_err(),
2840            "non-canonical portable resolver material was accepted",
2841        )?;
2842        Ok(())
2843    }
2844
2845    #[test]
2846    fn resolution_key_bindings_preserve_dependency_identity_across_states()
2847    -> Result<(), Box<dyn std::error::Error>> {
2848        let generation = IndexGeneration::new(7);
2849        let source = GraphEntity::new(
2850            project()?,
2851            EntitySelector::Symbol {
2852                symbol: symbol_selector("src/caller.rs")?,
2853            },
2854            generation,
2855        )?;
2856        let target = GraphEntity::new(
2857            project()?,
2858            EntitySelector::Symbol {
2859                symbol: symbol_selector("src/service.rs")?,
2860            },
2861            generation,
2862        )?;
2863        let dependency = CanonicalResolutionKey::new(
2864            project()?,
2865            ResolutionKeyDomain::Declaration,
2866            &GraphIdentityText::new("tree-sitter")?,
2867            &GraphIdentityText::new("rust")?,
2868            None,
2869            None,
2870            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2871            &GraphIdentityText::new("répond")?,
2872        );
2873        let export = EntityResolutionKey::new(target.key().clone(), dependency.clone())?;
2874        require(
2875            export.key().reconcile(&dependency)?,
2876            "export binding changed its canonical resolver identity",
2877        )?;
2878
2879        let relations = [
2880            LogicalRelation::new(
2881                &source,
2882                GraphRelationKind::Legacy(RelationKind::Calls),
2883                RelationResolution::resolved(&target)?,
2884                ConfidenceClass::Exact,
2885                Completeness::Complete,
2886                generation,
2887            )?,
2888            LogicalRelation::new(
2889                &source,
2890                GraphRelationKind::Legacy(RelationKind::Calls),
2891                RelationResolution::Ambiguous {
2892                    reference: GraphIdentityText::new("répond")?,
2893                    candidates: NonZeroU32::new(2).ok_or("nonzero candidate fixture")?,
2894                },
2895                ConfidenceClass::High,
2896                Completeness::Complete,
2897                generation,
2898            )?,
2899            LogicalRelation::new(
2900                &source,
2901                GraphRelationKind::Legacy(RelationKind::Calls),
2902                RelationResolution::Unresolved {
2903                    reference: GraphIdentityText::new("répond")?,
2904                },
2905                ConfidenceClass::Low,
2906                Completeness::Complete,
2907                generation,
2908            )?,
2909        ];
2910        for relation in relations {
2911            let binding = RelationDependencyKey::new(relation.key().clone(), dependency.clone())?;
2912            require(
2913                binding.key().reconcile(&dependency)?,
2914                "resolution state changed the retained dependency identity",
2915            )?;
2916        }
2917
2918        let foreign = CanonicalResolutionKey::new(
2919            ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?,
2920            ResolutionKeyDomain::Declaration,
2921            &GraphIdentityText::new("tree-sitter")?,
2922            &GraphIdentityText::new("rust")?,
2923            None,
2924            None,
2925            Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2926            &GraphIdentityText::new("répond")?,
2927        );
2928        require(
2929            matches!(
2930                EntityResolutionKey::new(target.key().clone(), foreign),
2931                Err(GraphContractError::ResolutionKeyOwnerMismatch)
2932            ),
2933            "cross-project resolver binding was accepted",
2934        )?;
2935        Ok(())
2936    }
2937
2938    #[test]
2939    fn selectors_normalize_slashes_and_preserve_unicode_and_case()
2940    -> Result<(), Box<dyn std::error::Error>> {
2941        let upper = EntitySelector::Symbol {
2942            symbol: symbol_selector("Src\\Über.rs")?,
2943        };
2944        let lower = EntitySelector::Symbol {
2945            symbol: symbol_selector("src/Über.rs")?,
2946        };
2947        let encoded = serde_json::to_string(&upper)?;
2948        let decoded: EntitySelector = serde_json::from_str(&encoded)?;
2949        require(decoded == upper, "Unicode selector did not round-trip")?;
2950        require(
2951            encoded.contains("Src/Über.rs"),
2952            "selector changed Unicode or path case",
2953        )?;
2954        require(
2955            GraphEntityKey::new(project()?, &upper) != GraphEntityKey::new(project()?, &lower),
2956            "case-distinct repository paths collapsed",
2957        )?;
2958
2959        let slash_variant = EntitySelector::File {
2960            path: RepositoryFilePath::new(Path::new("src\\lib.rs"))?,
2961        };
2962        let normalized = EntitySelector::File {
2963            path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
2964        };
2965        require(
2966            GraphEntityKey::new(project()?, &slash_variant)
2967                == GraphEntityKey::new(project()?, &normalized),
2968            "slash variants did not normalize to one entity",
2969        )?;
2970        Ok(())
2971    }
2972
2973    #[test]
2974    fn invalid_selector_inputs_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
2975        require(
2976            RepositoryFilePath::new(Path::new("../secret.rs")).is_err(),
2977            "parent traversal was accepted",
2978        )?;
2979        require(
2980            RepositoryFilePath::new(Path::new(".")).is_err(),
2981            "project root was accepted as a file",
2982        )?;
2983        require(
2984            RepositoryNodePath::new(Path::new("C:/repo")).is_err(),
2985            "absolute Windows path was accepted",
2986        )?;
2987        require(
2988            GraphIdentityText::new(" ").is_err(),
2989            "blank identity text was accepted",
2990        )?;
2991        require(
2992            GraphIdentityText::new(" padded").is_err()
2993                && GraphIdentityText::new("padded ").is_err(),
2994            "surrounding identity whitespace was accepted",
2995        )?;
2996        require(
2997            GraphIdentityText::new("bad\nidentity").is_err(),
2998            "control-bearing identity text was accepted",
2999        )?;
3000        require(
3001            GraphIdentityText::new("x".repeat(super::MAX_GRAPH_IDENTITY_BYTES + 1)).is_err(),
3002            "oversized identity text was accepted",
3003        )?;
3004        require(
3005            SourceSpan::new(0, 0, 1, 0).is_err(),
3006            "zero source line was accepted",
3007        )?;
3008        require(
3009            SourceSpan::new(4, 2, 3, 9).is_err(),
3010            "reversed source span was accepted",
3011        )?;
3012        require(
3013            GraphEntity::new(
3014                project()?,
3015                EntitySelector::File {
3016                    path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3017                },
3018                IndexGeneration::ZERO,
3019            )
3020            .is_err(),
3021            "pre-publication generation was accepted for a graph entity",
3022        )?;
3023
3024        let package = EntitySelector::Package {
3025            package: PackageSelector {
3026                manager: GraphIdentityText::new("cargo")?,
3027                name: GraphIdentityText::new("projectatlas-core")?,
3028                manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
3029            },
3030        };
3031        require(
3032            package == serde_json::from_str(&serde_json::to_string(&package)?)?,
3033            "package selector did not round-trip",
3034        )?;
3035        Ok(())
3036    }
3037
3038    #[test]
3039    fn graph_identity_rejection_wire_names_are_typed_and_stable()
3040    -> Result<(), Box<dyn std::error::Error>> {
3041        require(
3042            serde_json::to_string(&GraphIdentityField::RelationSource)? == r#""relation.source""#,
3043            "relation-source field changed its dotted wire name",
3044        )?;
3045        require(
3046            serde_json::from_str::<GraphIdentityField>(r#""relation.target""#)?
3047                == GraphIdentityField::RelationTarget,
3048            "relation-target field lost its dotted wire name",
3049        )?;
3050        let rejection = GraphIdentityRejection {
3051            path: RepositoryNodePath::new(Path::new("src/lib.rs"))?,
3052            span: SourceSpan::new(2, 0, 2, 8)?,
3053            parser: ParserKind::TreeSitter,
3054            field: GraphIdentityField::RelationTarget,
3055            reason: GraphIdentityRejectionReason::ControlCharacters,
3056            fact_index: 0,
3057        };
3058        require(
3059            serde_json::from_str::<GraphIdentityRejection>(&serde_json::to_string(&rejection)?)?
3060                == rejection,
3061            "typed graph rejection did not round-trip",
3062        )?;
3063        Ok(())
3064    }
3065
3066    #[test]
3067    fn graph_limit_kind_inventory_and_stable_names_stay_exhaustive()
3068    -> Result<(), Box<dyn std::error::Error>> {
3069        let serialized = serde_json::to_string(&GraphLimitKind::ALL)?;
3070        require(
3071            serialized
3072                == r#"["rows","nodes","edges","occurrences","visited","intermediate_bytes","deadline","depth","output_bytes"]"#,
3073            "graph limit stable protocol drifted without an explicit migration",
3074        )?;
3075        let mut names = std::collections::BTreeSet::new();
3076        for kind in GraphLimitKind::ALL {
3077            let stable_name = kind.as_str();
3078            require(
3079                names.insert(stable_name),
3080                "graph limit inventory contains a duplicate stable name",
3081            )?;
3082            let encoded = serde_json::to_string(&kind)?;
3083            require(
3084                encoded == format!("\"{stable_name}\""),
3085                "graph limit serde spelling drifted from its stable name",
3086            )?;
3087            require(
3088                serde_json::from_str::<GraphLimitKind>(&encoded)? == kind,
3089                "graph limit serde value did not round-trip",
3090            )?;
3091            require(
3092                GraphLimitKind::from_stable_name(stable_name) == Some(kind),
3093                "graph limit stable-name parsing did not round-trip",
3094            )?;
3095        }
3096        require(
3097            GraphLimitKind::from_stable_name("unknown").is_none(),
3098            "unknown graph limit stable name was accepted",
3099        )?;
3100        Ok(())
3101    }
3102
3103    #[test]
3104    fn logical_relations_deduplicate_distinct_source_occurrences()
3105    -> Result<(), Box<dyn std::error::Error>> {
3106        let source = GraphEntity::new(
3107            project()?,
3108            EntitySelector::Symbol {
3109                symbol: symbol_selector("src/caller.rs")?,
3110            },
3111            IndexGeneration::new(7),
3112        )?;
3113        let target_symbol = symbol_selector("src/target.rs")?;
3114        let target = GraphEntity::new(
3115            project()?,
3116            EntitySelector::Symbol {
3117                symbol: target_symbol,
3118            },
3119            IndexGeneration::new(7),
3120        )?;
3121        let relation = LogicalRelation::new(
3122            &source,
3123            GraphRelationKind::from_legacy(RelationKind::Calls),
3124            RelationResolution::resolved(&target)?,
3125            ConfidenceClass::Exact,
3126            Completeness::Complete,
3127            IndexGeneration::new(7),
3128        )?;
3129        let first = RelationOccurrence::new(
3130            &relation,
3131            RepositoryFilePath::new(Path::new("src/caller.rs"))?,
3132            SourceSpan::new(10, 4, 10, 18)?,
3133            IndexGeneration::new(7),
3134        )?;
3135        let second = RelationOccurrence::new(
3136            &relation,
3137            RepositoryFilePath::new(Path::new("src/caller.rs"))?,
3138            SourceSpan::new(20, 4, 20, 18)?,
3139            IndexGeneration::new(7),
3140        )?;
3141        require(
3142            first.relation() == second.relation(),
3143            "occurrences did not retain one logical relation",
3144        )?;
3145        require(
3146            first.span() != second.span(),
3147            "distinct relation occurrences lost their source spans",
3148        )?;
3149        Ok(())
3150    }
3151
3152    #[test]
3153    fn relation_records_reject_inconsistent_identity_resolution_and_generation()
3154    -> Result<(), Box<dyn std::error::Error>> {
3155        let source = GraphEntity::new(
3156            project()?,
3157            EntitySelector::Symbol {
3158                symbol: symbol_selector("src/caller.rs")?,
3159            },
3160            IndexGeneration::new(3),
3161        )?;
3162        let target_symbol = symbol_selector("src/target.rs")?;
3163        let target = GraphEntity::new(
3164            project()?,
3165            EntitySelector::Symbol {
3166                symbol: target_symbol.clone(),
3167            },
3168            IndexGeneration::new(4),
3169        )?;
3170        require(
3171            matches!(
3172                LogicalRelation::new(
3173                    &source,
3174                    GraphRelationKind::from_legacy(RelationKind::Calls),
3175                    RelationResolution::Unresolved {
3176                        reference: GraphIdentityText::new("missing")?,
3177                    },
3178                    ConfidenceClass::Low,
3179                    Completeness::Complete,
3180                    IndexGeneration::new(4),
3181                ),
3182                Err(GraphContractError::GenerationMismatch { .. })
3183            ),
3184            "mixed source generation was accepted",
3185        )?;
3186        require(
3187            matches!(
3188                LogicalRelation::new(
3189                    &source,
3190                    GraphRelationKind::from_legacy(RelationKind::Calls),
3191                    RelationResolution::resolved(&target)?,
3192                    ConfidenceClass::Exact,
3193                    Completeness::Complete,
3194                    IndexGeneration::new(3),
3195                ),
3196                Err(GraphContractError::GenerationMismatch { .. })
3197            ),
3198            "mixed target generation was accepted",
3199        )?;
3200        let target = GraphEntity::new(
3201            project()?,
3202            EntitySelector::Symbol {
3203                symbol: target_symbol,
3204            },
3205            IndexGeneration::new(3),
3206        )?;
3207        let relation = LogicalRelation::new(
3208            &source,
3209            GraphRelationKind::from_legacy(RelationKind::Calls),
3210            RelationResolution::resolved(&target)?,
3211            ConfidenceClass::Exact,
3212            Completeness::Complete,
3213            IndexGeneration::new(3),
3214        )?;
3215        let encoded = serde_json::to_string(relation.key())?;
3216        let decoded = serde_json::from_str::<super::LogicalRelationKey>(&encoded)?;
3217        require(
3218            relation.key().reconcile(&decoded)?,
3219            "validated logical relation key did not round-trip",
3220        )?;
3221        require(
3222            matches!(
3223                RelationOccurrence::new(
3224                    &relation,
3225                    RepositoryFilePath::new(Path::new("src/caller.rs"))?,
3226                    SourceSpan::new(2, 0, 2, 8)?,
3227                    IndexGeneration::new(4),
3228                ),
3229                Err(GraphContractError::GenerationMismatch { .. })
3230            ),
3231            "mixed occurrence generation was accepted",
3232        )?;
3233        require(
3234            matches!(
3235                LogicalRelation::new(
3236                    &source,
3237                    GraphRelationKind::from_legacy(RelationKind::Calls),
3238                    RelationResolution::Resolved {
3239                        target: target.key().clone(),
3240                        selector: ReusableTargetSelector::Symbol {
3241                            symbol: symbol_selector("src/wrong.rs")?,
3242                        },
3243                        generation: IndexGeneration::new(3),
3244                    },
3245                    ConfidenceClass::Exact,
3246                    Completeness::Complete,
3247                    IndexGeneration::new(3),
3248                ),
3249                Err(GraphContractError::InvalidResolution { .. })
3250            ),
3251            "selector for an unrelated entity was accepted",
3252        )?;
3253
3254        let other_project_target = GraphEntity::new(
3255            ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?,
3256            EntitySelector::Symbol {
3257                symbol: symbol_selector("src/target.rs")?,
3258            },
3259            IndexGeneration::new(3),
3260        )?;
3261        require(
3262            matches!(
3263                LogicalRelation::new(
3264                    &source,
3265                    GraphRelationKind::from_legacy(RelationKind::Calls),
3266                    RelationResolution::resolved(&other_project_target)?,
3267                    ConfidenceClass::Exact,
3268                    Completeness::Complete,
3269                    IndexGeneration::new(3),
3270                ),
3271                Err(GraphContractError::CrossProjectRelation)
3272            ),
3273            "cross-project relation was accepted without federation",
3274        )?;
3275
3276        let external_selector = ExternalSelector {
3277            system: GraphIdentityText::new("cargo")?,
3278            identity: GraphIdentityText::new("serde")?,
3279        };
3280        let external_target = GraphEntity::new(
3281            project()?,
3282            EntitySelector::External {
3283                external: external_selector.clone(),
3284            },
3285            IndexGeneration::new(3),
3286        )?;
3287        LogicalRelation::new(
3288            &source,
3289            GraphRelationKind::from_legacy(RelationKind::DependsOn),
3290            RelationResolution::external(&external_target)?,
3291            ConfidenceClass::Exact,
3292            Completeness::Complete,
3293            IndexGeneration::new(3),
3294        )?;
3295        require(
3296            matches!(
3297                LogicalRelation::new(
3298                    &source,
3299                    GraphRelationKind::from_legacy(RelationKind::DependsOn),
3300                    RelationResolution::External {
3301                        target: external_target.key().clone(),
3302                        external: ExternalSelector {
3303                            system: external_selector.system,
3304                            identity: GraphIdentityText::new("different")?,
3305                        },
3306                        generation: IndexGeneration::new(3),
3307                    },
3308                    ConfidenceClass::Exact,
3309                    Completeness::Complete,
3310                    IndexGeneration::new(3),
3311                ),
3312                Err(GraphContractError::InvalidResolution { .. })
3313            ),
3314            "mismatched external identity was accepted",
3315        )?;
3316
3317        let mut serialized = serde_json::to_value(relation.key())?;
3318        serialized["stable"]["canonical_identity"] =
3319            serde_json::json!(source.key().canonical_identity());
3320        serialized["stable"]["digest"] = serde_json::json!(
3321            blake3::hash(source.key().canonical_identity().as_bytes())
3322                .to_hex()
3323                .as_str()
3324        );
3325        require(
3326            serde_json::from_value::<super::LogicalRelationKey>(serialized).is_err(),
3327            "entity namespace was accepted as a relation key",
3328        )?;
3329
3330        let file_key = GraphEntityKey::new(
3331            project()?,
3332            &EntitySelector::File {
3333                path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3334            },
3335        );
3336        let mut serialized = serde_json::to_value(&file_key)?;
3337        let malformed = format!("{}suffix", super::entity_project_prefix(project()?));
3338        serialized["stable"]["canonical_identity"] = serde_json::json!(&malformed);
3339        serialized["stable"]["digest"] =
3340            serde_json::json!(blake3::hash(malformed.as_bytes()).to_hex().as_str());
3341        require(
3342            serde_json::from_value::<GraphEntityKey>(serialized).is_err(),
3343            "partial project prefix was accepted as typed entity identity",
3344        )?;
3345        Ok(())
3346    }
3347
3348    #[test]
3349    fn ambiguous_candidate_counts_do_not_change_logical_relation_identity()
3350    -> Result<(), Box<dyn std::error::Error>> {
3351        let source = GraphEntity::new(
3352            project()?,
3353            EntitySelector::Symbol {
3354                symbol: symbol_selector("src/caller.rs")?,
3355            },
3356            IndexGeneration::new(11),
3357        )?;
3358        let relation = |candidates| {
3359            LogicalRelation::new(
3360                &source,
3361                GraphRelationKind::Extended(ExtendedRelationKind::References),
3362                RelationResolution::Ambiguous {
3363                    reference: GraphIdentityText::new("handler")?,
3364                    candidates: NonZeroU32::new(candidates).ok_or(
3365                        GraphContractError::InvalidCoverage {
3366                            reason: "test candidate count must be nonzero",
3367                        },
3368                    )?,
3369                },
3370                ConfidenceClass::High,
3371                Completeness::Complete,
3372                IndexGeneration::new(11),
3373            )
3374        };
3375        let first = relation(2)?;
3376        let second = relation(3)?;
3377        require(
3378            first.key().reconcile(second.key())?,
3379            "candidate-count metadata changed logical relation identity",
3380        )?;
3381        Ok(())
3382    }
3383
3384    #[test]
3385    fn relation_resolution_and_target_selectors_round_trip()
3386    -> Result<(), Box<dyn std::error::Error>> {
3387        let selector = ReusableTargetSelector::Symbol {
3388            symbol: symbol_selector("src/lib.rs")?,
3389        };
3390        let encoded = serde_json::to_string(&selector)?;
3391        require(
3392            serde_json::from_str::<ReusableTargetSelector>(&encoded)? == selector,
3393            "exact target selector did not round-trip",
3394        )?;
3395
3396        let cases = [
3397            (
3398                EntitySelector::Folder {
3399                    path: RepositoryNodePath::new(Path::new("src"))?,
3400                },
3401                ReusableTargetSelector::Folder {
3402                    folder: RepositoryNodePath::new(Path::new("src"))?,
3403                },
3404            ),
3405            (
3406                EntitySelector::File {
3407                    path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3408                },
3409                ReusableTargetSelector::File {
3410                    file: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3411                },
3412            ),
3413            (
3414                EntitySelector::Package {
3415                    package: PackageSelector {
3416                        manager: GraphIdentityText::new("cargo")?,
3417                        name: GraphIdentityText::new("projectatlas-core")?,
3418                        manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
3419                    },
3420                },
3421                ReusableTargetSelector::Package {
3422                    package: PackageSelector {
3423                        manager: GraphIdentityText::new("cargo")?,
3424                        name: GraphIdentityText::new("projectatlas-core")?,
3425                        manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
3426                    },
3427                },
3428            ),
3429            (
3430                EntitySelector::Symbol {
3431                    symbol: symbol_selector("src/lib.rs")?,
3432                },
3433                selector,
3434            ),
3435        ];
3436        for (entity_selector, expected) in cases {
3437            let entity = GraphEntity::new(project()?, entity_selector, IndexGeneration::new(5))?;
3438            require(
3439                ReusableTargetSelector::for_entity(&entity)? == expected,
3440                "entity-derived reusable selector drifted",
3441            )?;
3442        }
3443
3444        let project_entity =
3445            GraphEntity::new(project()?, EntitySelector::Project, IndexGeneration::new(5))?;
3446        require(
3447            RelationResolution::resolved(&project_entity).is_err(),
3448            "project aggregate was exposed as a direct source target",
3449        )?;
3450        let external_entity = GraphEntity::new(
3451            project()?,
3452            EntitySelector::External {
3453                external: ExternalSelector {
3454                    system: GraphIdentityText::new("cargo")?,
3455                    identity: GraphIdentityText::new("serde")?,
3456                },
3457            },
3458            IndexGeneration::new(5),
3459        )?;
3460        let external = RelationResolution::external(&external_entity)?;
3461        require(
3462            external.resolved_target().is_none(),
3463            "external resolution exposed a traversable local target",
3464        )?;
3465        require(
3466            RelationResolution::external(&project_entity).is_err(),
3467            "non-external entity was accepted as an external resolution",
3468        )?;
3469
3470        let unresolved = RelationResolution::Unresolved {
3471            reference: GraphIdentityText::new("missing::target")?,
3472        };
3473        require(
3474            unresolved.resolved_target().is_none(),
3475            "unresolved relation exposed a traversable target",
3476        )?;
3477        let ambiguous = RelationResolution::Ambiguous {
3478            reference: GraphIdentityText::new("handler")?,
3479            candidates: NonZeroU32::new(2).ok_or("candidate count must be nonzero")?,
3480        };
3481        require(
3482            ambiguous.resolved_target().is_none(),
3483            "ambiguous relation exposed a traversable target",
3484        )?;
3485        Ok(())
3486    }
3487
3488    #[test]
3489    fn legacy_relation_projection_remains_compatible() {
3490        for kind in [
3491            RelationKind::Contains,
3492            RelationKind::Imports,
3493            RelationKind::Calls,
3494            RelationKind::DependsOn,
3495        ] {
3496            assert_eq!(
3497                GraphRelationKind::from_legacy(kind).legacy_kind(),
3498                Some(kind)
3499            );
3500        }
3501        assert_eq!(
3502            GraphRelationKind::Extended(ExtendedRelationKind::Tests).legacy_kind(),
3503            None
3504        );
3505    }
3506
3507    #[test]
3508    fn documents_relation_and_unresolved_reasons_are_closed()
3509    -> Result<(), Box<dyn std::error::Error>> {
3510        let documents = GraphRelationKind::Extended(ExtendedRelationKind::Documents);
3511        require(
3512            GraphRelationKind::ALL.contains(&documents)
3513                && documents.as_str() == "extended:documents"
3514                && documents.legacy_kind().is_none(),
3515            "documents relation was not added as an extended persisted family",
3516        )?;
3517        require(
3518            serde_json::to_string(&documents)?
3519                == "{\"scope\":\"extended\",\"value\":\"documents\"}",
3520            "documents relation wire spelling drifted",
3521        )?;
3522
3523        for reason in DocumentTargetUnresolvedReason::ALL {
3524            require(
3525                DocumentTargetUnresolvedReason::from_db(reason.as_str()) == Some(reason),
3526                "document unresolved reason did not round-trip",
3527            )?;
3528            require(
3529                serde_json::from_str::<DocumentTargetUnresolvedReason>(&serde_json::to_string(
3530                    &reason,
3531                )?)? == reason,
3532                "document unresolved reason wire spelling did not round-trip",
3533            )?;
3534        }
3535        require(
3536            DocumentTargetUnresolvedReason::from_db("external").is_none(),
3537            "unsupported document unresolved reason was accepted",
3538        )
3539    }
3540
3541    #[test]
3542    fn coverage_state_enforces_consistent_counts_and_reasons()
3543    -> Result<(), Box<dyn std::error::Error>> {
3544        let complete = CoverageRecord::new(
3545            CoverageScope::Project,
3546            None,
3547            CoverageState::Complete,
3548            8,
3549            0,
3550            IndexGeneration::new(9),
3551            None,
3552            None,
3553        )?;
3554        require(complete.total() == 8, "complete coverage total drifted")?;
3555
3556        let no_candidates = CoverageRecord::new(
3557            CoverageScope::Project,
3558            Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
3559            CoverageState::NoCandidates,
3560            0,
3561            0,
3562            IndexGeneration::new(9),
3563            None,
3564            None,
3565        )?;
3566        require(
3567            no_candidates.total() == 0,
3568            "zero-candidate coverage total drifted",
3569        )?;
3570        require(
3571            CoverageRecord::new(
3572                CoverageScope::Project,
3573                Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
3574                CoverageState::NoCandidates,
3575                1,
3576                0,
3577                IndexGeneration::new(9),
3578                None,
3579                None,
3580            )
3581            .is_err(),
3582            "zero-candidate coverage accepted a covered row",
3583        )?;
3584        require(
3585            CoverageRecord::new(
3586                CoverageScope::Project,
3587                Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
3588                CoverageState::NoCandidates,
3589                0,
3590                0,
3591                IndexGeneration::new(9),
3592                Some(GraphIdentityText::new("unexpected reason")?),
3593                None,
3594            )
3595            .is_err(),
3596            "zero-candidate coverage accepted an omission reason",
3597        )?;
3598        require(
3599            CoverageRecord::new(
3600                CoverageScope::Project,
3601                None,
3602                CoverageState::NoCandidates,
3603                0,
3604                0,
3605                IndexGeneration::new(9),
3606                None,
3607                None,
3608            )
3609            .is_err(),
3610            "zero-candidate coverage accepted a non-document relation",
3611        )?;
3612
3613        let partial = CoverageRecord::new(
3614            CoverageScope::Path {
3615                path: RepositoryNodePath::new(Path::new("src"))?,
3616            },
3617            Some(GraphRelationKind::Extended(
3618                ExtendedRelationKind::References,
3619            )),
3620            CoverageState::Partial,
3621            5,
3622            3,
3623            IndexGeneration::new(9),
3624            Some(GraphIdentityText::new("parser limit reached")?),
3625            Some(GraphLimitKind::Rows),
3626        )?;
3627        require(
3628            (partial.covered(), partial.omitted(), partial.total()) == (5, 3, 8),
3629            "partial coverage counts are inconsistent",
3630        )?;
3631
3632        require(
3633            CoverageRecord::new(
3634                CoverageScope::Project,
3635                None,
3636                CoverageState::Complete,
3637                7,
3638                1,
3639                IndexGeneration::new(9),
3640                None,
3641                None,
3642            )
3643            .is_err(),
3644            "complete coverage accepted omitted rows",
3645        )?;
3646        require(
3647            CoverageRecord::new(
3648                CoverageScope::Project,
3649                None,
3650                CoverageState::Failed,
3651                0,
3652                8,
3653                IndexGeneration::new(9),
3654                None,
3655                None,
3656            )
3657            .is_err(),
3658            "failed coverage accepted a missing reason",
3659        )?;
3660        require(
3661            CoverageRecord::new(
3662                CoverageScope::Project,
3663                None,
3664                CoverageState::Complete,
3665                1,
3666                0,
3667                IndexGeneration::ZERO,
3668                None,
3669                None,
3670            )
3671            .is_err(),
3672            "pre-publication coverage generation was accepted",
3673        )?;
3674        Ok(())
3675    }
3676
3677    #[test]
3678    fn graph_limits_are_nonzero_bounded_and_validated_on_input()
3679    -> Result<(), Box<dyn std::error::Error>> {
3680        let limits = GraphLimits::new(100, 20, 4, 64 * 1_024)?;
3681        let encoded = serde_json::to_string(&limits)?;
3682        require(
3683            serde_json::from_str::<GraphLimits>(&encoded)? == limits,
3684            "graph limits did not round-trip",
3685        )?;
3686        require(
3687            GraphLimits::new(0, 20, 4, 1024).is_err(),
3688            "zero graph row limit was accepted",
3689        )?;
3690        require(
3691            GraphLimits::new(GraphLimits::MAX_ROWS + 1, 20, 4, 1024).is_err(),
3692            "graph row limit exceeded its hard ceiling",
3693        )?;
3694        require(
3695            serde_json::from_str::<GraphLimits>(
3696                r#"{"rows":1,"occurrences":1,"depth":65,"output_bytes":1}"#,
3697            )
3698            .is_err(),
3699            "deserialization bypassed graph depth limits",
3700        )?;
3701        Ok(())
3702    }
3703}