1use crate::symbols::{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";
17const MAX_IDENTITY_BYTES: usize = 4_096;
19const MAX_PORTABLE_RESOLUTION_IDENTITY_BYTES: usize = 32 * 1_024;
21
22#[derive(Debug, Error)]
24pub enum GraphContractError {
25 #[error("invalid project instance identifier: {reason}")]
27 InvalidProjectInstanceId {
28 reason: &'static str,
30 },
31 #[error("invalid graph identity text: {reason}")]
33 InvalidIdentityText {
34 reason: &'static str,
36 },
37 #[error(transparent)]
39 InvalidRepositoryPath(#[from] CoreError),
40 #[error("stable graph key digest does not match its canonical identity")]
42 InvalidStableKeyDigest,
43 #[error("unsupported canonical resolution-key domain")]
45 InvalidResolutionKeyDomain,
46 #[error("invalid portable canonical resolution-key identity")]
48 InvalidResolutionKeyIdentity,
49 #[error("stable graph key collision for digest {digest}")]
51 StableKeyCollision {
52 digest: String,
54 },
55 #[error("stable entity key is not qualified by its declared project")]
57 ProjectQualificationMismatch,
58 #[error("canonical resolution key belongs to a different project than its graph owner")]
60 ResolutionKeyOwnerMismatch,
61 #[error("resolved graph relation target belongs to another project")]
63 CrossProjectRelation,
64 #[error("graph generation mismatch for {context}")]
66 GenerationMismatch {
67 context: &'static str,
69 },
70 #[error("graph records require a complete nonzero publication generation")]
72 InvalidGeneration,
73 #[error("invalid graph relation resolution: {reason}")]
75 InvalidResolution {
76 reason: &'static str,
78 },
79 #[error("invalid source span: {reason}")]
81 InvalidSourceSpan {
82 reason: &'static str,
84 },
85 #[error("invalid graph coverage: {reason}")]
87 InvalidCoverage {
88 reason: &'static str,
90 },
91 #[error("invalid graph limits: {reason}")]
93 InvalidLimits {
94 reason: &'static str,
96 },
97}
98
99#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
101pub struct ProjectInstanceId([u8; 16]);
102
103impl ProjectInstanceId {
104 pub fn from_bytes(bytes: [u8; 16]) -> Result<Self, GraphContractError> {
110 if bytes == [0; 16] {
111 return Err(GraphContractError::InvalidProjectInstanceId {
112 reason: "the zero identifier is reserved",
113 });
114 }
115 Ok(Self(bytes))
116 }
117
118 #[must_use]
120 pub const fn as_bytes(self) -> [u8; 16] {
121 self.0
122 }
123
124 #[must_use]
126 pub fn as_hex(self) -> String {
127 encode_hex(&self.0)
128 }
129}
130
131impl fmt::Display for ProjectInstanceId {
132 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133 formatter.write_str(&self.as_hex())
134 }
135}
136
137impl TryFrom<&str> for ProjectInstanceId {
138 type Error = GraphContractError;
139
140 fn try_from(value: &str) -> Result<Self, Self::Error> {
141 let compact = match value.len() {
142 32 => value.to_string(),
143 36 if value.as_bytes().get(8) == Some(&b'-')
144 && value.as_bytes().get(13) == Some(&b'-')
145 && value.as_bytes().get(18) == Some(&b'-')
146 && value.as_bytes().get(23) == Some(&b'-') =>
147 {
148 value
149 .chars()
150 .filter(|character| *character != '-')
151 .collect()
152 }
153 _ => {
154 return Err(GraphContractError::InvalidProjectInstanceId {
155 reason: "expected 32 hexadecimal digits or a hyphenated UUID",
156 });
157 }
158 };
159 if compact.len() != 32 {
160 return Err(GraphContractError::InvalidProjectInstanceId {
161 reason: "hyphenated identifier must contain exactly 32 hexadecimal digits",
162 });
163 }
164 let mut bytes = [0_u8; 16];
165 for (index, pair) in compact.as_bytes().chunks_exact(2).enumerate() {
166 let high = decode_hex(pair[0]).ok_or(GraphContractError::InvalidProjectInstanceId {
167 reason: "identifier contains a non-hexadecimal digit",
168 })?;
169 let low = decode_hex(pair[1]).ok_or(GraphContractError::InvalidProjectInstanceId {
170 reason: "identifier contains a non-hexadecimal digit",
171 })?;
172 bytes[index] = (high << 4) | low;
173 }
174 Self::from_bytes(bytes)
175 }
176}
177
178impl TryFrom<String> for ProjectInstanceId {
179 type Error = GraphContractError;
180
181 fn try_from(value: String) -> Result<Self, Self::Error> {
182 Self::try_from(value.as_str())
183 }
184}
185
186impl Serialize for ProjectInstanceId {
187 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
188 where
189 S: serde::Serializer,
190 {
191 serializer.serialize_str(&self.as_hex())
192 }
193}
194
195impl<'de> Deserialize<'de> for ProjectInstanceId {
196 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
197 where
198 D: serde::Deserializer<'de>,
199 {
200 let value = String::deserialize(deserializer)?;
201 Self::try_from(value).map_err(serde::de::Error::custom)
202 }
203}
204
205#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
207#[serde(try_from = "String", into = "String")]
208pub struct GraphIdentityText(String);
209
210impl GraphIdentityText {
211 pub fn new(value: impl Into<String>) -> Result<Self, GraphContractError> {
217 let value = value.into();
218 if value.trim().is_empty() {
219 return Err(GraphContractError::InvalidIdentityText {
220 reason: "identity text must not be empty",
221 });
222 }
223 if value.trim() != value {
224 return Err(GraphContractError::InvalidIdentityText {
225 reason: "identity text must not contain surrounding whitespace",
226 });
227 }
228 if value.len() > MAX_IDENTITY_BYTES {
229 return Err(GraphContractError::InvalidIdentityText {
230 reason: "identity text exceeds the byte limit",
231 });
232 }
233 if value.chars().any(char::is_control) {
234 return Err(GraphContractError::InvalidIdentityText {
235 reason: "identity text contains control characters",
236 });
237 }
238 Ok(Self(value))
239 }
240
241 #[must_use]
243 pub fn as_str(&self) -> &str {
244 &self.0
245 }
246}
247
248impl fmt::Display for GraphIdentityText {
249 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
250 formatter.write_str(self.as_str())
251 }
252}
253
254impl TryFrom<String> for GraphIdentityText {
255 type Error = GraphContractError;
256
257 fn try_from(value: String) -> Result<Self, Self::Error> {
258 Self::new(value)
259 }
260}
261
262impl TryFrom<&str> for GraphIdentityText {
263 type Error = GraphContractError;
264
265 fn try_from(value: &str) -> Result<Self, Self::Error> {
266 Self::new(value)
267 }
268}
269
270impl From<GraphIdentityText> for String {
271 fn from(value: GraphIdentityText) -> Self {
272 value.0
273 }
274}
275
276#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
278#[serde(try_from = "String", into = "String")]
279pub struct RepositoryNodePath(String);
280
281impl RepositoryNodePath {
282 pub fn new(path: &Path) -> Result<Self, GraphContractError> {
288 Ok(Self(validated_repo_node_key(path)?))
289 }
290
291 #[must_use]
293 pub fn as_str(&self) -> &str {
294 &self.0
295 }
296}
297
298impl TryFrom<String> for RepositoryNodePath {
299 type Error = GraphContractError;
300
301 fn try_from(value: String) -> Result<Self, Self::Error> {
302 Self::new(Path::new(&value))
303 }
304}
305
306impl From<RepositoryNodePath> for String {
307 fn from(value: RepositoryNodePath) -> Self {
308 value.0
309 }
310}
311
312#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
314#[serde(try_from = "String", into = "String")]
315pub struct RepositoryFilePath(String);
316
317impl RepositoryFilePath {
318 pub fn new(path: &Path) -> Result<Self, GraphContractError> {
324 Ok(Self(validated_repo_file_key(path)?))
325 }
326
327 #[must_use]
329 pub fn as_str(&self) -> &str {
330 &self.0
331 }
332}
333
334impl TryFrom<String> for RepositoryFilePath {
335 type Error = GraphContractError;
336
337 fn try_from(value: String) -> Result<Self, Self::Error> {
338 Self::new(Path::new(&value))
339 }
340}
341
342impl From<RepositoryFilePath> for String {
343 fn from(value: RepositoryFilePath) -> Self {
344 value.0
345 }
346}
347
348#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
350pub struct PackageSelector {
351 pub manager: GraphIdentityText,
353 pub name: GraphIdentityText,
355 pub manifest: RepositoryFilePath,
357}
358
359#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
361pub struct SymbolSelector {
362 pub file: RepositoryFilePath,
364 pub name: GraphIdentityText,
366 pub kind: SymbolKind,
368 pub parent: Option<GraphIdentityText>,
370 pub signature: GraphIdentityText,
372}
373
374#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
376pub struct ExternalSelector {
377 pub system: GraphIdentityText,
379 pub identity: GraphIdentityText,
381}
382
383#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
385#[serde(tag = "kind", rename_all = "snake_case")]
386pub enum EntitySelector {
387 Project,
389 Folder {
391 path: RepositoryNodePath,
393 },
394 File {
396 path: RepositoryFilePath,
398 },
399 Package {
401 package: PackageSelector,
403 },
404 Symbol {
406 symbol: SymbolSelector,
408 },
409 External {
411 external: ExternalSelector,
413 },
414}
415
416#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
418#[serde(try_from = "StableKeyWire", into = "StableKeyWire")]
419struct StableKey {
420 digest: String,
422 canonical_identity: String,
424}
425
426impl StableKey {
427 fn new(canonical_identity: String) -> Self {
429 let digest = blake3::hash(canonical_identity.as_bytes())
430 .to_hex()
431 .as_str()
432 .to_owned();
433 Self {
434 digest,
435 canonical_identity,
436 }
437 }
438
439 fn from_persisted(
441 digest: &str,
442 canonical_identity: String,
443 ) -> Result<Self, GraphContractError> {
444 let key = Self::new(canonical_identity);
445 if key.digest != digest {
446 return Err(GraphContractError::InvalidStableKeyDigest);
447 }
448 Ok(key)
449 }
450
451 fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
453 if self.digest != other.digest {
454 return Ok(false);
455 }
456 if self.canonical_identity != other.canonical_identity {
457 return Err(GraphContractError::StableKeyCollision {
458 digest: self.digest.clone(),
459 });
460 }
461 Ok(true)
462 }
463
464 fn digest_bytes(&self) -> Result<[u8; 32], GraphContractError> {
466 let Ok(digest) = blake3::Hash::from_hex(&self.digest) else {
467 return Err(GraphContractError::InvalidStableKeyDigest);
468 };
469 Ok(*digest.as_bytes())
470 }
471}
472
473#[derive(Deserialize, Serialize)]
475struct StableKeyWire {
476 digest: String,
478 canonical_identity: String,
480}
481
482impl TryFrom<StableKeyWire> for StableKey {
483 type Error = GraphContractError;
484
485 fn try_from(value: StableKeyWire) -> Result<Self, Self::Error> {
486 Self::from_persisted(&value.digest, value.canonical_identity)
487 }
488}
489
490impl From<StableKey> for StableKeyWire {
491 fn from(value: StableKey) -> Self {
492 Self {
493 digest: value.digest,
494 canonical_identity: value.canonical_identity,
495 }
496 }
497}
498
499#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
501#[serde(try_from = "GraphEntityKeyWire", into = "GraphEntityKeyWire")]
502pub struct GraphEntityKey {
503 project: ProjectInstanceId,
505 stable: StableKey,
507}
508
509impl GraphEntityKey {
510 #[must_use]
512 pub fn new(project: ProjectInstanceId, selector: &EntitySelector) -> Self {
513 let canonical_identity = entity_canonical_identity(project, selector);
514 Self {
515 project,
516 stable: StableKey::new(canonical_identity),
517 }
518 }
519
520 #[must_use]
522 pub const fn project(&self) -> ProjectInstanceId {
523 self.project
524 }
525
526 #[must_use]
528 pub fn digest(&self) -> &str {
529 &self.stable.digest
530 }
531
532 pub fn digest_bytes(&self) -> Result<[u8; 32], GraphContractError> {
539 self.stable.digest_bytes()
540 }
541
542 #[must_use]
544 pub fn canonical_identity(&self) -> &str {
545 &self.stable.canonical_identity
546 }
547
548 pub fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
555 if self.project != other.project {
556 return Ok(false);
557 }
558 self.stable.reconcile(&other.stable)
559 }
560}
561
562#[derive(Deserialize, Serialize)]
564struct GraphEntityKeyWire {
565 project: ProjectInstanceId,
567 stable: StableKey,
569}
570
571impl TryFrom<GraphEntityKeyWire> for GraphEntityKey {
572 type Error = GraphContractError;
573
574 fn try_from(value: GraphEntityKeyWire) -> Result<Self, Self::Error> {
575 let prefix = entity_project_prefix(value.project);
576 if !has_canonical_prefix(&value.stable.canonical_identity, &prefix) {
577 return Err(GraphContractError::ProjectQualificationMismatch);
578 }
579 Ok(Self {
580 project: value.project,
581 stable: value.stable,
582 })
583 }
584}
585
586impl From<GraphEntityKey> for GraphEntityKeyWire {
587 fn from(value: GraphEntityKey) -> Self {
588 Self {
589 project: value.project,
590 stable: value.stable,
591 }
592 }
593}
594
595#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
597pub struct GraphEntity {
598 key: GraphEntityKey,
600 selector: EntitySelector,
602 generation: IndexGeneration,
604}
605
606impl GraphEntity {
607 pub fn new(
613 project: ProjectInstanceId,
614 selector: EntitySelector,
615 generation: IndexGeneration,
616 ) -> Result<Self, GraphContractError> {
617 if generation == IndexGeneration::ZERO {
618 return Err(GraphContractError::InvalidGeneration);
619 }
620 let key = GraphEntityKey::new(project, &selector);
621 Ok(Self {
622 key,
623 selector,
624 generation,
625 })
626 }
627
628 #[must_use]
630 pub const fn key(&self) -> &GraphEntityKey {
631 &self.key
632 }
633
634 #[must_use]
636 pub const fn selector(&self) -> &EntitySelector {
637 &self.selector
638 }
639
640 #[must_use]
642 pub const fn generation(&self) -> IndexGeneration {
643 self.generation
644 }
645}
646
647#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
649#[serde(rename_all = "snake_case")]
650pub enum ExtendedRelationKind {
651 References,
653 Tests,
655 RoutesTo,
657 Configures,
659 Deploys,
661 Reads,
663 Writes,
665}
666
667#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
669#[serde(tag = "scope", content = "value", rename_all = "snake_case")]
670pub enum GraphRelationKind {
671 Legacy(RelationKind),
673 Extended(ExtendedRelationKind),
675}
676
677impl GraphRelationKind {
678 pub const ALL: [Self; 11] = [
680 Self::Legacy(RelationKind::Contains),
681 Self::Legacy(RelationKind::Imports),
682 Self::Legacy(RelationKind::Calls),
683 Self::Legacy(RelationKind::DependsOn),
684 Self::Extended(ExtendedRelationKind::References),
685 Self::Extended(ExtendedRelationKind::Tests),
686 Self::Extended(ExtendedRelationKind::RoutesTo),
687 Self::Extended(ExtendedRelationKind::Configures),
688 Self::Extended(ExtendedRelationKind::Deploys),
689 Self::Extended(ExtendedRelationKind::Reads),
690 Self::Extended(ExtendedRelationKind::Writes),
691 ];
692
693 #[must_use]
695 pub const fn from_legacy(kind: RelationKind) -> Self {
696 Self::Legacy(kind)
697 }
698
699 #[must_use]
701 pub const fn legacy_kind(self) -> Option<RelationKind> {
702 match self {
703 Self::Legacy(kind) => Some(kind),
704 Self::Extended(_) => None,
705 }
706 }
707
708 #[must_use]
710 pub const fn as_str(self) -> &'static str {
711 match self {
712 Self::Legacy(RelationKind::Contains) => "legacy:contains",
713 Self::Legacy(RelationKind::Imports) => "legacy:imports",
714 Self::Legacy(RelationKind::Calls) => "legacy:calls",
715 Self::Legacy(RelationKind::DependsOn) => "legacy:depends-on",
716 Self::Extended(ExtendedRelationKind::References) => "extended:references",
717 Self::Extended(ExtendedRelationKind::Tests) => "extended:tests",
718 Self::Extended(ExtendedRelationKind::RoutesTo) => "extended:routes-to",
719 Self::Extended(ExtendedRelationKind::Configures) => "extended:configures",
720 Self::Extended(ExtendedRelationKind::Deploys) => "extended:deploys",
721 Self::Extended(ExtendedRelationKind::Reads) => "extended:reads",
722 Self::Extended(ExtendedRelationKind::Writes) => "extended:writes",
723 }
724 }
725}
726
727#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
729#[serde(try_from = "SourceSpanWire", into = "SourceSpanWire")]
730pub struct SourceSpan {
731 start_line: u32,
733 start_column: u32,
735 end_line: u32,
737 end_column: u32,
739}
740
741impl SourceSpan {
742 pub fn new(
748 start_line: u32,
749 start_column: u32,
750 end_line: u32,
751 end_column: u32,
752 ) -> Result<Self, GraphContractError> {
753 if start_line == 0 || end_line == 0 {
754 return Err(GraphContractError::InvalidSourceSpan {
755 reason: "source lines are one-based",
756 });
757 }
758 if (end_line, end_column) < (start_line, start_column) {
759 return Err(GraphContractError::InvalidSourceSpan {
760 reason: "source span end precedes its start",
761 });
762 }
763 Ok(Self {
764 start_line,
765 start_column,
766 end_line,
767 end_column,
768 })
769 }
770
771 #[must_use]
773 pub const fn start_line(self) -> u32 {
774 self.start_line
775 }
776
777 #[must_use]
779 pub const fn start_column(self) -> u32 {
780 self.start_column
781 }
782
783 #[must_use]
785 pub const fn end_line(self) -> u32 {
786 self.end_line
787 }
788
789 #[must_use]
791 pub const fn end_column(self) -> u32 {
792 self.end_column
793 }
794}
795
796#[derive(Deserialize, Serialize)]
798struct SourceSpanWire {
799 start_line: u32,
801 start_column: u32,
803 end_line: u32,
805 end_column: u32,
807}
808
809impl TryFrom<SourceSpanWire> for SourceSpan {
810 type Error = GraphContractError;
811
812 fn try_from(value: SourceSpanWire) -> Result<Self, Self::Error> {
813 Self::new(
814 value.start_line,
815 value.start_column,
816 value.end_line,
817 value.end_column,
818 )
819 }
820}
821
822impl From<SourceSpan> for SourceSpanWire {
823 fn from(value: SourceSpan) -> Self {
824 Self {
825 start_line: value.start_line,
826 start_column: value.start_column,
827 end_line: value.end_line,
828 end_column: value.end_column,
829 }
830 }
831}
832
833#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
835#[serde(tag = "kind", rename_all = "snake_case")]
836pub enum ReusableTargetSelector {
837 Folder {
839 folder: RepositoryNodePath,
841 },
842 File {
844 file: RepositoryFilePath,
846 },
847 Package {
849 package: PackageSelector,
851 },
852 Symbol {
854 symbol: SymbolSelector,
856 },
857}
858
859impl ReusableTargetSelector {
860 fn for_entity(target: &GraphEntity) -> Result<Self, GraphContractError> {
862 match &target.selector {
863 EntitySelector::Project => Err(GraphContractError::InvalidResolution {
864 reason: "the project aggregate is not a direct source target",
865 }),
866 EntitySelector::Folder { path } => Ok(Self::Folder {
867 folder: path.clone(),
868 }),
869 EntitySelector::File { path } => Ok(Self::File { file: path.clone() }),
870 EntitySelector::Package { package } => Ok(Self::Package {
871 package: package.clone(),
872 }),
873 EntitySelector::Symbol { symbol } => Ok(Self::Symbol {
874 symbol: symbol.clone(),
875 }),
876 EntitySelector::External { .. } => Err(GraphContractError::InvalidResolution {
877 reason: "external entities do not have a local reusable selector",
878 }),
879 }
880 }
881
882 fn entity_selector(&self) -> EntitySelector {
884 match self {
885 Self::Folder { folder } => EntitySelector::Folder {
886 path: folder.clone(),
887 },
888 Self::File { file } => EntitySelector::File { path: file.clone() },
889 Self::Package { package } => EntitySelector::Package {
890 package: package.clone(),
891 },
892 Self::Symbol { symbol } => EntitySelector::Symbol {
893 symbol: symbol.clone(),
894 },
895 }
896 }
897}
898
899#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
901#[serde(tag = "status", rename_all = "snake_case")]
902pub enum RelationResolution {
903 Resolved {
905 target: GraphEntityKey,
907 selector: ReusableTargetSelector,
909 generation: IndexGeneration,
911 },
912 Ambiguous {
914 reference: GraphIdentityText,
916 candidates: NonZeroU32,
918 },
919 Unresolved {
921 reference: GraphIdentityText,
923 },
924 External {
926 target: GraphEntityKey,
928 external: ExternalSelector,
930 generation: IndexGeneration,
932 },
933}
934
935impl RelationResolution {
936 pub fn resolved(target: &GraphEntity) -> Result<Self, GraphContractError> {
942 Ok(Self::Resolved {
943 target: target.key.clone(),
944 selector: ReusableTargetSelector::for_entity(target)?,
945 generation: target.generation,
946 })
947 }
948
949 pub fn external(target: &GraphEntity) -> Result<Self, GraphContractError> {
955 let EntitySelector::External { external } = &target.selector else {
956 return Err(GraphContractError::InvalidResolution {
957 reason: "external resolution requires an external entity",
958 });
959 };
960 Ok(Self::External {
961 target: target.key.clone(),
962 external: external.clone(),
963 generation: target.generation,
964 })
965 }
966
967 #[must_use]
969 pub const fn resolved_target(&self) -> Option<&GraphEntityKey> {
970 match self {
971 Self::Resolved { target, .. } => Some(target),
972 Self::Ambiguous { .. } | Self::Unresolved { .. } | Self::External { .. } => None,
973 }
974 }
975
976 const fn target_key(&self) -> Option<&GraphEntityKey> {
978 match self {
979 Self::Resolved { target, .. } | Self::External { target, .. } => Some(target),
980 Self::Ambiguous { .. } | Self::Unresolved { .. } => None,
981 }
982 }
983
984 const fn target_generation(&self) -> Option<IndexGeneration> {
986 match self {
987 Self::Resolved { generation, .. } | Self::External { generation, .. } => {
988 Some(*generation)
989 }
990 Self::Ambiguous { .. } | Self::Unresolved { .. } => None,
991 }
992 }
993
994 fn append_canonical(&self, canonical: &mut String) {
996 match self {
997 Self::Resolved { target, .. } => {
998 append_canonical_field(canonical, "resolved");
999 append_canonical_field(canonical, target.canonical_identity());
1000 }
1001 Self::Ambiguous { reference, .. } => {
1002 append_canonical_field(canonical, "ambiguous");
1003 append_canonical_field(canonical, reference.as_str());
1004 }
1005 Self::Unresolved { reference } => {
1006 append_canonical_field(canonical, "unresolved");
1007 append_canonical_field(canonical, reference.as_str());
1008 }
1009 Self::External { target, .. } => {
1010 append_canonical_field(canonical, "external");
1011 append_canonical_field(canonical, target.canonical_identity());
1012 }
1013 }
1014 }
1015}
1016
1017#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1019#[serde(rename_all = "snake_case")]
1020pub enum ConfidenceClass {
1021 Exact,
1023 High,
1025 Medium,
1027 Low,
1029}
1030
1031#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1033#[serde(rename_all = "snake_case")]
1034pub enum Completeness {
1035 Complete,
1037 Partial,
1039}
1040
1041#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1043#[serde(try_from = "LogicalRelationKeyWire", into = "LogicalRelationKeyWire")]
1044pub struct LogicalRelationKey {
1045 project: ProjectInstanceId,
1047 stable: StableKey,
1049}
1050
1051#[derive(Deserialize, Serialize)]
1053struct LogicalRelationKeyWire {
1054 project: ProjectInstanceId,
1056 stable: StableKey,
1058}
1059
1060impl TryFrom<LogicalRelationKeyWire> for LogicalRelationKey {
1061 type Error = GraphContractError;
1062
1063 fn try_from(value: LogicalRelationKeyWire) -> Result<Self, Self::Error> {
1064 let prefix = relation_project_prefix(value.project);
1065 if !has_canonical_prefix(&value.stable.canonical_identity, &prefix) {
1066 return Err(GraphContractError::ProjectQualificationMismatch);
1067 }
1068 Ok(Self {
1069 project: value.project,
1070 stable: value.stable,
1071 })
1072 }
1073}
1074
1075impl From<LogicalRelationKey> for LogicalRelationKeyWire {
1076 fn from(value: LogicalRelationKey) -> Self {
1077 Self {
1078 project: value.project,
1079 stable: value.stable,
1080 }
1081 }
1082}
1083
1084impl LogicalRelationKey {
1085 #[must_use]
1087 pub const fn project(&self) -> ProjectInstanceId {
1088 self.project
1089 }
1090
1091 #[must_use]
1093 pub fn digest(&self) -> &str {
1094 &self.stable.digest
1095 }
1096
1097 pub fn digest_bytes(&self) -> Result<[u8; 32], GraphContractError> {
1104 self.stable.digest_bytes()
1105 }
1106
1107 #[must_use]
1109 pub fn canonical_identity(&self) -> &str {
1110 &self.stable.canonical_identity
1111 }
1112
1113 pub fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
1120 if self.project != other.project {
1121 return Ok(false);
1122 }
1123 self.stable.reconcile(&other.stable)
1124 }
1125}
1126
1127#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
1129#[serde(rename_all = "snake_case")]
1130pub enum ResolutionKeyDomain {
1131 Declaration,
1133 Module,
1135 Package,
1137}
1138
1139impl ResolutionKeyDomain {
1140 #[must_use]
1142 pub const fn as_str(self) -> &'static str {
1143 match self {
1144 Self::Declaration => "declaration",
1145 Self::Module => "module",
1146 Self::Package => "package",
1147 }
1148 }
1149}
1150
1151impl TryFrom<&str> for ResolutionKeyDomain {
1152 type Error = GraphContractError;
1153
1154 fn try_from(value: &str) -> Result<Self, Self::Error> {
1155 match value {
1156 "declaration" => Ok(Self::Declaration),
1157 "module" => Ok(Self::Module),
1158 "package" => Ok(Self::Package),
1159 _ => Err(GraphContractError::InvalidResolutionKeyDomain),
1160 }
1161 }
1162}
1163
1164#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
1170#[serde(
1171 try_from = "CanonicalResolutionKeyWire",
1172 into = "CanonicalResolutionKeyWire"
1173)]
1174pub struct CanonicalResolutionKey {
1175 project: ProjectInstanceId,
1177 domain: ResolutionKeyDomain,
1179 digest: [u8; 32],
1181 canonical_identity: String,
1183}
1184
1185impl CanonicalResolutionKey {
1186 #[allow(clippy::too_many_arguments)]
1188 #[must_use]
1189 pub fn new(
1190 project: ProjectInstanceId,
1191 domain: ResolutionKeyDomain,
1192 provider: &GraphIdentityText,
1193 language: &GraphIdentityText,
1194 package: Option<&GraphIdentityText>,
1195 scope: Option<&GraphIdentityText>,
1196 relation: Option<GraphRelationKind>,
1197 identity: &GraphIdentityText,
1198 ) -> Self {
1199 let mut canonical_identity = resolution_project_prefix(project, domain);
1200 append_canonical_field(&mut canonical_identity, provider.as_str());
1201 append_canonical_field(&mut canonical_identity, language.as_str());
1202 append_optional_canonical_field(&mut canonical_identity, package);
1203 append_optional_canonical_field(&mut canonical_identity, scope);
1204 append_optional_raw_canonical_field(
1205 &mut canonical_identity,
1206 relation.map(GraphRelationKind::as_str),
1207 );
1208 append_canonical_field(&mut canonical_identity, identity.as_str());
1209 let digest = *blake3::hash(canonical_identity.as_bytes()).as_bytes();
1210 Self {
1211 project,
1212 domain,
1213 digest,
1214 canonical_identity,
1215 }
1216 }
1217
1218 pub fn from_persisted(
1225 project: ProjectInstanceId,
1226 domain: ResolutionKeyDomain,
1227 digest: [u8; 32],
1228 canonical_identity: String,
1229 ) -> Result<Self, GraphContractError> {
1230 let prefix = resolution_project_prefix(project, domain);
1231 if !has_canonical_prefix(&canonical_identity, &prefix) {
1232 return Err(GraphContractError::ProjectQualificationMismatch);
1233 }
1234 if *blake3::hash(canonical_identity.as_bytes()).as_bytes() != digest {
1235 return Err(GraphContractError::InvalidStableKeyDigest);
1236 }
1237 Ok(Self {
1238 project,
1239 domain,
1240 digest,
1241 canonical_identity,
1242 })
1243 }
1244
1245 #[must_use]
1247 pub const fn project(&self) -> ProjectInstanceId {
1248 self.project
1249 }
1250
1251 #[must_use]
1253 pub const fn domain(&self) -> ResolutionKeyDomain {
1254 self.domain
1255 }
1256
1257 #[must_use]
1259 pub const fn digest_bytes(&self) -> [u8; 32] {
1260 self.digest
1261 }
1262
1263 #[must_use]
1265 pub fn canonical_identity(&self) -> &str {
1266 &self.canonical_identity
1267 }
1268
1269 pub fn portable(&self) -> Result<PortableResolutionKey, GraphContractError> {
1276 let prefix = resolution_project_prefix(self.project, self.domain);
1277 let Some(canonical_identity) = self.canonical_identity.strip_prefix(&prefix) else {
1278 return Err(GraphContractError::ProjectQualificationMismatch);
1279 };
1280 PortableResolutionKey::new(self.domain, canonical_identity.to_string())
1281 }
1282
1283 pub fn reconcile(&self, other: &Self) -> Result<bool, GraphContractError> {
1290 if self.project != other.project || self.domain != other.domain {
1291 return Ok(false);
1292 }
1293 if self.digest != other.digest {
1294 return Ok(false);
1295 }
1296 if self.canonical_identity != other.canonical_identity {
1297 return Err(GraphContractError::StableKeyCollision {
1298 digest: encode_hex(&self.digest),
1299 });
1300 }
1301 Ok(true)
1302 }
1303}
1304
1305#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
1308#[serde(
1309 try_from = "PortableResolutionKeyWire",
1310 into = "PortableResolutionKeyWire"
1311)]
1312pub struct PortableResolutionKey {
1313 domain: ResolutionKeyDomain,
1315 canonical_identity: String,
1317}
1318
1319impl PortableResolutionKey {
1320 pub fn new(
1327 domain: ResolutionKeyDomain,
1328 canonical_identity: String,
1329 ) -> Result<Self, GraphContractError> {
1330 if canonical_identity.len() > MAX_PORTABLE_RESOLUTION_IDENTITY_BYTES
1331 || !valid_canonical_field_sequence(&canonical_identity)
1332 {
1333 return Err(GraphContractError::InvalidResolutionKeyIdentity);
1334 }
1335 Ok(Self {
1336 domain,
1337 canonical_identity,
1338 })
1339 }
1340
1341 #[must_use]
1343 pub const fn domain(&self) -> ResolutionKeyDomain {
1344 self.domain
1345 }
1346
1347 #[must_use]
1349 pub fn canonical_identity(&self) -> &str {
1350 &self.canonical_identity
1351 }
1352
1353 #[must_use]
1355 pub fn bind(&self, project: ProjectInstanceId) -> CanonicalResolutionKey {
1356 let mut canonical_identity = resolution_project_prefix(project, self.domain);
1357 canonical_identity.push_str(&self.canonical_identity);
1358 let digest = *blake3::hash(canonical_identity.as_bytes()).as_bytes();
1359 CanonicalResolutionKey {
1360 project,
1361 domain: self.domain,
1362 digest,
1363 canonical_identity,
1364 }
1365 }
1366}
1367
1368#[derive(Deserialize, Serialize)]
1370struct PortableResolutionKeyWire {
1371 domain: ResolutionKeyDomain,
1373 canonical_identity: String,
1375}
1376
1377impl TryFrom<PortableResolutionKeyWire> for PortableResolutionKey {
1378 type Error = GraphContractError;
1379
1380 fn try_from(value: PortableResolutionKeyWire) -> Result<Self, Self::Error> {
1381 Self::new(value.domain, value.canonical_identity)
1382 }
1383}
1384
1385impl From<PortableResolutionKey> for PortableResolutionKeyWire {
1386 fn from(value: PortableResolutionKey) -> Self {
1387 Self {
1388 domain: value.domain,
1389 canonical_identity: value.canonical_identity,
1390 }
1391 }
1392}
1393
1394#[derive(Deserialize, Serialize)]
1396struct CanonicalResolutionKeyWire {
1397 project: ProjectInstanceId,
1399 domain: ResolutionKeyDomain,
1401 digest: [u8; 32],
1403 canonical_identity: String,
1405}
1406
1407impl TryFrom<CanonicalResolutionKeyWire> for CanonicalResolutionKey {
1408 type Error = GraphContractError;
1409
1410 fn try_from(value: CanonicalResolutionKeyWire) -> Result<Self, Self::Error> {
1411 Self::from_persisted(
1412 value.project,
1413 value.domain,
1414 value.digest,
1415 value.canonical_identity,
1416 )
1417 }
1418}
1419
1420impl From<CanonicalResolutionKey> for CanonicalResolutionKeyWire {
1421 fn from(value: CanonicalResolutionKey) -> Self {
1422 Self {
1423 project: value.project,
1424 domain: value.domain,
1425 digest: value.digest,
1426 canonical_identity: value.canonical_identity,
1427 }
1428 }
1429}
1430
1431#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1433pub struct EntityResolutionKey {
1434 entity: GraphEntityKey,
1436 key: CanonicalResolutionKey,
1438}
1439
1440impl EntityResolutionKey {
1441 pub fn new(
1447 entity: GraphEntityKey,
1448 key: CanonicalResolutionKey,
1449 ) -> Result<Self, GraphContractError> {
1450 if entity.project() != key.project() {
1451 return Err(GraphContractError::ResolutionKeyOwnerMismatch);
1452 }
1453 Ok(Self { entity, key })
1454 }
1455
1456 #[must_use]
1458 pub const fn entity(&self) -> &GraphEntityKey {
1459 &self.entity
1460 }
1461
1462 #[must_use]
1464 pub const fn key(&self) -> &CanonicalResolutionKey {
1465 &self.key
1466 }
1467}
1468
1469#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1471pub struct RelationDependencyKey {
1472 relation: LogicalRelationKey,
1474 key: CanonicalResolutionKey,
1476}
1477
1478impl RelationDependencyKey {
1479 pub fn new(
1485 relation: LogicalRelationKey,
1486 key: CanonicalResolutionKey,
1487 ) -> Result<Self, GraphContractError> {
1488 if relation.project() != key.project() {
1489 return Err(GraphContractError::ResolutionKeyOwnerMismatch);
1490 }
1491 Ok(Self { relation, key })
1492 }
1493
1494 #[must_use]
1496 pub const fn relation(&self) -> &LogicalRelationKey {
1497 &self.relation
1498 }
1499
1500 #[must_use]
1502 pub const fn key(&self) -> &CanonicalResolutionKey {
1503 &self.key
1504 }
1505}
1506
1507#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1509pub struct LogicalRelation {
1510 key: LogicalRelationKey,
1512 source: GraphEntityKey,
1514 kind: GraphRelationKind,
1516 resolution: RelationResolution,
1518 confidence: ConfidenceClass,
1520 completeness: Completeness,
1522 generation: IndexGeneration,
1524}
1525
1526impl LogicalRelation {
1527 pub fn new(
1535 source: &GraphEntity,
1536 kind: GraphRelationKind,
1537 resolution: RelationResolution,
1538 confidence: ConfidenceClass,
1539 completeness: Completeness,
1540 generation: IndexGeneration,
1541 ) -> Result<Self, GraphContractError> {
1542 if generation == IndexGeneration::ZERO {
1543 return Err(GraphContractError::InvalidGeneration);
1544 }
1545 if source.generation != generation {
1546 return Err(GraphContractError::GenerationMismatch {
1547 context: "logical relation source",
1548 });
1549 }
1550 if resolution
1551 .target_key()
1552 .is_some_and(|target| target.project() != source.key.project())
1553 {
1554 return Err(GraphContractError::CrossProjectRelation);
1555 }
1556 if resolution
1557 .target_generation()
1558 .is_some_and(|target_generation| target_generation != generation)
1559 {
1560 return Err(GraphContractError::GenerationMismatch {
1561 context: "logical relation target",
1562 });
1563 }
1564 let selected = match &resolution {
1565 RelationResolution::Resolved {
1566 target, selector, ..
1567 } => Some((target, selector.entity_selector())),
1568 RelationResolution::External {
1569 target, external, ..
1570 } => Some((
1571 target,
1572 EntitySelector::External {
1573 external: external.clone(),
1574 },
1575 )),
1576 RelationResolution::Ambiguous { .. } | RelationResolution::Unresolved { .. } => None,
1577 };
1578 if let Some((target, selector)) = selected {
1579 let selected = GraphEntityKey::new(target.project(), &selector);
1580 if !target.reconcile(&selected)? {
1581 return Err(GraphContractError::InvalidResolution {
1582 reason: "resolution selector does not identify its retained target",
1583 });
1584 }
1585 }
1586 let mut canonical = relation_project_prefix(source.key.project());
1587 append_canonical_field(&mut canonical, source.key.canonical_identity());
1588 append_canonical_field(&mut canonical, kind.as_str());
1589 resolution.append_canonical(&mut canonical);
1590 let key = LogicalRelationKey {
1591 project: source.key.project(),
1592 stable: StableKey::new(canonical),
1593 };
1594 Ok(Self {
1595 key,
1596 source: source.key.clone(),
1597 kind,
1598 resolution,
1599 confidence,
1600 completeness,
1601 generation,
1602 })
1603 }
1604
1605 #[must_use]
1607 pub const fn key(&self) -> &LogicalRelationKey {
1608 &self.key
1609 }
1610
1611 #[must_use]
1613 pub const fn source(&self) -> &GraphEntityKey {
1614 &self.source
1615 }
1616
1617 #[must_use]
1619 pub const fn kind(&self) -> GraphRelationKind {
1620 self.kind
1621 }
1622
1623 #[must_use]
1625 pub const fn resolution(&self) -> &RelationResolution {
1626 &self.resolution
1627 }
1628
1629 #[must_use]
1631 pub const fn confidence(&self) -> ConfidenceClass {
1632 self.confidence
1633 }
1634
1635 #[must_use]
1637 pub const fn completeness(&self) -> Completeness {
1638 self.completeness
1639 }
1640
1641 #[must_use]
1643 pub const fn generation(&self) -> IndexGeneration {
1644 self.generation
1645 }
1646}
1647
1648#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1650pub struct RelationOccurrence {
1651 relation: LogicalRelationKey,
1653 file: RepositoryFilePath,
1655 span: SourceSpan,
1657 generation: IndexGeneration,
1659}
1660
1661impl RelationOccurrence {
1662 pub fn new(
1668 relation: &LogicalRelation,
1669 file: RepositoryFilePath,
1670 span: SourceSpan,
1671 generation: IndexGeneration,
1672 ) -> Result<Self, GraphContractError> {
1673 if relation.generation != generation {
1674 return Err(GraphContractError::GenerationMismatch {
1675 context: "relation occurrence",
1676 });
1677 }
1678 Ok(Self {
1679 relation: relation.key.clone(),
1680 file,
1681 span,
1682 generation,
1683 })
1684 }
1685
1686 #[must_use]
1688 pub const fn relation(&self) -> &LogicalRelationKey {
1689 &self.relation
1690 }
1691
1692 #[must_use]
1694 pub const fn file(&self) -> &RepositoryFilePath {
1695 &self.file
1696 }
1697
1698 #[must_use]
1700 pub const fn span(&self) -> SourceSpan {
1701 self.span
1702 }
1703
1704 #[must_use]
1706 pub const fn generation(&self) -> IndexGeneration {
1707 self.generation
1708 }
1709}
1710
1711#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1713#[serde(rename_all = "snake_case")]
1714pub enum CoverageState {
1715 Complete,
1717 Partial,
1719 Failed,
1721 Ignored,
1723 Oversized,
1725 Quarantined,
1727 Stale,
1729}
1730
1731#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1733#[serde(tag = "kind", rename_all = "snake_case")]
1734pub enum CoverageScope {
1735 Project,
1737 Path {
1739 path: RepositoryNodePath,
1741 },
1742}
1743
1744#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1746#[serde(rename_all = "snake_case")]
1747pub enum GraphLimitKind {
1748 Rows,
1750 Nodes,
1752 Edges,
1754 Occurrences,
1756 Visited,
1758 IntermediateBytes,
1760 Deadline,
1762 Depth,
1764 OutputBytes,
1766}
1767
1768#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1770pub struct CoverageRecord {
1771 scope: CoverageScope,
1773 relation: Option<GraphRelationKind>,
1775 state: CoverageState,
1777 total: u64,
1779 covered: u64,
1781 omitted: u64,
1783 generation: IndexGeneration,
1785 reason: Option<GraphIdentityText>,
1787 reached_limit: Option<GraphLimitKind>,
1789}
1790
1791impl CoverageRecord {
1792 pub fn new(
1799 scope: CoverageScope,
1800 relation: Option<GraphRelationKind>,
1801 state: CoverageState,
1802 covered: u64,
1803 omitted: u64,
1804 generation: IndexGeneration,
1805 reason: Option<GraphIdentityText>,
1806 reached_limit: Option<GraphLimitKind>,
1807 ) -> Result<Self, GraphContractError> {
1808 if generation == IndexGeneration::ZERO {
1809 return Err(GraphContractError::InvalidGeneration);
1810 }
1811 let total = covered
1812 .checked_add(omitted)
1813 .ok_or(GraphContractError::InvalidCoverage {
1814 reason: "coverage counts overflow",
1815 })?;
1816 let valid_counts = match state {
1817 CoverageState::Complete => omitted == 0,
1818 CoverageState::Partial => covered > 0 && omitted > 0,
1819 CoverageState::Failed
1820 | CoverageState::Ignored
1821 | CoverageState::Oversized
1822 | CoverageState::Quarantined
1823 | CoverageState::Stale => covered == 0 && omitted > 0,
1824 };
1825 if !valid_counts {
1826 return Err(GraphContractError::InvalidCoverage {
1827 reason: "coverage state contradicts covered and omitted counts",
1828 });
1829 }
1830 if state == CoverageState::Complete && (reason.is_some() || reached_limit.is_some()) {
1831 return Err(GraphContractError::InvalidCoverage {
1832 reason: "complete coverage cannot report an omission reason or limit",
1833 });
1834 }
1835 if state != CoverageState::Complete && reason.is_none() {
1836 return Err(GraphContractError::InvalidCoverage {
1837 reason: "non-complete coverage requires an actionable reason",
1838 });
1839 }
1840 Ok(Self {
1841 scope,
1842 relation,
1843 state,
1844 total,
1845 covered,
1846 omitted,
1847 generation,
1848 reason,
1849 reached_limit,
1850 })
1851 }
1852
1853 #[must_use]
1855 pub const fn scope(&self) -> &CoverageScope {
1856 &self.scope
1857 }
1858
1859 #[must_use]
1861 pub const fn relation(&self) -> Option<GraphRelationKind> {
1862 self.relation
1863 }
1864
1865 #[must_use]
1867 pub const fn state(&self) -> CoverageState {
1868 self.state
1869 }
1870
1871 #[must_use]
1873 pub const fn total(&self) -> u64 {
1874 self.total
1875 }
1876
1877 #[must_use]
1879 pub const fn covered(&self) -> u64 {
1880 self.covered
1881 }
1882
1883 #[must_use]
1885 pub const fn omitted(&self) -> u64 {
1886 self.omitted
1887 }
1888
1889 #[must_use]
1891 pub const fn generation(&self) -> IndexGeneration {
1892 self.generation
1893 }
1894
1895 #[must_use]
1897 pub const fn reason(&self) -> Option<&GraphIdentityText> {
1898 self.reason.as_ref()
1899 }
1900
1901 #[must_use]
1903 pub const fn reached_limit(&self) -> Option<GraphLimitKind> {
1904 self.reached_limit
1905 }
1906}
1907
1908#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1910#[serde(try_from = "GraphLimitsWire", into = "GraphLimitsWire")]
1911pub struct GraphLimits {
1912 rows: NonZeroU32,
1914 occurrences: NonZeroU32,
1916 depth: NonZeroU32,
1918 output_bytes: NonZeroU32,
1920}
1921
1922impl GraphLimits {
1923 pub const MAX_ROWS: u32 = 10_000;
1925 pub const MAX_OCCURRENCES: u32 = 1_024;
1927 pub const MAX_DEPTH: u32 = 64;
1929 pub const MAX_OUTPUT_BYTES: u32 = 16 * 1_024 * 1_024;
1931
1932 pub fn new(
1938 rows: u32,
1939 occurrences: u32,
1940 depth: u32,
1941 output_bytes: u32,
1942 ) -> Result<Self, GraphContractError> {
1943 if rows == 0 || rows > Self::MAX_ROWS {
1944 return Err(GraphContractError::InvalidLimits {
1945 reason: "row limit is zero or above the absolute ceiling",
1946 });
1947 }
1948 if occurrences == 0 || occurrences > Self::MAX_OCCURRENCES {
1949 return Err(GraphContractError::InvalidLimits {
1950 reason: "occurrence limit is zero or above the absolute ceiling",
1951 });
1952 }
1953 if depth == 0 || depth > Self::MAX_DEPTH {
1954 return Err(GraphContractError::InvalidLimits {
1955 reason: "depth limit is zero or above the absolute ceiling",
1956 });
1957 }
1958 if output_bytes == 0 || output_bytes > Self::MAX_OUTPUT_BYTES {
1959 return Err(GraphContractError::InvalidLimits {
1960 reason: "output limit is zero or above the absolute ceiling",
1961 });
1962 }
1963 Ok(Self {
1964 rows: NonZeroU32::new(rows).ok_or(GraphContractError::InvalidLimits {
1965 reason: "row limit must be nonzero",
1966 })?,
1967 occurrences: NonZeroU32::new(occurrences).ok_or(GraphContractError::InvalidLimits {
1968 reason: "occurrence limit must be nonzero",
1969 })?,
1970 depth: NonZeroU32::new(depth).ok_or(GraphContractError::InvalidLimits {
1971 reason: "depth limit must be nonzero",
1972 })?,
1973 output_bytes: NonZeroU32::new(output_bytes).ok_or(
1974 GraphContractError::InvalidLimits {
1975 reason: "output limit must be nonzero",
1976 },
1977 )?,
1978 })
1979 }
1980
1981 #[must_use]
1983 pub const fn rows(self) -> u32 {
1984 self.rows.get()
1985 }
1986
1987 #[must_use]
1989 pub const fn occurrences(self) -> u32 {
1990 self.occurrences.get()
1991 }
1992
1993 #[must_use]
1995 pub const fn depth(self) -> u32 {
1996 self.depth.get()
1997 }
1998
1999 #[must_use]
2001 pub const fn output_bytes(self) -> u32 {
2002 self.output_bytes.get()
2003 }
2004}
2005
2006#[derive(Deserialize, Serialize)]
2008struct GraphLimitsWire {
2009 rows: u32,
2011 occurrences: u32,
2013 depth: u32,
2015 output_bytes: u32,
2017}
2018
2019impl TryFrom<GraphLimitsWire> for GraphLimits {
2020 type Error = GraphContractError;
2021
2022 fn try_from(value: GraphLimitsWire) -> Result<Self, Self::Error> {
2023 Self::new(
2024 value.rows,
2025 value.occurrences,
2026 value.depth,
2027 value.output_bytes,
2028 )
2029 }
2030}
2031
2032impl From<GraphLimits> for GraphLimitsWire {
2033 fn from(value: GraphLimits) -> Self {
2034 Self {
2035 rows: value.rows(),
2036 occurrences: value.occurrences(),
2037 depth: value.depth(),
2038 output_bytes: value.output_bytes(),
2039 }
2040 }
2041}
2042
2043fn project_canonical_prefix(domain: &str, project: ProjectInstanceId) -> String {
2045 let mut canonical = domain.to_string();
2046 append_canonical_field(&mut canonical, &project.as_hex());
2047 canonical
2048}
2049
2050fn entity_project_prefix(project: ProjectInstanceId) -> String {
2052 project_canonical_prefix(ENTITY_KEY_DOMAIN, project)
2053}
2054
2055fn relation_project_prefix(project: ProjectInstanceId) -> String {
2057 project_canonical_prefix(RELATION_KEY_DOMAIN, project)
2058}
2059
2060fn resolution_project_prefix(project: ProjectInstanceId, domain: ResolutionKeyDomain) -> String {
2062 let mut canonical = project_canonical_prefix(RESOLUTION_KEY_DOMAIN, project);
2063 append_canonical_field(&mut canonical, domain.as_str());
2064 canonical
2065}
2066
2067fn has_canonical_prefix(canonical: &str, prefix: &str) -> bool {
2069 canonical
2070 .strip_prefix(prefix)
2071 .is_some_and(|remainder| remainder.starts_with('|'))
2072}
2073
2074fn entity_canonical_identity(project: ProjectInstanceId, selector: &EntitySelector) -> String {
2076 let mut canonical = entity_project_prefix(project);
2077 match selector {
2078 EntitySelector::Project => append_canonical_field(&mut canonical, "project"),
2079 EntitySelector::Folder { path } => {
2080 append_canonical_field(&mut canonical, "folder");
2081 append_canonical_field(&mut canonical, path.as_str());
2082 }
2083 EntitySelector::File { path } => {
2084 append_canonical_field(&mut canonical, "file");
2085 append_canonical_field(&mut canonical, path.as_str());
2086 }
2087 EntitySelector::Package { package } => {
2088 append_canonical_field(&mut canonical, "package");
2089 append_canonical_field(&mut canonical, package.manager.as_str());
2090 append_canonical_field(&mut canonical, package.name.as_str());
2091 append_canonical_field(&mut canonical, package.manifest.as_str());
2092 }
2093 EntitySelector::Symbol { symbol } => {
2094 append_canonical_field(&mut canonical, "symbol");
2095 append_canonical_field(&mut canonical, symbol.file.as_str());
2096 append_canonical_field(&mut canonical, symbol.name.as_str());
2097 append_canonical_field(&mut canonical, &symbol.kind.to_string());
2098 append_optional_canonical_field(&mut canonical, symbol.parent.as_ref());
2099 append_canonical_field(&mut canonical, symbol.signature.as_str());
2100 }
2101 EntitySelector::External { external } => {
2102 append_canonical_field(&mut canonical, "external");
2103 append_canonical_field(&mut canonical, external.system.as_str());
2104 append_canonical_field(&mut canonical, external.identity.as_str());
2105 }
2106 }
2107 canonical
2108}
2109
2110fn append_canonical_field(canonical: &mut String, value: &str) {
2112 canonical.push('|');
2113 canonical.push_str(&value.len().to_string());
2114 canonical.push(':');
2115 canonical.push_str(value);
2116}
2117
2118fn valid_canonical_field_sequence(canonical: &str) -> bool {
2121 let Some(fields) = decode_canonical_fields(canonical) else {
2122 return false;
2123 };
2124 let mut index = 0;
2125 for _required in 0..2 {
2126 let Some(field) = fields.get(index) else {
2127 return false;
2128 };
2129 if !valid_canonical_identity_field(field) {
2130 return false;
2131 }
2132 index += 1;
2133 }
2134 for _optional in 0..3 {
2135 let Some(marker) = fields.get(index) else {
2136 return false;
2137 };
2138 index += 1;
2139 match *marker {
2140 "none" => {}
2141 "some" => {
2142 let Some(field) = fields.get(index) else {
2143 return false;
2144 };
2145 if !valid_canonical_identity_field(field) {
2146 return false;
2147 }
2148 index += 1;
2149 }
2150 _ => return false,
2151 }
2152 }
2153 fields
2154 .get(index)
2155 .is_some_and(|field| valid_canonical_identity_field(field))
2156 && index + 1 == fields.len()
2157}
2158
2159fn decode_canonical_fields(mut canonical: &str) -> Option<Vec<&str>> {
2161 let mut fields = Vec::new();
2162 while !canonical.is_empty() {
2163 canonical = canonical.strip_prefix('|')?;
2164 let separator = canonical.find(':')?;
2165 let length_text = canonical.get(..separator)?;
2166 let length = length_text.parse::<usize>().ok()?;
2167 if length_text != length.to_string() {
2168 return None;
2169 }
2170 canonical = canonical.get(separator + 1..)?;
2171 if length == 0
2172 || length > MAX_IDENTITY_BYTES
2173 || length > canonical.len()
2174 || !canonical.is_char_boundary(length)
2175 {
2176 return None;
2177 }
2178 let (field, remainder) = canonical.split_at(length);
2179 fields.push(field);
2180 canonical = remainder;
2181 }
2182 Some(fields)
2183}
2184
2185fn valid_canonical_identity_field(value: &str) -> bool {
2187 !value.is_empty() && value.len() <= MAX_IDENTITY_BYTES && !value.chars().any(char::is_control)
2188}
2189
2190fn append_optional_canonical_field(canonical: &mut String, value: Option<&GraphIdentityText>) {
2192 match value {
2193 Some(value) => {
2194 append_canonical_field(canonical, "some");
2195 append_canonical_field(canonical, value.as_str());
2196 }
2197 None => append_canonical_field(canonical, "none"),
2198 }
2199}
2200
2201fn append_optional_raw_canonical_field(canonical: &mut String, value: Option<&str>) {
2203 match value {
2204 Some(value) => {
2205 append_canonical_field(canonical, "some");
2206 append_canonical_field(canonical, value);
2207 }
2208 None => append_canonical_field(canonical, "none"),
2209 }
2210}
2211
2212fn encode_hex(bytes: &[u8]) -> String {
2214 const HEX: &[u8; 16] = b"0123456789abcdef";
2215 let mut encoded = String::with_capacity(bytes.len() * 2);
2216 for byte in bytes {
2217 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
2218 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
2219 }
2220 encoded
2221}
2222
2223const fn decode_hex(value: u8) -> Option<u8> {
2225 match value {
2226 b'0'..=b'9' => Some(value - b'0'),
2227 b'a'..=b'f' => Some(value - b'a' + 10),
2228 b'A'..=b'F' => Some(value - b'A' + 10),
2229 _ => None,
2230 }
2231}
2232
2233#[cfg(test)]
2234mod tests {
2235 use super::{
2236 CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
2237 CoverageState, EntityResolutionKey, EntitySelector, ExtendedRelationKind, ExternalSelector,
2238 GraphContractError, GraphEntity, GraphEntityKey, GraphIdentityText, GraphLimitKind,
2239 GraphLimits, GraphRelationKind, LogicalRelation, PackageSelector, PortableResolutionKey,
2240 ProjectInstanceId, RelationDependencyKey, RelationOccurrence, RelationResolution,
2241 RepositoryFilePath, RepositoryNodePath, ResolutionKeyDomain, ReusableTargetSelector,
2242 SourceSpan, StableKey, SymbolSelector,
2243 };
2244 use crate::IndexGeneration;
2245 use crate::symbols::{RelationKind, SymbolKind};
2246 use std::io;
2247 use std::num::NonZeroU32;
2248 use std::path::Path;
2249
2250 fn project() -> Result<ProjectInstanceId, GraphContractError> {
2252 ProjectInstanceId::try_from("00112233445566778899aabbccddeeff")
2253 }
2254
2255 fn symbol_selector(path: &str) -> Result<SymbolSelector, GraphContractError> {
2257 Ok(SymbolSelector {
2258 file: RepositoryFilePath::new(Path::new(path))?,
2259 name: GraphIdentityText::new("répond")?,
2260 kind: SymbolKind::Function,
2261 parent: Some(GraphIdentityText::new("Service")?),
2262 signature: GraphIdentityText::new("fn répond(input: &str)")?,
2263 })
2264 }
2265
2266 fn require(condition: bool, message: &'static str) -> Result<(), Box<dyn std::error::Error>> {
2268 if condition {
2269 Ok(())
2270 } else {
2271 Err(io::Error::other(message).into())
2272 }
2273 }
2274
2275 #[test]
2276 fn project_identity_validates_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
2277 let id = ProjectInstanceId::try_from("00112233-4455-6677-8899-AABBCCDDEEFF")?;
2278 require(
2279 id.as_hex() == "00112233445566778899aabbccddeeff",
2280 "project identity did not normalize to lowercase hexadecimal",
2281 )?;
2282 let encoded = serde_json::to_string(&id)?;
2283 require(
2284 encoded == "\"00112233445566778899aabbccddeeff\"",
2285 "project identity serialized with an unstable shape",
2286 )?;
2287 require(
2288 serde_json::from_str::<ProjectInstanceId>(&encoded)? == id,
2289 "project identity did not round-trip",
2290 )?;
2291 require(
2292 ProjectInstanceId::try_from("00000000000000000000000000000000").is_err(),
2293 "zero project identity was accepted",
2294 )?;
2295 require(
2296 ProjectInstanceId::try_from("not-an-id").is_err(),
2297 "malformed project identity was accepted",
2298 )?;
2299 require(
2300 ProjectInstanceId::try_from("00112233-4455-6677-8899-AABBCCDDEEF-").is_err(),
2301 "hyphenated project identity with missing hexadecimal data was accepted",
2302 )?;
2303 Ok(())
2304 }
2305
2306 #[test]
2307 fn entity_keys_are_stable_line_independent_and_project_scoped()
2308 -> Result<(), Box<dyn std::error::Error>> {
2309 let selector = EntitySelector::Symbol {
2310 symbol: symbol_selector("src\\service.rs")?,
2311 };
2312 let first = GraphEntity::new(project()?, selector.clone(), IndexGeneration::new(4))?;
2313 let rescanned = GraphEntity::new(project()?, selector, IndexGeneration::new(5))?;
2314 require(
2315 first.key().reconcile(rescanned.key())?,
2316 "unchanged selector did not retain identity",
2317 )?;
2318 require(
2319 first.key().digest() == rescanned.key().digest(),
2320 "unchanged selector changed its compact key",
2321 )?;
2322 require(
2323 first.key().digest_bytes()?
2324 == *blake3::hash(first.key().canonical_identity().as_bytes()).as_bytes(),
2325 "binary persistence key did not match the validated digest",
2326 )?;
2327
2328 let other_project = ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?;
2329 let other = GraphEntityKey::new(other_project, rescanned.selector());
2330 require(
2331 !first.key().reconcile(&other)?,
2332 "independent projects shared an entity identity",
2333 )?;
2334 require(
2335 first.key().digest() != other.digest(),
2336 "independent projects shared a compact entity key",
2337 )?;
2338 Ok(())
2339 }
2340
2341 #[test]
2342 fn stable_key_collisions_fail_closed() -> Result<(), Box<dyn std::error::Error>> {
2343 let selector = EntitySelector::File {
2344 path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
2345 };
2346 let first = GraphEntityKey::new(project()?, &selector);
2347 let conflicting = GraphEntityKey {
2348 project: first.project,
2349 stable: StableKey {
2350 digest: first.stable.digest.clone(),
2351 canonical_identity: "different canonical identity".to_string(),
2352 },
2353 };
2354 require(
2355 matches!(
2356 first.reconcile(&conflicting),
2357 Err(GraphContractError::StableKeyCollision { .. })
2358 ),
2359 "conflicting canonical identity did not fail closed",
2360 )?;
2361
2362 let mut serialized = serde_json::to_value(&first)?;
2363 serialized["stable"]["canonical_identity"] = serde_json::json!("tampered");
2364 require(
2365 serde_json::from_value::<GraphEntityKey>(serialized).is_err(),
2366 "tampered stable-key material was accepted",
2367 )?;
2368 Ok(())
2369 }
2370
2371 #[test]
2372 fn canonical_resolution_keys_are_stable_qualified_and_collision_checked()
2373 -> Result<(), Box<dyn std::error::Error>> {
2374 let provider = GraphIdentityText::new("tree-sitter")?;
2375 let language = GraphIdentityText::new("rust")?;
2376 let package = GraphIdentityText::new("auth")?;
2377 let scope = GraphIdentityText::new("crate")?;
2378 let identity = GraphIdentityText::new("répond")?;
2379 let key = CanonicalResolutionKey::new(
2380 project()?,
2381 ResolutionKeyDomain::Declaration,
2382 &provider,
2383 &language,
2384 Some(&package),
2385 Some(&scope),
2386 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2387 &identity,
2388 );
2389 let repeated = CanonicalResolutionKey::new(
2390 project()?,
2391 ResolutionKeyDomain::Declaration,
2392 &provider,
2393 &language,
2394 Some(&package),
2395 Some(&scope),
2396 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2397 &identity,
2398 );
2399 require(
2400 key.reconcile(&repeated)?,
2401 "equal canonical resolver input changed identity",
2402 )?;
2403 require(
2404 key.digest_bytes() == *blake3::hash(key.canonical_identity().as_bytes()).as_bytes(),
2405 "fixed resolver digest did not match its collision witness",
2406 )?;
2407 let persisted = CanonicalResolutionKey::from_persisted(
2408 key.project(),
2409 key.domain(),
2410 key.digest_bytes(),
2411 key.canonical_identity().to_string(),
2412 )?;
2413 require(
2414 key.reconcile(&persisted)?,
2415 "validated persisted resolver key changed identity",
2416 )?;
2417
2418 let other_project = ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?;
2419 let case_distinct = GraphIdentityText::new("Répond")?;
2420 let variants = [
2421 CanonicalResolutionKey::new(
2422 other_project,
2423 ResolutionKeyDomain::Declaration,
2424 &provider,
2425 &language,
2426 Some(&package),
2427 Some(&scope),
2428 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2429 &identity,
2430 ),
2431 CanonicalResolutionKey::new(
2432 project()?,
2433 ResolutionKeyDomain::Module,
2434 &provider,
2435 &language,
2436 Some(&package),
2437 Some(&scope),
2438 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2439 &identity,
2440 ),
2441 CanonicalResolutionKey::new(
2442 project()?,
2443 ResolutionKeyDomain::Declaration,
2444 &GraphIdentityText::new("manifest")?,
2445 &language,
2446 Some(&package),
2447 Some(&scope),
2448 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2449 &identity,
2450 ),
2451 CanonicalResolutionKey::new(
2452 project()?,
2453 ResolutionKeyDomain::Declaration,
2454 &provider,
2455 &GraphIdentityText::new("typescript")?,
2456 Some(&package),
2457 Some(&scope),
2458 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2459 &identity,
2460 ),
2461 CanonicalResolutionKey::new(
2462 project()?,
2463 ResolutionKeyDomain::Declaration,
2464 &provider,
2465 &language,
2466 Some(&GraphIdentityText::new("billing")?),
2467 Some(&scope),
2468 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2469 &identity,
2470 ),
2471 CanonicalResolutionKey::new(
2472 project()?,
2473 ResolutionKeyDomain::Declaration,
2474 &provider,
2475 &language,
2476 Some(&package),
2477 Some(&GraphIdentityText::new("module")?),
2478 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2479 &identity,
2480 ),
2481 CanonicalResolutionKey::new(
2482 project()?,
2483 ResolutionKeyDomain::Declaration,
2484 &provider,
2485 &language,
2486 Some(&package),
2487 Some(&scope),
2488 Some(GraphRelationKind::Legacy(RelationKind::Imports)),
2489 &identity,
2490 ),
2491 CanonicalResolutionKey::new(
2492 project()?,
2493 ResolutionKeyDomain::Declaration,
2494 &provider,
2495 &language,
2496 Some(&package),
2497 Some(&scope),
2498 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2499 &case_distinct,
2500 ),
2501 ];
2502 for variant in variants {
2503 require(
2504 !key.reconcile(&variant)?,
2505 "identity-affecting resolver dimension was ignored",
2506 )?;
2507 }
2508 require(
2509 matches!(
2510 ResolutionKeyDomain::try_from("declaration"),
2511 Ok(ResolutionKeyDomain::Declaration)
2512 ) && ResolutionKeyDomain::try_from("unknown").is_err(),
2513 "closed resolver-domain persistence accepted an unsupported value",
2514 )?;
2515
2516 let conflicting = CanonicalResolutionKey {
2517 canonical_identity: "different canonical identity".to_string(),
2518 ..key
2519 };
2520 require(
2521 matches!(
2522 key.reconcile(&conflicting),
2523 Err(GraphContractError::StableKeyCollision { .. })
2524 ),
2525 "equal resolver digest with a different witness did not fail closed",
2526 )?;
2527 let mut serialized = serde_json::to_value(&key)?;
2528 serialized["canonical_identity"] = serde_json::json!("tampered");
2529 require(
2530 serde_json::from_value::<CanonicalResolutionKey>(serialized).is_err(),
2531 "tampered persisted resolver key was accepted",
2532 )?;
2533 Ok(())
2534 }
2535
2536 #[test]
2537 fn portable_resolution_keys_rebind_without_project_identity()
2538 -> Result<(), Box<dyn std::error::Error>> {
2539 let provider = GraphIdentityText::new("tree-sitter")?;
2540 let language = GraphIdentityText::new("rust")?;
2541 let package = GraphIdentityText::new("auth")?;
2542 let scope = GraphIdentityText::new("crate")?;
2543 let identity = GraphIdentityText::new("répond")?;
2544 let source = CanonicalResolutionKey::new(
2545 project()?,
2546 ResolutionKeyDomain::Declaration,
2547 &provider,
2548 &language,
2549 Some(&package),
2550 Some(&scope),
2551 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2552 &identity,
2553 );
2554 let portable = source.portable()?;
2555 let encoded = serde_json::to_string(&portable)?;
2556 require(
2557 !encoded.contains(&project()?.as_hex()),
2558 "portable resolver key leaked its source project identity",
2559 )?;
2560 let decoded = serde_json::from_str::<PortableResolutionKey>(&encoded)?;
2561 let destination = ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?;
2562 let rebound = decoded.bind(destination);
2563 let expected = CanonicalResolutionKey::new(
2564 destination,
2565 ResolutionKeyDomain::Declaration,
2566 &provider,
2567 &language,
2568 Some(&package),
2569 Some(&scope),
2570 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2571 &identity,
2572 );
2573 require(
2574 rebound.reconcile(&expected)?,
2575 "portable resolver key did not rebind to the destination project",
2576 )?;
2577
2578 let mut invalid = serde_json::to_value(&portable)?;
2579 invalid["canonical_identity"] = serde_json::json!("|04:rust");
2580 require(
2581 serde_json::from_value::<PortableResolutionKey>(invalid).is_err(),
2582 "non-canonical portable resolver material was accepted",
2583 )?;
2584 Ok(())
2585 }
2586
2587 #[test]
2588 fn resolution_key_bindings_preserve_dependency_identity_across_states()
2589 -> Result<(), Box<dyn std::error::Error>> {
2590 let generation = IndexGeneration::new(7);
2591 let source = GraphEntity::new(
2592 project()?,
2593 EntitySelector::Symbol {
2594 symbol: symbol_selector("src/caller.rs")?,
2595 },
2596 generation,
2597 )?;
2598 let target = GraphEntity::new(
2599 project()?,
2600 EntitySelector::Symbol {
2601 symbol: symbol_selector("src/service.rs")?,
2602 },
2603 generation,
2604 )?;
2605 let dependency = CanonicalResolutionKey::new(
2606 project()?,
2607 ResolutionKeyDomain::Declaration,
2608 &GraphIdentityText::new("tree-sitter")?,
2609 &GraphIdentityText::new("rust")?,
2610 None,
2611 None,
2612 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2613 &GraphIdentityText::new("répond")?,
2614 );
2615 let export = EntityResolutionKey::new(target.key().clone(), dependency.clone())?;
2616 require(
2617 export.key().reconcile(&dependency)?,
2618 "export binding changed its canonical resolver identity",
2619 )?;
2620
2621 let relations = [
2622 LogicalRelation::new(
2623 &source,
2624 GraphRelationKind::Legacy(RelationKind::Calls),
2625 RelationResolution::resolved(&target)?,
2626 ConfidenceClass::Exact,
2627 Completeness::Complete,
2628 generation,
2629 )?,
2630 LogicalRelation::new(
2631 &source,
2632 GraphRelationKind::Legacy(RelationKind::Calls),
2633 RelationResolution::Ambiguous {
2634 reference: GraphIdentityText::new("répond")?,
2635 candidates: NonZeroU32::new(2).ok_or("nonzero candidate fixture")?,
2636 },
2637 ConfidenceClass::High,
2638 Completeness::Complete,
2639 generation,
2640 )?,
2641 LogicalRelation::new(
2642 &source,
2643 GraphRelationKind::Legacy(RelationKind::Calls),
2644 RelationResolution::Unresolved {
2645 reference: GraphIdentityText::new("répond")?,
2646 },
2647 ConfidenceClass::Low,
2648 Completeness::Complete,
2649 generation,
2650 )?,
2651 ];
2652 for relation in relations {
2653 let binding = RelationDependencyKey::new(relation.key().clone(), dependency.clone())?;
2654 require(
2655 binding.key().reconcile(&dependency)?,
2656 "resolution state changed the retained dependency identity",
2657 )?;
2658 }
2659
2660 let foreign = CanonicalResolutionKey::new(
2661 ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?,
2662 ResolutionKeyDomain::Declaration,
2663 &GraphIdentityText::new("tree-sitter")?,
2664 &GraphIdentityText::new("rust")?,
2665 None,
2666 None,
2667 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
2668 &GraphIdentityText::new("répond")?,
2669 );
2670 require(
2671 matches!(
2672 EntityResolutionKey::new(target.key().clone(), foreign),
2673 Err(GraphContractError::ResolutionKeyOwnerMismatch)
2674 ),
2675 "cross-project resolver binding was accepted",
2676 )?;
2677 Ok(())
2678 }
2679
2680 #[test]
2681 fn selectors_normalize_slashes_and_preserve_unicode_and_case()
2682 -> Result<(), Box<dyn std::error::Error>> {
2683 let upper = EntitySelector::Symbol {
2684 symbol: symbol_selector("Src\\Über.rs")?,
2685 };
2686 let lower = EntitySelector::Symbol {
2687 symbol: symbol_selector("src/Über.rs")?,
2688 };
2689 let encoded = serde_json::to_string(&upper)?;
2690 let decoded: EntitySelector = serde_json::from_str(&encoded)?;
2691 require(decoded == upper, "Unicode selector did not round-trip")?;
2692 require(
2693 encoded.contains("Src/Über.rs"),
2694 "selector changed Unicode or path case",
2695 )?;
2696 require(
2697 GraphEntityKey::new(project()?, &upper) != GraphEntityKey::new(project()?, &lower),
2698 "case-distinct repository paths collapsed",
2699 )?;
2700
2701 let slash_variant = EntitySelector::File {
2702 path: RepositoryFilePath::new(Path::new("src\\lib.rs"))?,
2703 };
2704 let normalized = EntitySelector::File {
2705 path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
2706 };
2707 require(
2708 GraphEntityKey::new(project()?, &slash_variant)
2709 == GraphEntityKey::new(project()?, &normalized),
2710 "slash variants did not normalize to one entity",
2711 )?;
2712 Ok(())
2713 }
2714
2715 #[test]
2716 fn invalid_selector_inputs_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
2717 require(
2718 RepositoryFilePath::new(Path::new("../secret.rs")).is_err(),
2719 "parent traversal was accepted",
2720 )?;
2721 require(
2722 RepositoryFilePath::new(Path::new(".")).is_err(),
2723 "project root was accepted as a file",
2724 )?;
2725 require(
2726 RepositoryNodePath::new(Path::new("C:/repo")).is_err(),
2727 "absolute Windows path was accepted",
2728 )?;
2729 require(
2730 GraphIdentityText::new(" ").is_err(),
2731 "blank identity text was accepted",
2732 )?;
2733 require(
2734 GraphIdentityText::new(" padded").is_err()
2735 && GraphIdentityText::new("padded ").is_err(),
2736 "surrounding identity whitespace was accepted",
2737 )?;
2738 require(
2739 GraphIdentityText::new("bad\nidentity").is_err(),
2740 "control-bearing identity text was accepted",
2741 )?;
2742 require(
2743 GraphIdentityText::new("x".repeat(super::MAX_IDENTITY_BYTES + 1)).is_err(),
2744 "oversized identity text was accepted",
2745 )?;
2746 require(
2747 SourceSpan::new(0, 0, 1, 0).is_err(),
2748 "zero source line was accepted",
2749 )?;
2750 require(
2751 SourceSpan::new(4, 2, 3, 9).is_err(),
2752 "reversed source span was accepted",
2753 )?;
2754 require(
2755 GraphEntity::new(
2756 project()?,
2757 EntitySelector::File {
2758 path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
2759 },
2760 IndexGeneration::ZERO,
2761 )
2762 .is_err(),
2763 "pre-publication generation was accepted for a graph entity",
2764 )?;
2765
2766 let package = EntitySelector::Package {
2767 package: PackageSelector {
2768 manager: GraphIdentityText::new("cargo")?,
2769 name: GraphIdentityText::new("projectatlas-core")?,
2770 manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
2771 },
2772 };
2773 require(
2774 package == serde_json::from_str(&serde_json::to_string(&package)?)?,
2775 "package selector did not round-trip",
2776 )?;
2777 Ok(())
2778 }
2779
2780 #[test]
2781 fn logical_relations_deduplicate_distinct_source_occurrences()
2782 -> Result<(), Box<dyn std::error::Error>> {
2783 let source = GraphEntity::new(
2784 project()?,
2785 EntitySelector::Symbol {
2786 symbol: symbol_selector("src/caller.rs")?,
2787 },
2788 IndexGeneration::new(7),
2789 )?;
2790 let target_symbol = symbol_selector("src/target.rs")?;
2791 let target = GraphEntity::new(
2792 project()?,
2793 EntitySelector::Symbol {
2794 symbol: target_symbol,
2795 },
2796 IndexGeneration::new(7),
2797 )?;
2798 let relation = LogicalRelation::new(
2799 &source,
2800 GraphRelationKind::from_legacy(RelationKind::Calls),
2801 RelationResolution::resolved(&target)?,
2802 ConfidenceClass::Exact,
2803 Completeness::Complete,
2804 IndexGeneration::new(7),
2805 )?;
2806 let first = RelationOccurrence::new(
2807 &relation,
2808 RepositoryFilePath::new(Path::new("src/caller.rs"))?,
2809 SourceSpan::new(10, 4, 10, 18)?,
2810 IndexGeneration::new(7),
2811 )?;
2812 let second = RelationOccurrence::new(
2813 &relation,
2814 RepositoryFilePath::new(Path::new("src/caller.rs"))?,
2815 SourceSpan::new(20, 4, 20, 18)?,
2816 IndexGeneration::new(7),
2817 )?;
2818 require(
2819 first.relation() == second.relation(),
2820 "occurrences did not retain one logical relation",
2821 )?;
2822 require(
2823 first.span() != second.span(),
2824 "distinct relation occurrences lost their source spans",
2825 )?;
2826 Ok(())
2827 }
2828
2829 #[test]
2830 fn relation_records_reject_inconsistent_identity_resolution_and_generation()
2831 -> Result<(), Box<dyn std::error::Error>> {
2832 let source = GraphEntity::new(
2833 project()?,
2834 EntitySelector::Symbol {
2835 symbol: symbol_selector("src/caller.rs")?,
2836 },
2837 IndexGeneration::new(3),
2838 )?;
2839 let target_symbol = symbol_selector("src/target.rs")?;
2840 let target = GraphEntity::new(
2841 project()?,
2842 EntitySelector::Symbol {
2843 symbol: target_symbol.clone(),
2844 },
2845 IndexGeneration::new(4),
2846 )?;
2847 require(
2848 matches!(
2849 LogicalRelation::new(
2850 &source,
2851 GraphRelationKind::from_legacy(RelationKind::Calls),
2852 RelationResolution::Unresolved {
2853 reference: GraphIdentityText::new("missing")?,
2854 },
2855 ConfidenceClass::Low,
2856 Completeness::Complete,
2857 IndexGeneration::new(4),
2858 ),
2859 Err(GraphContractError::GenerationMismatch { .. })
2860 ),
2861 "mixed source generation was accepted",
2862 )?;
2863 require(
2864 matches!(
2865 LogicalRelation::new(
2866 &source,
2867 GraphRelationKind::from_legacy(RelationKind::Calls),
2868 RelationResolution::resolved(&target)?,
2869 ConfidenceClass::Exact,
2870 Completeness::Complete,
2871 IndexGeneration::new(3),
2872 ),
2873 Err(GraphContractError::GenerationMismatch { .. })
2874 ),
2875 "mixed target generation was accepted",
2876 )?;
2877 let target = GraphEntity::new(
2878 project()?,
2879 EntitySelector::Symbol {
2880 symbol: target_symbol,
2881 },
2882 IndexGeneration::new(3),
2883 )?;
2884 let relation = LogicalRelation::new(
2885 &source,
2886 GraphRelationKind::from_legacy(RelationKind::Calls),
2887 RelationResolution::resolved(&target)?,
2888 ConfidenceClass::Exact,
2889 Completeness::Complete,
2890 IndexGeneration::new(3),
2891 )?;
2892 let encoded = serde_json::to_string(relation.key())?;
2893 let decoded = serde_json::from_str::<super::LogicalRelationKey>(&encoded)?;
2894 require(
2895 relation.key().reconcile(&decoded)?,
2896 "validated logical relation key did not round-trip",
2897 )?;
2898 require(
2899 matches!(
2900 RelationOccurrence::new(
2901 &relation,
2902 RepositoryFilePath::new(Path::new("src/caller.rs"))?,
2903 SourceSpan::new(2, 0, 2, 8)?,
2904 IndexGeneration::new(4),
2905 ),
2906 Err(GraphContractError::GenerationMismatch { .. })
2907 ),
2908 "mixed occurrence generation was accepted",
2909 )?;
2910 require(
2911 matches!(
2912 LogicalRelation::new(
2913 &source,
2914 GraphRelationKind::from_legacy(RelationKind::Calls),
2915 RelationResolution::Resolved {
2916 target: target.key().clone(),
2917 selector: ReusableTargetSelector::Symbol {
2918 symbol: symbol_selector("src/wrong.rs")?,
2919 },
2920 generation: IndexGeneration::new(3),
2921 },
2922 ConfidenceClass::Exact,
2923 Completeness::Complete,
2924 IndexGeneration::new(3),
2925 ),
2926 Err(GraphContractError::InvalidResolution { .. })
2927 ),
2928 "selector for an unrelated entity was accepted",
2929 )?;
2930
2931 let other_project_target = GraphEntity::new(
2932 ProjectInstanceId::try_from("10112233445566778899aabbccddeeff")?,
2933 EntitySelector::Symbol {
2934 symbol: symbol_selector("src/target.rs")?,
2935 },
2936 IndexGeneration::new(3),
2937 )?;
2938 require(
2939 matches!(
2940 LogicalRelation::new(
2941 &source,
2942 GraphRelationKind::from_legacy(RelationKind::Calls),
2943 RelationResolution::resolved(&other_project_target)?,
2944 ConfidenceClass::Exact,
2945 Completeness::Complete,
2946 IndexGeneration::new(3),
2947 ),
2948 Err(GraphContractError::CrossProjectRelation)
2949 ),
2950 "cross-project relation was accepted without federation",
2951 )?;
2952
2953 let external_selector = ExternalSelector {
2954 system: GraphIdentityText::new("cargo")?,
2955 identity: GraphIdentityText::new("serde")?,
2956 };
2957 let external_target = GraphEntity::new(
2958 project()?,
2959 EntitySelector::External {
2960 external: external_selector.clone(),
2961 },
2962 IndexGeneration::new(3),
2963 )?;
2964 LogicalRelation::new(
2965 &source,
2966 GraphRelationKind::from_legacy(RelationKind::DependsOn),
2967 RelationResolution::external(&external_target)?,
2968 ConfidenceClass::Exact,
2969 Completeness::Complete,
2970 IndexGeneration::new(3),
2971 )?;
2972 require(
2973 matches!(
2974 LogicalRelation::new(
2975 &source,
2976 GraphRelationKind::from_legacy(RelationKind::DependsOn),
2977 RelationResolution::External {
2978 target: external_target.key().clone(),
2979 external: ExternalSelector {
2980 system: external_selector.system,
2981 identity: GraphIdentityText::new("different")?,
2982 },
2983 generation: IndexGeneration::new(3),
2984 },
2985 ConfidenceClass::Exact,
2986 Completeness::Complete,
2987 IndexGeneration::new(3),
2988 ),
2989 Err(GraphContractError::InvalidResolution { .. })
2990 ),
2991 "mismatched external identity was accepted",
2992 )?;
2993
2994 let mut serialized = serde_json::to_value(relation.key())?;
2995 serialized["stable"]["canonical_identity"] =
2996 serde_json::json!(source.key().canonical_identity());
2997 serialized["stable"]["digest"] = serde_json::json!(
2998 blake3::hash(source.key().canonical_identity().as_bytes())
2999 .to_hex()
3000 .as_str()
3001 );
3002 require(
3003 serde_json::from_value::<super::LogicalRelationKey>(serialized).is_err(),
3004 "entity namespace was accepted as a relation key",
3005 )?;
3006
3007 let file_key = GraphEntityKey::new(
3008 project()?,
3009 &EntitySelector::File {
3010 path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3011 },
3012 );
3013 let mut serialized = serde_json::to_value(&file_key)?;
3014 let malformed = format!("{}suffix", super::entity_project_prefix(project()?));
3015 serialized["stable"]["canonical_identity"] = serde_json::json!(&malformed);
3016 serialized["stable"]["digest"] =
3017 serde_json::json!(blake3::hash(malformed.as_bytes()).to_hex().as_str());
3018 require(
3019 serde_json::from_value::<GraphEntityKey>(serialized).is_err(),
3020 "partial project prefix was accepted as typed entity identity",
3021 )?;
3022 Ok(())
3023 }
3024
3025 #[test]
3026 fn ambiguous_candidate_counts_do_not_change_logical_relation_identity()
3027 -> Result<(), Box<dyn std::error::Error>> {
3028 let source = GraphEntity::new(
3029 project()?,
3030 EntitySelector::Symbol {
3031 symbol: symbol_selector("src/caller.rs")?,
3032 },
3033 IndexGeneration::new(11),
3034 )?;
3035 let relation = |candidates| {
3036 LogicalRelation::new(
3037 &source,
3038 GraphRelationKind::Extended(ExtendedRelationKind::References),
3039 RelationResolution::Ambiguous {
3040 reference: GraphIdentityText::new("handler")?,
3041 candidates: NonZeroU32::new(candidates).ok_or(
3042 GraphContractError::InvalidCoverage {
3043 reason: "test candidate count must be nonzero",
3044 },
3045 )?,
3046 },
3047 ConfidenceClass::High,
3048 Completeness::Complete,
3049 IndexGeneration::new(11),
3050 )
3051 };
3052 let first = relation(2)?;
3053 let second = relation(3)?;
3054 require(
3055 first.key().reconcile(second.key())?,
3056 "candidate-count metadata changed logical relation identity",
3057 )?;
3058 Ok(())
3059 }
3060
3061 #[test]
3062 fn relation_resolution_and_target_selectors_round_trip()
3063 -> Result<(), Box<dyn std::error::Error>> {
3064 let selector = ReusableTargetSelector::Symbol {
3065 symbol: symbol_selector("src/lib.rs")?,
3066 };
3067 let encoded = serde_json::to_string(&selector)?;
3068 require(
3069 serde_json::from_str::<ReusableTargetSelector>(&encoded)? == selector,
3070 "exact target selector did not round-trip",
3071 )?;
3072
3073 let cases = [
3074 (
3075 EntitySelector::Folder {
3076 path: RepositoryNodePath::new(Path::new("src"))?,
3077 },
3078 ReusableTargetSelector::Folder {
3079 folder: RepositoryNodePath::new(Path::new("src"))?,
3080 },
3081 ),
3082 (
3083 EntitySelector::File {
3084 path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3085 },
3086 ReusableTargetSelector::File {
3087 file: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
3088 },
3089 ),
3090 (
3091 EntitySelector::Package {
3092 package: PackageSelector {
3093 manager: GraphIdentityText::new("cargo")?,
3094 name: GraphIdentityText::new("projectatlas-core")?,
3095 manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
3096 },
3097 },
3098 ReusableTargetSelector::Package {
3099 package: PackageSelector {
3100 manager: GraphIdentityText::new("cargo")?,
3101 name: GraphIdentityText::new("projectatlas-core")?,
3102 manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
3103 },
3104 },
3105 ),
3106 (
3107 EntitySelector::Symbol {
3108 symbol: symbol_selector("src/lib.rs")?,
3109 },
3110 selector,
3111 ),
3112 ];
3113 for (entity_selector, expected) in cases {
3114 let entity = GraphEntity::new(project()?, entity_selector, IndexGeneration::new(5))?;
3115 require(
3116 ReusableTargetSelector::for_entity(&entity)? == expected,
3117 "entity-derived reusable selector drifted",
3118 )?;
3119 }
3120
3121 let project_entity =
3122 GraphEntity::new(project()?, EntitySelector::Project, IndexGeneration::new(5))?;
3123 require(
3124 RelationResolution::resolved(&project_entity).is_err(),
3125 "project aggregate was exposed as a direct source target",
3126 )?;
3127 let external_entity = GraphEntity::new(
3128 project()?,
3129 EntitySelector::External {
3130 external: ExternalSelector {
3131 system: GraphIdentityText::new("cargo")?,
3132 identity: GraphIdentityText::new("serde")?,
3133 },
3134 },
3135 IndexGeneration::new(5),
3136 )?;
3137 let external = RelationResolution::external(&external_entity)?;
3138 require(
3139 external.resolved_target().is_none(),
3140 "external resolution exposed a traversable local target",
3141 )?;
3142 require(
3143 RelationResolution::external(&project_entity).is_err(),
3144 "non-external entity was accepted as an external resolution",
3145 )?;
3146
3147 let unresolved = RelationResolution::Unresolved {
3148 reference: GraphIdentityText::new("missing::target")?,
3149 };
3150 require(
3151 unresolved.resolved_target().is_none(),
3152 "unresolved relation exposed a traversable target",
3153 )?;
3154 let ambiguous = RelationResolution::Ambiguous {
3155 reference: GraphIdentityText::new("handler")?,
3156 candidates: NonZeroU32::new(2).ok_or("candidate count must be nonzero")?,
3157 };
3158 require(
3159 ambiguous.resolved_target().is_none(),
3160 "ambiguous relation exposed a traversable target",
3161 )?;
3162 Ok(())
3163 }
3164
3165 #[test]
3166 fn legacy_relation_projection_remains_compatible() {
3167 for kind in [
3168 RelationKind::Contains,
3169 RelationKind::Imports,
3170 RelationKind::Calls,
3171 RelationKind::DependsOn,
3172 ] {
3173 assert_eq!(
3174 GraphRelationKind::from_legacy(kind).legacy_kind(),
3175 Some(kind)
3176 );
3177 }
3178 assert_eq!(
3179 GraphRelationKind::Extended(ExtendedRelationKind::Tests).legacy_kind(),
3180 None
3181 );
3182 }
3183
3184 #[test]
3185 fn coverage_state_enforces_consistent_counts_and_reasons()
3186 -> Result<(), Box<dyn std::error::Error>> {
3187 let complete = CoverageRecord::new(
3188 CoverageScope::Project,
3189 None,
3190 CoverageState::Complete,
3191 8,
3192 0,
3193 IndexGeneration::new(9),
3194 None,
3195 None,
3196 )?;
3197 require(complete.total() == 8, "complete coverage total drifted")?;
3198
3199 let partial = CoverageRecord::new(
3200 CoverageScope::Path {
3201 path: RepositoryNodePath::new(Path::new("src"))?,
3202 },
3203 Some(GraphRelationKind::Extended(
3204 ExtendedRelationKind::References,
3205 )),
3206 CoverageState::Partial,
3207 5,
3208 3,
3209 IndexGeneration::new(9),
3210 Some(GraphIdentityText::new("parser limit reached")?),
3211 Some(GraphLimitKind::Rows),
3212 )?;
3213 require(
3214 (partial.covered(), partial.omitted(), partial.total()) == (5, 3, 8),
3215 "partial coverage counts are inconsistent",
3216 )?;
3217
3218 require(
3219 CoverageRecord::new(
3220 CoverageScope::Project,
3221 None,
3222 CoverageState::Complete,
3223 7,
3224 1,
3225 IndexGeneration::new(9),
3226 None,
3227 None,
3228 )
3229 .is_err(),
3230 "complete coverage accepted omitted rows",
3231 )?;
3232 require(
3233 CoverageRecord::new(
3234 CoverageScope::Project,
3235 None,
3236 CoverageState::Failed,
3237 0,
3238 8,
3239 IndexGeneration::new(9),
3240 None,
3241 None,
3242 )
3243 .is_err(),
3244 "failed coverage accepted a missing reason",
3245 )?;
3246 require(
3247 CoverageRecord::new(
3248 CoverageScope::Project,
3249 None,
3250 CoverageState::Complete,
3251 1,
3252 0,
3253 IndexGeneration::ZERO,
3254 None,
3255 None,
3256 )
3257 .is_err(),
3258 "pre-publication coverage generation was accepted",
3259 )?;
3260 Ok(())
3261 }
3262
3263 #[test]
3264 fn graph_limits_are_nonzero_bounded_and_validated_on_input()
3265 -> Result<(), Box<dyn std::error::Error>> {
3266 let limits = GraphLimits::new(100, 20, 4, 64 * 1_024)?;
3267 let encoded = serde_json::to_string(&limits)?;
3268 require(
3269 serde_json::from_str::<GraphLimits>(&encoded)? == limits,
3270 "graph limits did not round-trip",
3271 )?;
3272 require(
3273 GraphLimits::new(0, 20, 4, 1024).is_err(),
3274 "zero graph row limit was accepted",
3275 )?;
3276 require(
3277 GraphLimits::new(GraphLimits::MAX_ROWS + 1, 20, 4, 1024).is_err(),
3278 "graph row limit exceeded its hard ceiling",
3279 )?;
3280 require(
3281 serde_json::from_str::<GraphLimits>(
3282 r#"{"rows":1,"occurrences":1,"depth":65,"output_bytes":1}"#,
3283 )
3284 .is_err(),
3285 "deserialization bypassed graph depth limits",
3286 )?;
3287 Ok(())
3288 }
3289}