1use 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
11const ENTITY_KEY_DOMAIN: &str = "projectatlas.graph.entity.v1";
13const RELATION_KEY_DOMAIN: &str = "projectatlas.graph.relation.v1";
15const RESOLUTION_KEY_DOMAIN: &str = "projectatlas.graph.resolution.v1";
17pub const MAX_GRAPH_IDENTITY_BYTES: usize = 4_096;
19pub const QUALIFIED_SYMBOL_SCOPE_PREFIX: &str = "@projectatlas.scope.v1:";
21const MAX_PORTABLE_RESOLUTION_IDENTITY_BYTES: usize = 32 * 1_024;
23
24#[derive(Debug, Error)]
26pub enum GraphContractError {
27 #[error("invalid project instance identifier: {reason}")]
29 InvalidProjectInstanceId {
30 reason: &'static str,
32 },
33 #[error("invalid graph identity text: {reason}")]
35 InvalidIdentityText {
36 reason: &'static str,
38 },
39 #[error(transparent)]
41 InvalidRepositoryPath(#[from] CoreError),
42 #[error("stable graph key digest does not match its canonical identity")]
44 InvalidStableKeyDigest,
45 #[error("unsupported canonical resolution-key domain")]
47 InvalidResolutionKeyDomain,
48 #[error("invalid portable canonical resolution-key identity")]
50 InvalidResolutionKeyIdentity,
51 #[error("stable graph key collision for digest {digest}")]
53 StableKeyCollision {
54 digest: String,
56 },
57 #[error("stable entity key is not qualified by its declared project")]
59 ProjectQualificationMismatch,
60 #[error("canonical resolution key belongs to a different project than its graph owner")]
62 ResolutionKeyOwnerMismatch,
63 #[error("resolved graph relation target belongs to another project")]
65 CrossProjectRelation,
66 #[error("graph generation mismatch for {context}")]
68 GenerationMismatch {
69 context: &'static str,
71 },
72 #[error("graph records require a complete nonzero publication generation")]
74 InvalidGeneration,
75 #[error("invalid graph relation resolution: {reason}")]
77 InvalidResolution {
78 reason: &'static str,
80 },
81 #[error("invalid source span: {reason}")]
83 InvalidSourceSpan {
84 reason: &'static str,
86 },
87 #[error("invalid graph coverage: {reason}")]
89 InvalidCoverage {
90 reason: &'static str,
92 },
93 #[error("invalid graph limits: {reason}")]
95 InvalidLimits {
96 reason: &'static str,
98 },
99}
100
101#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
103#[serde(rename_all = "kebab-case")]
104pub enum GraphIdentityField {
105 Package,
107 Symbol,
109 Parent,
111 Signature,
113 #[serde(rename = "relation.source")]
115 RelationSource,
116 #[serde(rename = "relation.target")]
118 RelationTarget,
119 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#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
139#[serde(rename_all = "kebab-case")]
140pub enum GraphIdentityRejectionReason {
141 Empty,
143 SurroundingWhitespace,
145 ControlCharacters,
147 Oversized,
149 ReservedNamespace,
151 Contract,
153}
154
155impl GraphIdentityRejectionReason {
156 #[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206pub struct GraphIdentityRejection {
207 pub path: RepositoryNodePath,
209 pub span: SourceSpan,
211 pub parser: ParserKind,
213 pub field: GraphIdentityField,
215 pub reason: GraphIdentityRejectionReason,
217 #[serde(skip)]
223 pub fact_index: u64,
224}
225
226#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
228pub struct ProjectInstanceId([u8; 16]);
229
230impl ProjectInstanceId {
231 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 #[must_use]
247 pub const fn as_bytes(self) -> [u8; 16] {
248 self.0
249 }
250
251 #[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#[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 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 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 #[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#[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 pub fn new(path: &Path) -> Result<Self, GraphContractError> {
425 Ok(Self(validated_repo_node_key(path)?))
426 }
427
428 #[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#[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 pub fn new(path: &Path) -> Result<Self, GraphContractError> {
461 Ok(Self(validated_repo_file_key(path)?))
462 }
463
464 #[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
487pub struct PackageSelector {
488 pub manager: GraphIdentityText,
490 pub name: GraphIdentityText,
492 pub manifest: RepositoryFilePath,
494}
495
496#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
498pub struct SymbolSelector {
499 pub file: RepositoryFilePath,
501 pub name: GraphIdentityText,
503 pub kind: SymbolKind,
505 pub parent: Option<GraphIdentityText>,
507 pub signature: GraphIdentityText,
509}
510
511#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
513pub struct ExternalSelector {
514 pub system: GraphIdentityText,
516 pub identity: GraphIdentityText,
518}
519
520#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
522#[serde(tag = "kind", rename_all = "snake_case")]
523pub enum EntitySelector {
524 Project,
526 Folder {
528 path: RepositoryNodePath,
530 },
531 File {
533 path: RepositoryFilePath,
535 },
536 Package {
538 package: PackageSelector,
540 },
541 Symbol {
543 symbol: SymbolSelector,
545 },
546 External {
548 external: ExternalSelector,
550 },
551}
552
553#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
555#[serde(try_from = "StableKeyWire", into = "StableKeyWire")]
556struct StableKey {
557 digest: String,
559 canonical_identity: String,
561}
562
563impl StableKey {
564 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 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 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 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#[derive(Deserialize, Serialize)]
612struct StableKeyWire {
613 digest: String,
615 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
638#[serde(try_from = "GraphEntityKeyWire", into = "GraphEntityKeyWire")]
639pub struct GraphEntityKey {
640 project: ProjectInstanceId,
642 stable: StableKey,
644}
645
646impl GraphEntityKey {
647 #[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 #[must_use]
659 pub const fn project(&self) -> ProjectInstanceId {
660 self.project
661 }
662
663 #[must_use]
665 pub fn digest(&self) -> &str {
666 &self.stable.digest
667 }
668
669 pub fn digest_bytes(&self) -> Result<[u8; 32], GraphContractError> {
676 self.stable.digest_bytes()
677 }
678
679 #[must_use]
681 pub fn canonical_identity(&self) -> &str {
682 &self.stable.canonical_identity
683 }
684
685 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#[derive(Deserialize, Serialize)]
701struct GraphEntityKeyWire {
702 project: ProjectInstanceId,
704 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#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
734pub struct GraphEntity {
735 key: GraphEntityKey,
737 selector: EntitySelector,
739 generation: IndexGeneration,
741}
742
743impl GraphEntity {
744 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 #[must_use]
767 pub const fn key(&self) -> &GraphEntityKey {
768 &self.key
769 }
770
771 #[must_use]
773 pub const fn selector(&self) -> &EntitySelector {
774 &self.selector
775 }
776
777 #[must_use]
779 pub const fn generation(&self) -> IndexGeneration {
780 self.generation
781 }
782}
783
784#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
786#[serde(rename_all = "snake_case")]
787pub enum ExtendedRelationKind {
788 References,
790 Documents,
792 Tests,
794 RoutesTo,
796 Configures,
798 Deploys,
800 Reads,
802 Writes,
804}
805
806#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
808#[serde(tag = "scope", content = "value", rename_all = "snake_case")]
809pub enum GraphRelationKind {
810 Legacy(RelationKind),
812 Extended(ExtendedRelationKind),
814}
815
816impl GraphRelationKind {
817 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 #[must_use]
835 pub const fn from_legacy(kind: RelationKind) -> Self {
836 Self::Legacy(kind)
837 }
838
839 #[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 #[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#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
870#[serde(rename_all = "snake_case")]
871pub enum DocumentTargetUnresolvedReason {
872 Missing,
874 Ignored,
876 OutsideRoot,
878 CaseConflict,
880 Unsupported,
882 NoStaticTarget,
884}
885
886impl DocumentTargetUnresolvedReason {
887 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 #[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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
933#[serde(try_from = "SourceSpanWire", into = "SourceSpanWire")]
934pub struct SourceSpan {
935 start_line: u32,
937 start_column: u32,
939 end_line: u32,
941 end_column: u32,
943}
944
945impl SourceSpan {
946 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 #[must_use]
977 pub const fn start_line(self) -> u32 {
978 self.start_line
979 }
980
981 #[must_use]
983 pub const fn start_column(self) -> u32 {
984 self.start_column
985 }
986
987 #[must_use]
989 pub const fn end_line(self) -> u32 {
990 self.end_line
991 }
992
993 #[must_use]
995 pub const fn end_column(self) -> u32 {
996 self.end_column
997 }
998}
999
1000#[derive(Deserialize, Serialize)]
1002struct SourceSpanWire {
1003 start_line: u32,
1005 start_column: u32,
1007 end_line: u32,
1009 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1039#[serde(tag = "kind", rename_all = "snake_case")]
1040pub enum ReusableTargetSelector {
1041 Folder {
1043 folder: RepositoryNodePath,
1045 },
1046 File {
1048 file: RepositoryFilePath,
1050 },
1051 Package {
1053 package: PackageSelector,
1055 },
1056 Symbol {
1058 symbol: SymbolSelector,
1060 },
1061}
1062
1063impl ReusableTargetSelector {
1064 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 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1105#[serde(tag = "status", rename_all = "snake_case")]
1106pub enum RelationResolution {
1107 Resolved {
1109 target: GraphEntityKey,
1111 selector: ReusableTargetSelector,
1113 generation: IndexGeneration,
1115 },
1116 Ambiguous {
1118 reference: GraphIdentityText,
1120 candidates: NonZeroU32,
1122 },
1123 Unresolved {
1125 reference: GraphIdentityText,
1127 },
1128 External {
1130 target: GraphEntityKey,
1132 external: ExternalSelector,
1134 generation: IndexGeneration,
1136 },
1137}
1138
1139impl RelationResolution {
1140 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 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 #[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 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 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 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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1223#[serde(rename_all = "snake_case")]
1224pub enum ConfidenceClass {
1225 Exact,
1227 High,
1229 Medium,
1231 Low,
1233}
1234
1235#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1237#[serde(rename_all = "snake_case")]
1238pub enum Completeness {
1239 Complete,
1241 Partial,
1243}
1244
1245#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1247#[serde(try_from = "LogicalRelationKeyWire", into = "LogicalRelationKeyWire")]
1248pub struct LogicalRelationKey {
1249 project: ProjectInstanceId,
1251 stable: StableKey,
1253}
1254
1255#[derive(Deserialize, Serialize)]
1257struct LogicalRelationKeyWire {
1258 project: ProjectInstanceId,
1260 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 #[must_use]
1291 pub const fn project(&self) -> ProjectInstanceId {
1292 self.project
1293 }
1294
1295 #[must_use]
1297 pub fn digest(&self) -> &str {
1298 &self.stable.digest
1299 }
1300
1301 pub fn digest_bytes(&self) -> Result<[u8; 32], GraphContractError> {
1308 self.stable.digest_bytes()
1309 }
1310
1311 #[must_use]
1313 pub fn canonical_identity(&self) -> &str {
1314 &self.stable.canonical_identity
1315 }
1316
1317 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#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
1333#[serde(rename_all = "snake_case")]
1334pub enum ResolutionKeyDomain {
1335 Declaration,
1337 Module,
1339 Package,
1341}
1342
1343impl ResolutionKeyDomain {
1344 #[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#[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: ProjectInstanceId,
1381 domain: ResolutionKeyDomain,
1383 digest: [u8; 32],
1385 canonical_identity: String,
1387}
1388
1389impl CanonicalResolutionKey {
1390 #[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 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 #[must_use]
1451 pub const fn project(&self) -> ProjectInstanceId {
1452 self.project
1453 }
1454
1455 #[must_use]
1457 pub const fn domain(&self) -> ResolutionKeyDomain {
1458 self.domain
1459 }
1460
1461 #[must_use]
1463 pub const fn digest_bytes(&self) -> [u8; 32] {
1464 self.digest
1465 }
1466
1467 #[must_use]
1469 pub fn canonical_identity(&self) -> &str {
1470 &self.canonical_identity
1471 }
1472
1473 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 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
1512#[serde(
1513 try_from = "PortableResolutionKeyWire",
1514 into = "PortableResolutionKeyWire"
1515)]
1516pub struct PortableResolutionKey {
1517 domain: ResolutionKeyDomain,
1519 canonical_identity: String,
1521}
1522
1523impl PortableResolutionKey {
1524 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 #[must_use]
1547 pub const fn domain(&self) -> ResolutionKeyDomain {
1548 self.domain
1549 }
1550
1551 #[must_use]
1553 pub fn canonical_identity(&self) -> &str {
1554 &self.canonical_identity
1555 }
1556
1557 #[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#[derive(Deserialize, Serialize)]
1574struct PortableResolutionKeyWire {
1575 domain: ResolutionKeyDomain,
1577 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#[derive(Deserialize, Serialize)]
1600struct CanonicalResolutionKeyWire {
1601 project: ProjectInstanceId,
1603 domain: ResolutionKeyDomain,
1605 digest: [u8; 32],
1607 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#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1637pub struct EntityResolutionKey {
1638 entity: GraphEntityKey,
1640 key: CanonicalResolutionKey,
1642}
1643
1644impl EntityResolutionKey {
1645 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 #[must_use]
1662 pub const fn entity(&self) -> &GraphEntityKey {
1663 &self.entity
1664 }
1665
1666 #[must_use]
1668 pub const fn key(&self) -> &CanonicalResolutionKey {
1669 &self.key
1670 }
1671}
1672
1673#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1675pub struct RelationDependencyKey {
1676 relation: LogicalRelationKey,
1678 key: CanonicalResolutionKey,
1680}
1681
1682impl RelationDependencyKey {
1683 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 #[must_use]
1700 pub const fn relation(&self) -> &LogicalRelationKey {
1701 &self.relation
1702 }
1703
1704 #[must_use]
1706 pub const fn key(&self) -> &CanonicalResolutionKey {
1707 &self.key
1708 }
1709}
1710
1711#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1713pub struct LogicalRelation {
1714 key: LogicalRelationKey,
1716 source: GraphEntityKey,
1718 kind: GraphRelationKind,
1720 resolution: RelationResolution,
1722 confidence: ConfidenceClass,
1724 completeness: Completeness,
1726 generation: IndexGeneration,
1728}
1729
1730impl LogicalRelation {
1731 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 #[must_use]
1811 pub const fn key(&self) -> &LogicalRelationKey {
1812 &self.key
1813 }
1814
1815 #[must_use]
1817 pub const fn source(&self) -> &GraphEntityKey {
1818 &self.source
1819 }
1820
1821 #[must_use]
1823 pub const fn kind(&self) -> GraphRelationKind {
1824 self.kind
1825 }
1826
1827 #[must_use]
1829 pub const fn resolution(&self) -> &RelationResolution {
1830 &self.resolution
1831 }
1832
1833 #[must_use]
1835 pub const fn confidence(&self) -> ConfidenceClass {
1836 self.confidence
1837 }
1838
1839 #[must_use]
1841 pub const fn completeness(&self) -> Completeness {
1842 self.completeness
1843 }
1844
1845 #[must_use]
1847 pub const fn generation(&self) -> IndexGeneration {
1848 self.generation
1849 }
1850}
1851
1852#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1854pub struct RelationOccurrence {
1855 relation: LogicalRelationKey,
1857 file: RepositoryFilePath,
1859 span: SourceSpan,
1861 generation: IndexGeneration,
1863}
1864
1865impl RelationOccurrence {
1866 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 #[must_use]
1892 pub const fn relation(&self) -> &LogicalRelationKey {
1893 &self.relation
1894 }
1895
1896 #[must_use]
1898 pub const fn file(&self) -> &RepositoryFilePath {
1899 &self.file
1900 }
1901
1902 #[must_use]
1904 pub const fn span(&self) -> SourceSpan {
1905 self.span
1906 }
1907
1908 #[must_use]
1910 pub const fn generation(&self) -> IndexGeneration {
1911 self.generation
1912 }
1913}
1914
1915#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1917#[serde(rename_all = "snake_case")]
1918pub enum CoverageState {
1919 Complete,
1921 NoCandidates,
1923 Partial,
1925 Failed,
1927 Ignored,
1929 Oversized,
1931 Quarantined,
1933 Stale,
1935}
1936
1937#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1939#[serde(tag = "kind", rename_all = "snake_case")]
1940pub enum CoverageScope {
1941 Project,
1943 Path {
1945 path: RepositoryNodePath,
1947 },
1948}
1949
1950macro_rules! define_graph_limit_kinds {
1952 ($( $(#[$variant_meta:meta])* $variant:ident => $stable_name:literal),+ $(,)?) => {
1953 #[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 pub const ALL: [Self; define_graph_limit_kinds!(@count $($variant),+)] = [
1966 $(Self::$variant,)+
1967 ];
1968
1969 #[must_use]
1971 pub const fn as_str(self) -> &'static str {
1972 match self {
1973 $(Self::$variant => $stable_name,)+
1974 }
1975 }
1976
1977 #[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 Rows => "rows",
1993 Nodes => "nodes",
1995 Edges => "edges",
1997 Occurrences => "occurrences",
1999 Visited => "visited",
2001 IntermediateBytes => "intermediate_bytes",
2003 Deadline => "deadline",
2005 Depth => "depth",
2007 OutputBytes => "output_bytes",
2009}
2010
2011#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
2013pub struct CoverageRecord {
2014 scope: CoverageScope,
2016 relation: Option<GraphRelationKind>,
2018 state: CoverageState,
2020 total: u64,
2022 covered: u64,
2024 omitted: u64,
2026 generation: IndexGeneration,
2028 reason: Option<GraphIdentityText>,
2030 reached_limit: Option<GraphLimitKind>,
2032}
2033
2034impl CoverageRecord {
2035 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 #[must_use]
2110 pub const fn scope(&self) -> &CoverageScope {
2111 &self.scope
2112 }
2113
2114 #[must_use]
2116 pub const fn relation(&self) -> Option<GraphRelationKind> {
2117 self.relation
2118 }
2119
2120 #[must_use]
2122 pub const fn state(&self) -> CoverageState {
2123 self.state
2124 }
2125
2126 #[must_use]
2128 pub const fn total(&self) -> u64 {
2129 self.total
2130 }
2131
2132 #[must_use]
2134 pub const fn covered(&self) -> u64 {
2135 self.covered
2136 }
2137
2138 #[must_use]
2140 pub const fn omitted(&self) -> u64 {
2141 self.omitted
2142 }
2143
2144 #[must_use]
2146 pub const fn generation(&self) -> IndexGeneration {
2147 self.generation
2148 }
2149
2150 #[must_use]
2152 pub const fn reason(&self) -> Option<&GraphIdentityText> {
2153 self.reason.as_ref()
2154 }
2155
2156 #[must_use]
2158 pub const fn reached_limit(&self) -> Option<GraphLimitKind> {
2159 self.reached_limit
2160 }
2161}
2162
2163#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2165#[serde(try_from = "GraphLimitsWire", into = "GraphLimitsWire")]
2166pub struct GraphLimits {
2167 rows: NonZeroU32,
2169 occurrences: NonZeroU32,
2171 depth: NonZeroU32,
2173 output_bytes: NonZeroU32,
2175}
2176
2177impl GraphLimits {
2178 pub const MAX_ROWS: u32 = 10_000;
2180 pub const MAX_OCCURRENCES: u32 = 1_024;
2182 pub const MAX_DEPTH: u32 = 64;
2184 pub const MAX_OUTPUT_BYTES: u32 = 16 * 1_024 * 1_024;
2186
2187 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 #[must_use]
2238 pub const fn rows(self) -> u32 {
2239 self.rows.get()
2240 }
2241
2242 #[must_use]
2244 pub const fn occurrences(self) -> u32 {
2245 self.occurrences.get()
2246 }
2247
2248 #[must_use]
2250 pub const fn depth(self) -> u32 {
2251 self.depth.get()
2252 }
2253
2254 #[must_use]
2256 pub const fn output_bytes(self) -> u32 {
2257 self.output_bytes.get()
2258 }
2259}
2260
2261#[derive(Deserialize, Serialize)]
2263struct GraphLimitsWire {
2264 rows: u32,
2266 occurrences: u32,
2268 depth: u32,
2270 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
2298fn 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
2305fn entity_project_prefix(project: ProjectInstanceId) -> String {
2307 project_canonical_prefix(ENTITY_KEY_DOMAIN, project)
2308}
2309
2310fn relation_project_prefix(project: ProjectInstanceId) -> String {
2312 project_canonical_prefix(RELATION_KEY_DOMAIN, project)
2313}
2314
2315fn 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
2322fn has_canonical_prefix(canonical: &str, prefix: &str) -> bool {
2324 canonical
2325 .strip_prefix(prefix)
2326 .is_some_and(|remainder| remainder.starts_with('|'))
2327}
2328
2329fn 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
2365fn 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
2373fn 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
2414fn 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
2440fn 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
2447fn 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
2458fn 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
2469fn 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
2480const 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 fn project() -> Result<ProjectInstanceId, GraphContractError> {
2510 ProjectInstanceId::try_from("00112233445566778899aabbccddeeff")
2511 }
2512
2513 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 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}