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