1use crate::config::{
9 validate_detector_id, validate_exact_discovery_path, validate_sha256, validate_tag_template,
10 Config, ExcludedPathReceipt, ManagedPathReceipt, Projection, ReleaseUnitConfig, TagConfig,
11 CONFIG_PATH, CONFIG_SCHEMA, CURRENT_CONTRACT,
12};
13use crate::error::{Error, Result};
14use crate::model::{
15 Adapter, Bump, Pre1BumpMapping, ProjectionMode, ReleaseUnitDisposition, TagRole,
16};
17use crate::plan::canonical_json;
18use crate::version::{
19 bump_version_with_mapping, effective_bumps, resolve_versions, ReleaseUnitVersion,
20};
21use glob::glob;
22use node_semver::{Range as NodeRange, Version as NodeVersion};
23use semver::Version;
24use serde::{Deserialize, Serialize};
25use serde_json::Value as JsonValue;
26use sha2::{Digest, Sha256};
27use std::collections::{BTreeMap, BTreeSet};
28use std::io::Write;
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31use walkdir::WalkDir;
32
33pub const INIT_PLAN_PATH: &str = ".intentional/init-plan.yml";
35
36pub const INIT_PLAN_SCHEMA: &str = "https://intentional.foo/schemas/init-plan.yml";
38
39const CHANGESETS_CONFIG: &str = ".changeset/config.json";
40const TRANSACTION_PATH: &str = ".intentional/.takeover-transaction";
41const TRANSACTION_STATE_PATH: &str = ".intentional/.takeover-state";
42const DEVCONTAINER_FEATURE_MANIFEST: &str = "devcontainer-feature.json";
43const DEVCONTAINER_TEMPLATE_MANIFEST: &str = "devcontainer-template.json";
44
45#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
47#[serde(rename_all = "kebab-case")]
48pub enum InitState {
49 Success,
51 NeedsInput,
53 Ready,
55}
56
57#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
59#[serde(rename_all = "kebab-case", deny_unknown_fields)]
60pub struct SourceEvidence {
61 pub path: PathBuf,
63 pub digest: String,
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub lines: Vec<usize>,
68}
69
70#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
72#[serde(rename_all = "kebab-case", deny_unknown_fields)]
73pub struct RawVersionEvidence {
74 pub value: String,
76 pub evidence: Vec<SourceEvidence>,
78}
79
80#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
82#[serde(rename_all = "kebab-case", deny_unknown_fields)]
83pub struct CandidateProjectionSuggestion {
84 pub adapter: Adapter,
86 pub path: PathBuf,
88 pub mode: ProjectionMode,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub pointer: Option<String>,
93}
94
95#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
97#[serde(rename_all = "kebab-case", deny_unknown_fields)]
98pub struct CandidateTagSuggestion {
99 pub id: String,
101 pub role: TagRole,
103 pub template: String,
105}
106
107#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
109#[serde(rename_all = "kebab-case", deny_unknown_fields)]
110pub struct ExtractionDiagnostic {
111 pub id: String,
113 pub code: String,
115 pub message: String,
117 pub evidence: Vec<SourceEvidence>,
119}
120
121#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
123#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
124pub enum CandidateResolution {
125 Independent {
127 release_unit: String,
129 },
130 Projection {
132 release_unit: String,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 target_candidate: Option<String>,
137 },
138 Excluded,
140}
141
142#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
144#[serde(rename_all = "kebab-case", deny_unknown_fields)]
145pub struct DiscoveryCandidate {
146 pub id: String,
148 pub detector: String,
150 pub path: PathBuf,
152 pub evidence: Vec<SourceEvidence>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub native_identity: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub raw_version: Option<RawVersionEvidence>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub projection: Option<CandidateProjectionSuggestion>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub tag: Option<CandidateTagSuggestion>,
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub diagnostics: Vec<ExtractionDiagnostic>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub resolution: Option<CandidateResolution>,
172}
173
174impl DiscoveryCandidate {
175 pub fn stable_id(detector: &str, path: &Path) -> Result<String> {
177 validate_detector_id(detector)?;
178 validate_exact_discovery_path(path, "discovery candidate path")?;
179 let mut path_parts = Vec::new();
180 for component in path.components() {
181 if let std::path::Component::Normal(value) = component {
182 path_parts.push(value.to_str().ok_or_else(|| {
183 Error::Validation("discovery candidate path must be valid UTF-8".to_owned())
184 })?);
185 }
186 }
187 let path = path_parts.join("/");
188 let mut identity = Sha256::new();
189 identity.update(detector.as_bytes());
190 identity.update([0]);
191 identity.update(path.as_bytes());
192 Ok(format!("candidate:{:x}", identity.finalize()))
193 }
194
195 fn validate(&self) -> Result<()> {
196 let expected = Self::stable_id(&self.detector, &self.path)?;
197 if self.id != expected {
198 return Err(Error::Validation(format!(
199 "discovery candidate {} does not match detector {} and path {}",
200 self.id,
201 self.detector,
202 self.path.display()
203 )));
204 }
205 if self.evidence.is_empty() {
206 return Err(Error::Validation(format!(
207 "discovery candidate {} must contain evidence",
208 self.id
209 )));
210 }
211 validate_source_evidence(&self.evidence, &format!("discovery candidate {}", self.id))?;
212 if !self.evidence.iter().any(|item| item.path == self.path) {
213 return Err(Error::Validation(format!(
214 "discovery candidate {} evidence must include its exact path {}",
215 self.id,
216 self.path.display()
217 )));
218 }
219 if self.native_identity.as_deref().is_some_and(str::is_empty) {
220 return Err(Error::Validation(format!(
221 "discovery candidate {} native identity must not be empty",
222 self.id
223 )));
224 }
225 if let Some(version) = &self.raw_version {
226 if version.value.is_empty() {
227 return Err(Error::Validation(format!(
228 "discovery candidate {} raw version must not be empty",
229 self.id
230 )));
231 }
232 validate_source_evidence(
233 &version.evidence,
234 &format!("discovery candidate {} raw version", self.id),
235 )?;
236 if !version
237 .evidence
238 .iter()
239 .all(|item| self.evidence.contains(item))
240 {
241 return Err(Error::Validation(format!(
242 "discovery candidate {} raw version references evidence outside the candidate",
243 self.id
244 )));
245 }
246 }
247 if let Some(projection) = &self.projection {
248 validate_exact_discovery_path(&projection.path, "candidate projection path")?;
249 if !self
250 .evidence
251 .iter()
252 .any(|item| item.path == projection.path)
253 {
254 return Err(Error::Validation(format!(
255 "discovery candidate {} projection path lacks candidate evidence",
256 self.id
257 )));
258 }
259 if projection.adapter.requires_pointer()
260 && projection.pointer.as_deref().is_none_or(str::is_empty)
261 {
262 return Err(Error::Validation(format!(
263 "discovery candidate {} generic projection requires a pointer",
264 self.id
265 )));
266 }
267 }
268 if let Some(tag) = &self.tag {
269 if tag.id.is_empty()
270 || !tag
271 .id
272 .chars()
273 .next()
274 .is_some_and(|character| character.is_ascii_alphanumeric())
275 || !tag
276 .id
277 .chars()
278 .all(|character| character.is_ascii_alphanumeric() || "-_.".contains(character))
279 {
280 return Err(Error::Validation(format!(
281 "discovery candidate {} has invalid tag id {:?}",
282 self.id, tag.id
283 )));
284 }
285 validate_tag_template(
286 &format!("discovery candidate {} tag", self.id),
287 &tag.template,
288 true,
289 )?;
290 }
291 let mut diagnostic_ids = BTreeSet::new();
292 for diagnostic in &self.diagnostics {
293 if diagnostic.id.is_empty()
294 || diagnostic.code.is_empty()
295 || diagnostic.message.is_empty()
296 {
297 return Err(Error::Validation(format!(
298 "discovery candidate {} has an incomplete extraction diagnostic",
299 self.id
300 )));
301 }
302 if !diagnostic_ids.insert(&diagnostic.id) {
303 return Err(Error::Validation(format!(
304 "discovery candidate {} repeats extraction diagnostic {}",
305 self.id, diagnostic.id
306 )));
307 }
308 validate_source_evidence(
309 &diagnostic.evidence,
310 &format!(
311 "discovery candidate {} diagnostic {}",
312 self.id, diagnostic.id
313 ),
314 )?;
315 if !diagnostic
316 .evidence
317 .iter()
318 .all(|item| self.evidence.contains(item))
319 {
320 return Err(Error::Validation(format!(
321 "discovery candidate {} diagnostic {} references evidence outside the candidate",
322 self.id, diagnostic.id
323 )));
324 }
325 }
326 Ok(())
327 }
328}
329
330#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
332#[serde(rename_all = "kebab-case", deny_unknown_fields)]
333pub struct DetectorResult {
334 pub detector: String,
336 pub candidates: Vec<DiscoveryCandidate>,
338}
339
340impl DetectorResult {
341 pub fn validate(&self) -> Result<()> {
343 validate_detector_id(&self.detector)?;
344 let mut paths = BTreeSet::new();
345 for candidate in &self.candidates {
346 if candidate.detector != self.detector {
347 return Err(Error::Validation(format!(
348 "detector {} returned candidate owned by {}",
349 self.detector, candidate.detector
350 )));
351 }
352 candidate.validate()?;
353 if !paths.insert(&candidate.path) {
354 return Err(Error::Validation(format!(
355 "detector {} repeated candidate path {}",
356 self.detector,
357 candidate.path.display()
358 )));
359 }
360 }
361 Ok(())
362 }
363
364 pub fn into_candidates(mut self) -> Result<Vec<DiscoveryCandidate>> {
366 self.validate()?;
367 self.candidates
368 .sort_by(|left, right| left.path.cmp(&right.path));
369 Ok(self.candidates)
370 }
371}
372
373fn validate_source_evidence(evidence: &[SourceEvidence], description: &str) -> Result<()> {
374 if evidence.is_empty() {
375 return Err(Error::Validation(format!(
376 "{description} must contain evidence"
377 )));
378 }
379 let mut paths = BTreeSet::new();
380 if evidence.windows(2).any(|pair| pair[0] >= pair[1]) {
381 return Err(Error::Validation(format!(
382 "{description} evidence must be ordered by path"
383 )));
384 }
385 for item in evidence {
386 validate_exact_discovery_path(&item.path, &format!("{description} evidence path"))?;
387 validate_sha256(&item.digest, &format!("{description} evidence digest"))?;
388 if !paths.insert(&item.path) {
389 return Err(Error::Validation(format!(
390 "{description} repeats evidence path {}",
391 item.path.display()
392 )));
393 }
394 if item.lines.contains(&0) {
395 return Err(Error::Validation(format!(
396 "{description} evidence lines must be one-based"
397 )));
398 }
399 let unique_lines = item.lines.iter().collect::<BTreeSet<_>>();
400 if unique_lines.len() != item.lines.len() {
401 return Err(Error::Validation(format!(
402 "{description} repeats an evidence line for {}",
403 item.path.display()
404 )));
405 }
406 if item.lines.windows(2).any(|pair| pair[0] >= pair[1]) {
407 return Err(Error::Validation(format!(
408 "{description} evidence lines for {} must be ordered",
409 item.path.display()
410 )));
411 }
412 }
413 Ok(())
414}
415
416#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
418#[serde(rename_all = "kebab-case", deny_unknown_fields)]
419pub struct InitDiagnostic {
420 pub id: String,
422 pub code: String,
424 pub message: String,
426 pub evidence: Vec<SourceEvidence>,
428 #[serde(default, skip_serializing_if = "Vec::is_empty")]
430 pub choices: Vec<String>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub recommended: Option<String>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub resolution: Option<String>,
437 #[serde(default)]
439 pub verified: bool,
440 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
442 pub invalidated_resolution: bool,
443 #[serde(default, skip_serializing_if = "Vec::is_empty")]
445 pub supporting_evidence: Vec<SourceEvidence>,
446 #[serde(default, skip_serializing_if = "Vec::is_empty")]
448 pub contradictory_evidence: Vec<SourceEvidence>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub uncertainty: Option<String>,
452}
453
454#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
456#[serde(rename_all = "kebab-case", deny_unknown_fields)]
457pub struct ParityRelease {
458 pub requested_bump: Bump,
460 pub next_version: String,
462}
463
464#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
466#[serde(rename_all = "kebab-case", deny_unknown_fields)]
467pub struct ParityReleaseUnit {
468 pub release_unit: String,
470 pub current_version: String,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub source: Option<ParityRelease>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub proposed: Option<ParityRelease>,
478}
479
480#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
482#[serde(rename_all = "kebab-case", deny_unknown_fields)]
483pub struct ParityResult {
484 pub status: String,
486 pub release_units: Vec<ParityReleaseUnit>,
488}
489
490#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
492#[serde(rename_all = "kebab-case", deny_unknown_fields)]
493pub struct ConvertedIntent {
494 pub id: String,
496 pub release_units: BTreeMap<String, Bump>,
498 pub message: String,
500 pub source: PathBuf,
502 pub target: PathBuf,
504}
505
506impl ConvertedIntent {
507 fn contents(&self) -> Result<String> {
508 Ok(format!(
509 "---\n{}---\n\n{}\n",
510 serde_yaml::to_string(&self.release_units)?,
511 self.message.trim()
512 ))
513 }
514}
515
516#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
518#[serde(rename_all = "kebab-case", deny_unknown_fields)]
519pub struct InitPlan {
520 #[serde(rename = "$schema")]
522 pub schema: String,
523 pub state: InitState,
525 pub source_kind: String,
527 pub source_fingerprint: String,
529 pub inferred_config: Config,
531 pub discovery_candidates: Vec<DiscoveryCandidate>,
533 pub diagnostics: Vec<InitDiagnostic>,
535 pub converted_intents: Vec<ConvertedIntent>,
537 pub parity: ParityResult,
539 pub planned_operations: Vec<String>,
541 pub post_commit_action: String,
543}
544
545impl InitPlan {
546 pub fn validate(&self) -> Result<()> {
548 if self.inferred_config.release_units.is_empty() {
549 if self.state != InitState::NeedsInput || self.discovery_candidates.is_empty() {
550 return Err(Error::Validation(
551 "an empty inferred config requires unresolved discovery candidates".to_owned(),
552 ));
553 }
554 validate_empty_inferred_config(&self.inferred_config)?;
555 } else {
556 self.inferred_config.validate()?;
557 }
558 let mut candidates = BTreeMap::new();
559 for candidate in &self.discovery_candidates {
560 candidate.validate()?;
561 if candidates
562 .insert(candidate.id.as_str(), candidate)
563 .is_some()
564 {
565 return Err(Error::Validation(format!(
566 "duplicate discovery candidate {}",
567 candidate.id
568 )));
569 }
570 }
571
572 let mut creators = self
573 .inferred_config
574 .release_units
575 .keys()
576 .map(|id| (id.as_str(), "configured release unit"))
577 .collect::<BTreeMap<_, _>>();
578 let mut edges = BTreeMap::<&str, &str>::new();
579 for candidate in &self.discovery_candidates {
580 match &candidate.resolution {
581 Some(CandidateResolution::Independent { release_unit }) => {
582 validate_resolution_release_unit(release_unit)?;
583 if let Some(previous) = creators.insert(release_unit, candidate.id.as_str()) {
584 return Err(Error::Validation(format!(
585 "duplicate creator for release unit {release_unit}: {previous} and {}",
586 candidate.id
587 )));
588 }
589 }
590 Some(CandidateResolution::Projection {
591 release_unit,
592 target_candidate,
593 }) => {
594 validate_resolution_release_unit(release_unit)?;
595 if let Some(target) = target_candidate {
596 if !candidates.contains_key(target.as_str()) {
597 return Err(Error::Validation(format!(
598 "discovery candidate {} projects onto absent candidate {target}",
599 candidate.id
600 )));
601 }
602 edges.insert(candidate.id.as_str(), target.as_str());
603 } else if !self
604 .inferred_config
605 .release_units
606 .contains_key(release_unit)
607 {
608 return Err(Error::Validation(format!(
609 "discovery candidate {} projects onto absent configured release unit {release_unit}",
610 candidate.id
611 )));
612 }
613 }
614 Some(CandidateResolution::Excluded) | None => {}
615 }
616 }
617
618 validate_candidate_graph_acyclic(&edges)?;
619 for candidate in &self.discovery_candidates {
620 let Some(CandidateResolution::Projection {
621 release_unit,
622 target_candidate: Some(target),
623 }) = &candidate.resolution
624 else {
625 continue;
626 };
627 validate_projection_target(candidate, release_unit, target, &candidates)?;
628 }
629 Ok(())
630 }
631
632 pub fn to_yaml(&self) -> Result<String> {
634 self.validate()?;
635 let yaml = serde_yaml::to_string(self)?;
636 Ok(annotate_choice_lines(&yaml))
637 }
638
639 pub fn to_json(&self) -> Result<String> {
641 self.validate()?;
642 canonical_json(self)
643 }
644}
645
646fn validate_empty_inferred_config(config: &Config) -> Result<()> {
647 if config
648 .schema
649 .as_deref()
650 .is_some_and(|schema| schema != CONFIG_SCHEMA)
651 {
652 return Err(Error::Validation(format!(
653 "empty inferred config schema must be {CONFIG_SCHEMA}"
654 )));
655 }
656 if config.contract != CURRENT_CONTRACT {
657 return Err(Error::Validation(format!(
658 "unsupported interpretation contract {:?}; expected {CURRENT_CONTRACT}",
659 config.contract
660 )));
661 }
662 if config.settings.internal_dependency_bump == Bump::None {
663 return Err(Error::Validation(
664 "internal-dependency-bump must be major, minor, or patch".to_owned(),
665 ));
666 }
667 if !config.fixed.is_empty()
668 || !config.linked.is_empty()
669 || !config.workspace_tags.is_empty()
670 || !config.discovery.managed_paths.is_empty()
671 || !config.discovery.excluded_paths.is_empty()
672 {
673 return Err(Error::Validation(
674 "empty inferred config may only contain schema, contract, settings, and release-units"
675 .to_owned(),
676 ));
677 }
678 Ok(())
679}
680
681fn validate_resolution_release_unit(id: &str) -> Result<()> {
682 if id.is_empty()
683 || !id
684 .chars()
685 .all(|character| character.is_ascii_alphanumeric() || "-_.@/".contains(character))
686 {
687 return Err(Error::Validation(format!(
688 "invalid candidate resolution release-unit id {id:?}"
689 )));
690 }
691 Ok(())
692}
693
694fn validate_candidate_graph_acyclic(edges: &BTreeMap<&str, &str>) -> Result<()> {
695 fn visit<'a>(
696 id: &'a str,
697 edges: &BTreeMap<&'a str, &'a str>,
698 visiting: &mut BTreeSet<&'a str>,
699 visited: &mut BTreeSet<&'a str>,
700 ) -> Result<()> {
701 if visited.contains(id) {
702 return Ok(());
703 }
704 if !visiting.insert(id) {
705 return Err(Error::Validation(format!(
706 "discovery candidate projection cycle includes {id}"
707 )));
708 }
709 if let Some(target) = edges.get(id) {
710 visit(target, edges, visiting, visited)?;
711 }
712 visiting.remove(id);
713 visited.insert(id);
714 Ok(())
715 }
716
717 let mut visiting = BTreeSet::new();
718 let mut visited = BTreeSet::new();
719 for id in edges.keys() {
720 visit(id, edges, &mut visiting, &mut visited)?;
721 }
722 Ok(())
723}
724
725fn validate_projection_target(
726 source: &DiscoveryCandidate,
727 release_unit: &str,
728 target: &str,
729 candidates: &BTreeMap<&str, &DiscoveryCandidate>,
730) -> Result<()> {
731 let target = candidates[target];
732 match &target.resolution {
733 Some(CandidateResolution::Independent {
734 release_unit: target_release_unit,
735 }) => {
736 if target_release_unit != release_unit {
737 return Err(Error::Validation(format!(
738 "discovery candidate {} resolves to {release_unit} but target {} creates {target_release_unit}",
739 source.id, target.id
740 )));
741 }
742 Ok(())
743 }
744 Some(CandidateResolution::Projection {
745 release_unit: _,
746 target_candidate: _,
747 }) => Err(Error::Validation(format!(
748 "discovery candidate {} projects onto target {} that is not an independent creator",
749 source.id, target.id
750 ))),
751 Some(CandidateResolution::Excluded) | None => Err(Error::Validation(format!(
752 "discovery candidate {} projects onto target {} without an independent or configured resolution",
753 source.id, target.id
754 ))),
755 }
756}
757
758#[derive(Debug, Clone)]
760pub struct InitResult {
761 pub state: InitState,
763 pub path: PathBuf,
765 pub contents: String,
767 pub operations: Vec<String>,
769 pub plan: Option<InitPlan>,
771 writes: Vec<(PathBuf, String)>,
772 deletes: Vec<PathBuf>,
773 proxy_removals: Vec<ProxyRemoval>,
774 takeover: bool,
775 takeover_evidence: Option<(String, BTreeSet<PathBuf>)>,
776}
777
778#[derive(Debug, Clone)]
779struct ProxyRemoval {
780 path: PathBuf,
781 native_identity: String,
782}
783
784impl InitResult {
785 pub fn apply(&self, root: &Path, dry_run: bool) -> Result<()> {
787 if dry_run {
788 return Ok(());
789 }
790 if self.takeover {
791 let (expected_fingerprint, evidence_paths) = self
792 .takeover_evidence
793 .as_ref()
794 .expect("takeover carries evidence");
795 verify_proxy_removal_preconditions(root, &self.proxy_removals)?;
796 verify_takeover_preconditions(
797 root,
798 &self.writes,
799 expected_fingerprint,
800 evidence_paths,
801 )?;
802 return apply_takeover_transaction(root, &self.writes, &self.deletes);
803 }
804 for (relative, contents) in &self.writes {
805 let path = root.join(relative);
806 if let Some(parent) = path.parent() {
807 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
808 }
809 std::fs::write(&path, contents).map_err(|error| Error::io(&path, error))?;
810 }
811 for relative in &self.deletes {
812 let path = root.join(relative);
813 if path.is_file() {
814 std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
815 }
816 }
817 if self.state == InitState::Success {
818 std::fs::create_dir_all(root.join(crate::intent::INTENTS_PATH))
819 .map_err(|error| Error::io(root.join(crate::intent::INTENTS_PATH), error))?;
820 }
821 Ok(())
822 }
823
824 pub fn to_json(&self) -> Result<String> {
826 match &self.plan {
827 Some(plan) => plan.to_json(),
828 None => {
829 let config = Config::from_yaml(&self.contents)?;
830 canonical_json(&config)
831 }
832 }
833 }
834}
835
836pub fn initialize(root: &Path, take_over: bool) -> Result<InitResult> {
838 let root = root
839 .canonicalize()
840 .map_err(|error| Error::io(root, error))?;
841 let root = root.as_path();
842 gix::discover(root)
843 .map_err(|_| Error::Validation("intentional init requires a Git repository".to_owned()))?;
844 recover_interrupted_takeover(root)?;
845 if root.join(CHANGESETS_CONFIG).exists() {
846 return changesets_plan(root, take_over);
847 }
848 if take_over {
849 return Err(Error::Validation(
850 "--take-over requires an existing .changeset/config.json".to_owned(),
851 ));
852 }
853 ordinary_plan(root)
854}
855
856pub fn discover_config(root: &Path) -> Result<InitResult> {
858 initialize(root, false)
859}
860
861#[derive(Default)]
862struct Discovery {
863 config: Config,
864 versions: BTreeMap<String, Version>,
865 evidence: BTreeSet<PathBuf>,
866 workspace_packages: BTreeSet<String>,
867 private_packages: BTreeSet<String>,
868 npm_dependencies: Vec<NpmDependencyEdge>,
869 candidates: Vec<DiscoveryCandidate>,
870}
871
872#[derive(Debug, Clone, Copy, PartialEq, Eq)]
873enum NpmDependencyKind {
874 Dependency,
875 Optional,
876 Peer,
877 Dev,
878}
879
880#[derive(Debug, Clone, PartialEq, Eq)]
881struct NpmDependencyEdge {
882 dependent: String,
883 dependency: String,
884 kind: NpmDependencyKind,
885 range: String,
886 manifest: PathBuf,
887}
888
889struct ManifestObservation {
890 candidate_path: PathBuf,
891 native_identity: String,
892 private_package: bool,
893 projection: CandidateProjectionSuggestion,
894 raw_version: Option<String>,
895 tag: CandidateTagSuggestion,
896 workspace_package: bool,
897}
898
899struct ChangesetsSourceSemantics<'a> {
900 private_packages: &'a BTreeSet<String>,
901 suppress_private_versions: bool,
902 npm_dependencies: &'a [NpmDependencyEdge],
903 internal_dependency_bump: Bump,
904 update_internal_dependents_always: bool,
905 only_update_peer_dependents_when_out_of_range: bool,
906 preflight_error: Option<String>,
907}
908
909fn ordinary_plan(root: &Path) -> Result<InitResult> {
910 let previous = load_previous_plan(root)?;
911 let had_previous_plan = previous.is_some();
912 let configured = if root.join(CONFIG_PATH).is_file() {
913 Config::load(root)?
914 } else {
915 Config::default()
916 };
917 let mut discovery = discover(root)?;
918 reconcile_candidate_resolutions(&mut discovery.candidates, previous.as_ref());
919 let mut candidates = unresolved_by_receipts(&configured, discovery.candidates);
920
921 if candidates.is_empty() {
922 if configured.release_units.is_empty() {
923 return Err(Error::Validation(
924 "no supported workspace manifests found".to_owned(),
925 ));
926 }
927 let contents = configured.to_yaml()?;
928 let (operations, deletes) = if had_previous_plan {
929 (
930 vec![format!("delete {INIT_PLAN_PATH}")],
931 vec![PathBuf::from(INIT_PLAN_PATH)],
932 )
933 } else {
934 (Vec::new(), Vec::new())
935 };
936 return Ok(InitResult {
937 state: InitState::Success,
938 path: PathBuf::from(CONFIG_PATH),
939 operations,
940 contents,
941 plan: None,
942 writes: Vec::new(),
943 deletes,
944 proxy_removals: Vec::new(),
945 takeover: false,
946 takeover_evidence: None,
947 });
948 }
949
950 candidates.sort_by(|left, right| left.id.cmp(&right.id));
951 let all_resolved = candidates
952 .iter()
953 .all(|candidate| candidate.resolution.is_some());
954 if all_resolved {
955 let applied = apply_candidate_resolutions(root, configured, &candidates)?;
956 let contents = applied.to_yaml()?;
957 return Ok(InitResult {
958 state: InitState::Success,
959 path: PathBuf::from(CONFIG_PATH),
960 operations: vec![
961 format!("write {CONFIG_PATH}"),
962 format!("create {}", crate::intent::INTENTS_PATH),
963 format!("delete {INIT_PLAN_PATH}"),
964 ],
965 contents: contents.clone(),
966 plan: None,
967 writes: vec![(PathBuf::from(CONFIG_PATH), contents)],
968 deletes: vec![PathBuf::from(INIT_PLAN_PATH)],
969 proxy_removals: Vec::new(),
970 takeover: false,
971 takeover_evidence: None,
972 });
973 }
974
975 let evidence_paths = candidates
976 .iter()
977 .flat_map(|candidate| candidate.evidence.iter().map(|item| item.path.clone()))
978 .collect::<BTreeSet<_>>();
979 let plan = InitPlan {
980 schema: INIT_PLAN_SCHEMA.to_owned(),
981 state: InitState::NeedsInput,
982 source_kind: if root.join(CONFIG_PATH).is_file() {
983 "intentional".to_owned()
984 } else {
985 "workspace".to_owned()
986 },
987 source_fingerprint: fingerprint(root, &evidence_paths)?,
988 inferred_config: configured,
989 discovery_candidates: candidates,
990 diagnostics: Vec::new(),
991 converted_intents: Vec::new(),
992 parity: ParityResult {
993 status: "equivalent".to_owned(),
994 release_units: Vec::new(),
995 },
996 planned_operations: vec![
997 format!("write {CONFIG_PATH}"),
998 format!("create {}", crate::intent::INTENTS_PATH),
999 format!("delete {INIT_PLAN_PATH}"),
1000 ],
1001 post_commit_action: String::new(),
1002 };
1003 let contents = plan.to_yaml()?;
1004 Ok(InitResult {
1005 state: InitState::NeedsInput,
1006 path: PathBuf::from(INIT_PLAN_PATH),
1007 operations: vec![format!("write {INIT_PLAN_PATH}")],
1008 contents: contents.clone(),
1009 plan: Some(plan),
1010 writes: vec![(PathBuf::from(INIT_PLAN_PATH), contents)],
1011 deletes: Vec::new(),
1012 proxy_removals: Vec::new(),
1013 takeover: false,
1014 takeover_evidence: None,
1015 })
1016}
1017
1018fn reconcile_candidate_resolutions(
1019 candidates: &mut [DiscoveryCandidate],
1020 previous: Option<&InitPlan>,
1021) {
1022 let Some(previous) = previous else {
1023 return;
1024 };
1025 let old = previous
1026 .discovery_candidates
1027 .iter()
1028 .map(|candidate| (candidate.id.as_str(), candidate))
1029 .collect::<BTreeMap<_, _>>();
1030 for candidate in candidates {
1031 if let Some(previous) = old.get(candidate.id.as_str()) {
1032 if previous.evidence == candidate.evidence
1033 && previous.native_identity == candidate.native_identity
1034 && previous.projection == candidate.projection
1035 && previous.tag == candidate.tag
1036 {
1037 candidate.resolution = previous.resolution.clone();
1038 }
1039 }
1040 }
1041}
1042
1043fn unresolved_by_receipts(
1044 config: &Config,
1045 candidates: Vec<DiscoveryCandidate>,
1046) -> Vec<DiscoveryCandidate> {
1047 let managed = config
1048 .discovery
1049 .managed_paths
1050 .iter()
1051 .map(|receipt| (&receipt.detector, &receipt.path))
1052 .collect::<BTreeSet<_>>();
1053 let excluded = config
1054 .discovery
1055 .excluded_paths
1056 .iter()
1057 .map(|receipt| ((&receipt.detector, &receipt.path), &receipt.evidence_digest))
1058 .collect::<BTreeMap<_, _>>();
1059 candidates
1060 .into_iter()
1061 .filter_map(|candidate| {
1062 if managed.contains(&(&candidate.detector, &candidate.path)) {
1063 return None;
1064 }
1065 if let Some(expected) = excluded.get(&(&candidate.detector, &candidate.path)) {
1066 let actual = candidate
1067 .evidence
1068 .iter()
1069 .find(|item| item.path == candidate.path)
1070 .map(|item| &item.digest);
1071 if actual == Some(*expected) {
1072 return None;
1073 }
1074 }
1075 Some(candidate)
1076 })
1077 .collect()
1078}
1079
1080fn apply_candidate_resolutions(
1081 root: &Path,
1082 mut config: Config,
1083 candidates: &[DiscoveryCandidate],
1084) -> Result<Config> {
1085 let validation_plan = InitPlan {
1086 schema: INIT_PLAN_SCHEMA.to_owned(),
1087 state: InitState::NeedsInput,
1088 source_kind: "workspace".to_owned(),
1089 source_fingerprint: format!("sha256:{}", "0".repeat(64)),
1090 inferred_config: config.clone(),
1091 discovery_candidates: candidates.to_vec(),
1092 diagnostics: Vec::new(),
1093 converted_intents: Vec::new(),
1094 parity: ParityResult {
1095 status: "equivalent".to_owned(),
1096 release_units: Vec::new(),
1097 },
1098 planned_operations: Vec::new(),
1099 post_commit_action: String::new(),
1100 };
1101 validation_plan.validate()?;
1102
1103 let resolved_paths = candidates
1104 .iter()
1105 .map(|candidate| (candidate.detector.clone(), candidate.path.clone()))
1106 .collect::<BTreeSet<_>>();
1107 config.discovery.managed_paths.retain(|receipt| {
1108 !resolved_paths.contains(&(receipt.detector.clone(), receipt.path.clone()))
1109 });
1110 config.discovery.excluded_paths.retain(|receipt| {
1111 !resolved_paths.contains(&(receipt.detector.clone(), receipt.path.clone()))
1112 });
1113
1114 for candidate in candidates {
1115 let CandidateResolution::Independent { release_unit } = candidate
1116 .resolution
1117 .as_ref()
1118 .expect("all candidate resolutions checked")
1119 else {
1120 continue;
1121 };
1122 let projection = candidate
1123 .projection
1124 .as_ref()
1125 .map(|_| {
1126 candidate_projection(candidate, candidate.path.parent().unwrap_or(Path::new("")))
1127 })
1128 .transpose()?;
1129 let path = candidate
1130 .path
1131 .parent()
1132 .filter(|path| !path.as_os_str().is_empty())
1133 .map(Path::to_path_buf)
1134 .unwrap_or_else(|| PathBuf::from("."));
1135 let tag = candidate.tag.as_ref().ok_or_else(|| Error::Validation(format!(
1136 "discovery candidate {} cannot create an independent release unit without a tag suggestion",
1137 candidate.id
1138 )))?;
1139 config.release_units.insert(
1140 release_unit.clone(),
1141 ReleaseUnitConfig {
1142 path,
1143 disposition: ReleaseUnitDisposition::Managed,
1144 projections: projection.into_iter().collect(),
1145 tags: BTreeMap::from([(
1146 tag.id.clone(),
1147 TagConfig {
1148 role: tag.role,
1149 template: tag.template.clone(),
1150 require_phase: None,
1151 tag_after: Vec::new(),
1152 },
1153 )]),
1154 depends_on: Vec::new(),
1155 },
1156 );
1157 }
1158 for candidate in candidates {
1159 match candidate
1160 .resolution
1161 .as_ref()
1162 .expect("all candidate resolutions checked")
1163 {
1164 CandidateResolution::Independent { release_unit } => {
1165 config.discovery.managed_paths.push(ManagedPathReceipt {
1166 detector: candidate.detector.clone(),
1167 path: candidate.path.clone(),
1168 release_unit: release_unit.clone(),
1169 });
1170 }
1171 CandidateResolution::Projection { release_unit, .. } => {
1172 let unit = config.release_units.get_mut(release_unit).ok_or_else(|| {
1173 Error::Validation(format!(
1174 "discovery candidate {} projects onto absent release unit {release_unit}",
1175 candidate.id
1176 ))
1177 })?;
1178 if candidate.projection.is_some() {
1179 let projection = candidate_projection(candidate, &unit.path)?;
1180 if !unit.projections.iter().any(|existing| {
1181 existing.file == projection.file && existing.pointer == projection.pointer
1182 }) {
1183 unit.projections.push(projection);
1184 }
1185 }
1186 config.discovery.managed_paths.push(ManagedPathReceipt {
1187 detector: candidate.detector.clone(),
1188 path: candidate.path.clone(),
1189 release_unit: release_unit.clone(),
1190 });
1191 }
1192 CandidateResolution::Excluded => {
1193 let digest = candidate
1194 .evidence
1195 .iter()
1196 .find(|item| item.path == candidate.path)
1197 .expect("candidate path evidence validated")
1198 .digest
1199 .clone();
1200 config.discovery.excluded_paths.push(ExcludedPathReceipt {
1201 detector: candidate.detector.clone(),
1202 path: candidate.path.clone(),
1203 evidence_digest: digest,
1204 });
1205 }
1206 }
1207 }
1208 config
1209 .discovery
1210 .managed_paths
1211 .sort_by(|a, b| (&a.detector, &a.path).cmp(&(&b.detector, &b.path)));
1212 config
1213 .discovery
1214 .excluded_paths
1215 .sort_by(|a, b| (&a.detector, &a.path).cmp(&(&b.detector, &b.path)));
1216 let npm_release_units = config
1217 .release_units
1218 .iter()
1219 .filter(|(_, unit)| {
1220 unit.projections
1221 .iter()
1222 .any(|projection| projection.adapter == Adapter::Npm)
1223 })
1224 .map(|(id, _)| id.clone())
1225 .collect::<BTreeSet<_>>();
1226 let configured_non_npm_dependencies = config
1227 .release_units
1228 .iter()
1229 .filter(|(_, unit)| {
1230 unit.projections
1231 .iter()
1232 .any(|projection| projection.adapter == Adapter::Npm)
1233 })
1234 .map(|(id, unit)| {
1235 let dependencies = unit
1238 .depends_on
1239 .iter()
1240 .filter(|dependency| !npm_release_units.contains(*dependency))
1241 .cloned()
1242 .collect::<Vec<_>>();
1243 (id.clone(), dependencies)
1244 })
1245 .collect::<BTreeMap<_, _>>();
1246 derive_npm_dependencies(root, &mut config)?;
1247 for (id, dependencies) in configured_non_npm_dependencies {
1248 if let Some(unit) = config.release_units.get_mut(&id) {
1249 unit.depends_on.extend(dependencies);
1250 unit.depends_on.sort();
1251 unit.depends_on.dedup();
1252 }
1253 }
1254 config.validate()?;
1255 Ok(config)
1256}
1257
1258fn candidate_projection(candidate: &DiscoveryCandidate, unit_path: &Path) -> Result<Projection> {
1259 let suggestion = candidate.projection.as_ref().ok_or_else(|| {
1260 Error::Validation(format!(
1261 "discovery candidate {} has no projection suggestion",
1262 candidate.id
1263 ))
1264 })?;
1265 let file = if unit_path == Path::new(".") {
1266 suggestion.path.clone()
1267 } else {
1268 suggestion
1269 .path
1270 .strip_prefix(unit_path)
1271 .map_err(|_| {
1272 Error::Validation(format!(
1273 "candidate projection {} is outside release-unit path {}",
1274 suggestion.path.display(),
1275 unit_path.display()
1276 ))
1277 })?
1278 .to_owned()
1279 };
1280 Ok(Projection {
1281 adapter: suggestion.adapter,
1282 file,
1283 mode: suggestion.mode,
1284 pointer: suggestion.pointer.clone(),
1285 })
1286}
1287
1288fn apply_changesets_candidate_resolutions(
1289 root: &Path,
1290 discovery: &mut Discovery,
1291 candidates: &[DiscoveryCandidate],
1292) -> Result<BTreeMap<String, String>> {
1293 let mut removed_release_units = BTreeSet::new();
1294 let mut identity_map = BTreeMap::new();
1295 for candidate in candidates {
1296 match &candidate.resolution {
1297 None => {}
1298 Some(CandidateResolution::Projection { release_unit, .. }) => {
1299 let projection_owner =
1300 candidate_projection_owner(&discovery.config, candidate).map(str::to_owned);
1301 let Some(target) = discovery.config.release_units.get(release_unit) else {
1302 return Err(Error::Validation(format!(
1303 "Changesets discovery candidate {} projects onto absent release unit {release_unit}",
1304 candidate.id
1305 )));
1306 };
1307 let target_path = target.path.clone();
1308 if candidate.projection.is_some() {
1309 if let Some(owner) = projection_owner.as_deref() {
1310 let native_owner = candidate.native_identity.as_deref() == Some(owner);
1311 if owner != release_unit && !native_owner {
1312 return Err(Error::Validation(format!(
1313 "Changesets discovery candidate {} projection is already owned by release unit {owner}, not {release_unit}",
1314 candidate.id
1315 )));
1316 }
1317 }
1318 let projection = candidate_projection(candidate, &target_path)?;
1319 let target = discovery
1320 .config
1321 .release_units
1322 .get_mut(release_unit)
1323 .expect("validated target release unit");
1324 if let Some(existing) = target.projections.iter().find(|existing| {
1325 existing.file == projection.file && existing.pointer == projection.pointer
1326 }) {
1327 if existing.adapter != projection.adapter
1328 || existing.mode != projection.mode
1329 {
1330 return Err(Error::Validation(format!(
1331 "Changesets discovery candidate {} conflicts with existing projection {} on release unit {release_unit}: adapter or mode differs",
1332 candidate.id,
1333 projection.file.display()
1334 )));
1335 }
1336 } else {
1337 target.projections.push(projection);
1338 }
1339 }
1340 if let Some(native_identity) = &candidate.native_identity {
1341 if native_identity != release_unit {
1342 if let Some(existing) =
1343 identity_map.insert(native_identity.clone(), release_unit.clone())
1344 {
1345 if existing != *release_unit {
1346 return Err(Error::Validation(format!(
1347 "Changesets discovery candidates map native identity {native_identity} to both {existing} and {release_unit}"
1348 )));
1349 }
1350 }
1351 if let Some(source) =
1352 discovery.config.release_units.get_mut(native_identity)
1353 {
1354 source.projections.retain(|projection| {
1355 root.join(&source.path).join(&projection.file)
1356 != root.join(&candidate.path)
1357 });
1358 if source.projections.is_empty() {
1359 removed_release_units.insert(native_identity.clone());
1360 }
1361 }
1362 }
1363 }
1364 discovery
1365 .config
1366 .discovery
1367 .managed_paths
1368 .push(ManagedPathReceipt {
1369 detector: candidate.detector.clone(),
1370 path: candidate.path.clone(),
1371 release_unit: release_unit.clone(),
1372 });
1373 }
1374 Some(CandidateResolution::Excluded) => {
1375 if let Some(native_identity) = &candidate.native_identity {
1376 if let Some(unit) = discovery.config.release_units.get_mut(native_identity) {
1377 let unit_path = unit.path.clone();
1378 unit.projections.retain(|projection| {
1379 let path = if unit_path == Path::new(".") {
1380 projection.file.clone()
1381 } else {
1382 unit_path.join(&projection.file)
1383 };
1384 path != candidate.path
1385 });
1386 if unit.projections.is_empty() {
1387 removed_release_units.insert(native_identity.clone());
1388 }
1389 }
1390 }
1391 let digest = candidate
1392 .evidence
1393 .iter()
1394 .find(|item| item.path == candidate.path)
1395 .expect("candidate path evidence validated")
1396 .digest
1397 .clone();
1398 discovery
1399 .config
1400 .discovery
1401 .excluded_paths
1402 .push(ExcludedPathReceipt {
1403 detector: candidate.detector.clone(),
1404 path: candidate.path.clone(),
1405 evidence_digest: digest,
1406 });
1407 }
1408 Some(CandidateResolution::Independent { release_unit }) => {
1409 return Err(Error::Validation(format!(
1410 "Changesets already establishes release unit {release_unit}; candidate {} must project onto it or be excluded",
1411 candidate.id
1412 )));
1413 }
1414 }
1415 }
1416 for release_unit in &removed_release_units {
1417 discovery.config.release_units.remove(release_unit);
1418 }
1419 for (source, target) in &identity_map {
1420 if discovery.config.release_units.contains_key(source) {
1421 return Err(Error::Validation(format!(
1422 "Changesets discovery candidates leave native identity {source} split between release units {source} and {target}; resolve every projection for that identity consistently"
1423 )));
1424 }
1425 if discovery.workspace_packages.remove(source) {
1426 discovery.workspace_packages.insert(target.clone());
1427 }
1428 if discovery.private_packages.remove(source) {
1429 discovery.private_packages.insert(target.clone());
1430 }
1431 discovery.versions.remove(source);
1432 }
1433 for unit in discovery.config.release_units.values_mut() {
1434 for dependency in &mut unit.depends_on {
1435 if let Some(target) = identity_map.get(dependency) {
1436 *dependency = target.clone();
1437 }
1438 }
1439 unit.depends_on
1440 .retain(|dependency| !removed_release_units.contains(dependency));
1441 unit.depends_on.sort();
1442 unit.depends_on.dedup();
1443 }
1444 for edge in &mut discovery.npm_dependencies {
1445 if let Some(target) = identity_map.get(&edge.dependent) {
1446 edge.dependent = target.clone();
1447 }
1448 if let Some(target) = identity_map.get(&edge.dependency) {
1449 edge.dependency = target.clone();
1450 }
1451 }
1452 discovery.npm_dependencies.retain(|edge| {
1453 edge.dependent != edge.dependency
1454 && discovery.config.release_units.contains_key(&edge.dependent)
1455 && discovery
1456 .config
1457 .release_units
1458 .contains_key(&edge.dependency)
1459 });
1460 retain_materialized_npm_dependencies(&mut discovery.config, &mut discovery.npm_dependencies);
1461 discovery
1462 .config
1463 .discovery
1464 .managed_paths
1465 .sort_by(|left, right| (&left.detector, &left.path).cmp(&(&right.detector, &right.path)));
1466 discovery
1467 .config
1468 .discovery
1469 .excluded_paths
1470 .sort_by(|left, right| (&left.detector, &left.path).cmp(&(&right.detector, &right.path)));
1471 recompute_resolved_versions(discovery, candidates)?;
1472 Ok(identity_map)
1473}
1474
1475fn recompute_resolved_versions(
1476 discovery: &mut Discovery,
1477 candidates: &[DiscoveryCandidate],
1478) -> Result<()> {
1479 let mut versions = BTreeMap::<String, BTreeSet<Version>>::new();
1480 let mut unresolved = BTreeSet::new();
1481 for candidate in candidates {
1482 let Some(raw_version) = candidate.raw_version.as_ref() else {
1483 continue;
1484 };
1485 let Some(native_identity) = candidate.native_identity.as_ref() else {
1486 continue;
1487 };
1488 let release_unit = match candidate.resolution.as_ref() {
1489 Some(CandidateResolution::Projection { release_unit, .. })
1490 | Some(CandidateResolution::Independent { release_unit }) => release_unit,
1491 Some(CandidateResolution::Excluded) => continue,
1492 None => {
1493 unresolved.insert(native_identity.clone());
1494 native_identity
1495 }
1496 };
1497 if !discovery.config.release_units.contains_key(release_unit) {
1498 continue;
1499 }
1500 if let Ok(version) = Version::parse(&raw_version.value) {
1501 versions
1502 .entry(release_unit.clone())
1503 .or_default()
1504 .insert(version);
1505 }
1506 }
1507
1508 discovery.versions.clear();
1509 for (release_unit, observed) in versions {
1510 if observed.len() == 1 {
1511 discovery.versions.insert(
1512 release_unit,
1513 observed.into_iter().next().expect("one observed version"),
1514 );
1515 } else if !unresolved.contains(&release_unit) {
1516 return Err(Error::Validation(format!(
1517 "resolved discovery candidates for release unit {release_unit} disagree on current version: {}",
1518 observed
1519 .into_iter()
1520 .map(|version| version.to_string())
1521 .collect::<Vec<_>>()
1522 .join(", ")
1523 )));
1524 }
1525 }
1526 Ok(())
1527}
1528
1529fn candidate_projection_owner<'a>(
1530 config: &'a Config,
1531 candidate: &DiscoveryCandidate,
1532) -> Option<&'a str> {
1533 let Some(suggestion) = &candidate.projection else {
1534 return None;
1535 };
1536 config.release_units.iter().find_map(|(id, unit)| {
1537 unit.projections
1538 .iter()
1539 .any(|projection| {
1540 projection_workspace_path(unit, projection) == suggestion.path
1541 && projection.pointer == suggestion.pointer
1542 })
1543 .then_some(id.as_str())
1544 })
1545}
1546
1547fn projection_workspace_path(unit: &ReleaseUnitConfig, projection: &Projection) -> PathBuf {
1548 if unit.path == Path::new(".") {
1549 projection.file.clone()
1550 } else {
1551 unit.path.join(&projection.file)
1552 }
1553}
1554
1555fn changesets_plan(root: &Path, take_over: bool) -> Result<InitResult> {
1556 let previous = load_previous_plan(root)?;
1557 let changesets_config_path = root.join(CHANGESETS_CONFIG);
1558 let changesets_text = std::fs::read_to_string(&changesets_config_path)
1559 .map_err(|error| Error::io(&changesets_config_path, error))?;
1560 let changesets: JsonValue = serde_json::from_str(&changesets_text)
1561 .map_err(|error| Error::Validation(format!("invalid Changesets config: {error}")))?;
1562 let mut converted_intents = load_changesets_intents(root)?;
1563 let mut referenced_names = converted_intents
1564 .iter()
1565 .flat_map(|intent| intent.release_units.keys().cloned())
1566 .collect::<BTreeSet<_>>();
1567 for key in ["ignore", "fixed", "linked"] {
1568 collect_json_strings(&changesets[key], &mut referenced_names);
1569 }
1570 let mut discovery = discover(root)?;
1571 reconcile_candidate_resolutions(&mut discovery.candidates, previous.as_ref());
1572 let candidates = discovery.candidates.clone();
1573 let identity_map = apply_changesets_candidate_resolutions(root, &mut discovery, &candidates)?;
1574 discovery.config.settings.pre_1_0_bump_mapping = Pre1BumpMapping::Component;
1575 discovery.config.settings.internal_dependency_bump = changesets["updateInternalDependencies"]
1576 .as_str()
1577 .and_then(|value| value.parse().ok())
1578 .unwrap_or(Bump::Patch);
1579 discovery.config.fixed = parse_groups(&changesets["fixed"])?;
1580 discovery.config.linked = parse_groups(&changesets["linked"])?;
1581 remap_groups(&mut discovery.config.fixed, &identity_map);
1582 remap_groups(&mut discovery.config.linked, &identity_map);
1583 remap_converted_intents(&mut converted_intents, &identity_map, &discovery.config)?;
1584
1585 let mut diagnostics = Vec::new();
1586 let config_evidence = evidence(root, Path::new(CHANGESETS_CONFIG), Vec::new())?;
1587 for ignored in changesets["ignore"].as_array().into_iter().flatten() {
1588 let Some(source_package) = ignored.as_str() else {
1589 continue;
1590 };
1591 diagnostics.push(InitDiagnostic {
1592 id: format!("ignored-release-unit-disposition:{source_package}"),
1593 code: "ignored-release-unit-disposition".to_owned(),
1594 message: format!(
1595 "Choose whether Changesets-ignored package {source_package} is suspended, excluded, or managed. Selecting managed requires removing it from Changesets ignore before takeover."
1596 ),
1597 evidence: vec![config_evidence.clone()],
1598 choices: vec!["suspended".to_owned(), "excluded".to_owned(), "managed".to_owned()],
1599 recommended: Some("suspended".to_owned()),
1600 resolution: None,
1601 verified: false,
1602 invalidated_resolution: false,
1603 supporting_evidence: Vec::new(),
1604 contradictory_evidence: Vec::new(),
1605 uncertainty: None,
1606 });
1607 }
1608 if let Some((versions_private, tags_private)) =
1609 changesets_private_package_settings(&changesets["privatePackages"])
1610 {
1611 diagnostics.push(InitDiagnostic {
1612 id: "private-package-versioning".to_owned(),
1613 code: "private-package-versioning".to_owned(),
1614 message: if versions_private {
1615 "Changesets versions private packages; Intentional preserves that behavior because package privacy is independent from version management.".to_owned()
1616 } else {
1617 "Changesets suppresses private-package versions; Intentional manages versions independently from publication privacy, so accepting Intentional semantics is a deliberate contract change.".to_owned()
1618 },
1619 evidence: vec![config_evidence.clone()],
1620 choices: if versions_private {
1621 Vec::new()
1622 } else {
1623 vec!["intentional".to_owned()]
1624 },
1625 recommended: (!versions_private).then(|| "intentional".to_owned()),
1626 resolution: None,
1627 verified: versions_private,
1628 invalidated_resolution: false,
1629 supporting_evidence: Vec::new(),
1630 contradictory_evidence: Vec::new(),
1631 uncertainty: None,
1632 });
1633 if !tags_private {
1634 diagnostics.push(InitDiagnostic {
1635 id: "private-package-tagging".to_owned(),
1636 code: "private-package-tagging".to_owned(),
1637 message: "Changesets suppresses private-package tags; Intentional creates annotated records for every managed release unit, independently from publication privacy.".to_owned(),
1638 evidence: vec![config_evidence.clone()],
1639 choices: vec!["intentional".to_owned()],
1640 recommended: Some("intentional".to_owned()),
1641 resolution: None,
1642 verified: false,
1643 invalidated_resolution: false,
1644 supporting_evidence: Vec::new(),
1645 contradictory_evidence: Vec::new(),
1646 uncertainty: None,
1647 });
1648 }
1649 }
1650 if changesets["changelog"] != JsonValue::Bool(false) && !changesets["changelog"].is_null() {
1651 diagnostics.push(InitDiagnostic {
1652 id: "changesets-changelog".to_owned(),
1653 code: "changesets-changelog".to_owned(),
1654 message: "Intentional renders its contract-defined release-unit changelogs instead of invoking the configured Changesets changelog generator.".to_owned(),
1655 evidence: vec![config_evidence.clone()],
1656 choices: vec!["intentional".to_owned()],
1657 recommended: Some("intentional".to_owned()),
1658 resolution: None,
1659 verified: false,
1660 invalidated_resolution: false,
1661 supporting_evidence: Vec::new(),
1662 contradictory_evidence: Vec::new(),
1663 uncertainty: None,
1664 });
1665 }
1666 if changesets["commit"] != JsonValue::Bool(false) && !changesets["commit"].is_null() {
1667 diagnostics.push(InitDiagnostic {
1668 id: "changesets-commit".to_owned(),
1669 code: "changesets-commit".to_owned(),
1670 message: "Intentional never creates commits; repository orchestration must retain the configured commit behavior externally.".to_owned(),
1671 evidence: vec![config_evidence.clone()],
1672 choices: vec!["external".to_owned()],
1673 recommended: Some("external".to_owned()),
1674 resolution: None,
1675 verified: false,
1676 invalidated_resolution: false,
1677 supporting_evidence: Vec::new(),
1678 contradictory_evidence: Vec::new(),
1679 uncertainty: None,
1680 });
1681 }
1682 if changesets["___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH"]
1683 ["onlyUpdatePeerDependentsWhenOutOfRange"]
1684 .as_bool()
1685 == Some(true)
1686 {
1687 diagnostics.push(InitDiagnostic {
1688 id: "changesets-peer-dependent-policy".to_owned(),
1689 code: "changesets-peer-dependent-policy".to_owned(),
1690 message: "Changesets conditionally updates peer dependents from npm ranges; Intentional applies explicit depends-on edges uniformly. Accept Intentional's dependency contract, and resolve any current release divergence shown by parity before takeover.".to_owned(),
1691 evidence: vec![config_evidence.clone()],
1692 choices: vec!["intentional".to_owned()],
1693 recommended: Some("intentional".to_owned()),
1694 resolution: None,
1695 verified: false,
1696 invalidated_resolution: false,
1697 supporting_evidence: Vec::new(),
1698 contradictory_evidence: Vec::new(),
1699 uncertainty: None,
1700 });
1701 }
1702 let dev_dependents = discovery
1703 .npm_dependencies
1704 .iter()
1705 .filter(|edge| edge.kind == NpmDependencyKind::Dev)
1706 .map(|edge| edge.dependent.clone())
1707 .collect::<BTreeSet<_>>();
1708 if !dev_dependents.is_empty() {
1709 let evidence = dev_dependents
1710 .iter()
1711 .filter_map(|id| {
1712 let release_unit = &discovery.config.release_units[id];
1713 release_unit
1714 .projections
1715 .iter()
1716 .find(|projection| projection.adapter == Adapter::Npm)
1717 .map(|projection| release_unit.path.join(&projection.file))
1718 })
1719 .map(|path| evidence(root, &path, Vec::new()))
1720 .collect::<Result<Vec<_>>>()?;
1721 diagnostics.push(InitDiagnostic {
1722 id: "changesets-dev-dependency-policy".to_owned(),
1723 code: "changesets-dev-dependency-policy".to_owned(),
1724 message: "Changesets propagates releases through internal devDependencies; Intentional reserves depends-on for durable release dependencies. Accept Intentional's dependency contract, and resolve any current release divergence shown by parity before takeover.".to_owned(),
1725 evidence,
1726 choices: vec!["intentional".to_owned()],
1727 recommended: Some("intentional".to_owned()),
1728 resolution: None,
1729 verified: false,
1730 invalidated_resolution: false,
1731 supporting_evidence: Vec::new(),
1732 contradictory_evidence: Vec::new(),
1733 uncertainty: None,
1734 });
1735 }
1736 diagnostics.extend(release_tool_proxy_diagnostics(
1737 root,
1738 &discovery.candidates,
1739 &referenced_names,
1740 )?);
1741 let mut unmapped_release_units = BTreeSet::new();
1742 for release_unit in discovery.config.release_units.keys() {
1743 if referenced_names.contains(release_unit)
1744 || discovery.workspace_packages.contains(release_unit)
1745 || identity_map.values().any(|target| target == release_unit)
1746 {
1747 continue;
1748 }
1749 unmapped_release_units.insert(release_unit.clone());
1750 let release_unit_config = &discovery.config.release_units[release_unit];
1751 let manifest = release_unit_config
1752 .projections
1753 .first()
1754 .map(|projection| release_unit_config.path.join(&projection.file))
1755 .ok_or_else(|| {
1756 Error::Validation(format!(
1757 "discovered release unit {release_unit} has no manifest evidence"
1758 ))
1759 })?;
1760 diagnostics.push(InitDiagnostic {
1761 id: format!("unmapped-release-unit-disposition:{release_unit}"),
1762 code: "unmapped-release-unit-disposition".to_owned(),
1763 message: format!(
1764 "Choose whether workspace package {release_unit}, which is outside the Changesets release inventory, is excluded, suspended, or managed."
1765 ),
1766 evidence: vec![evidence(root, &manifest, Vec::new())?],
1767 choices: vec!["excluded".to_owned(), "suspended".to_owned(), "managed".to_owned()],
1768 recommended: Some("excluded".to_owned()),
1769 resolution: None,
1770 verified: false,
1771 invalidated_resolution: false,
1772 supporting_evidence: Vec::new(),
1773 contradictory_evidence: Vec::new(),
1774 uncertainty: None,
1775 });
1776 }
1777 let current_integrations = scan_changesets_integrations(root)?;
1778 for item in ¤t_integrations {
1779 diagnostics.push(InitDiagnostic {
1780 id: format!("repository-integration:{}", item.path.display()),
1781 code: "repository-integration".to_owned(),
1782 message: format!(
1783 "Remove or replace executable Changesets references in {} and rerun init for verification.",
1784 item.path.display()
1785 ),
1786 evidence: vec![item.clone()],
1787 choices: vec!["removed".to_owned()],
1788 recommended: Some("removed".to_owned()),
1789 resolution: None,
1790 verified: false,
1791 invalidated_resolution: false,
1792 supporting_evidence: Vec::new(),
1793 contradictory_evidence: Vec::new(),
1794 uncertainty: None,
1795 });
1796 }
1797 let integration_paths = current_integrations
1798 .iter()
1799 .map(|item| item.path.as_path())
1800 .collect::<BTreeSet<_>>();
1801 for item in scan_external_release_evidence(root)?
1802 .into_iter()
1803 .filter(|item| !integration_paths.contains(item.path.as_path()))
1804 {
1805 diagnostics.push(InitDiagnostic {
1806 id: format!("external-release-evidence:{}", item.path.display()),
1807 code: "external-release-evidence".to_owned(),
1808 message: format!(
1809 "Keep repository-specific release behavior from {} in the external release executor; Intentional records this file as evidence without interpreting its contents.",
1810 item.path.display()
1811 ),
1812 evidence: vec![item],
1813 choices: vec!["external".to_owned()],
1814 recommended: Some("external".to_owned()),
1815 resolution: None,
1816 verified: false,
1817 invalidated_resolution: false,
1818 supporting_evidence: Vec::new(),
1819 contradictory_evidence: Vec::new(),
1820 uncertainty: None,
1821 });
1822 }
1823 reconcile_diagnostics(
1824 root,
1825 &mut diagnostics,
1826 previous.as_ref(),
1827 &discovery.candidates,
1828 )?;
1829 let mut source_config = discovery.config.clone();
1830 let ignored = changesets["ignore"]
1831 .as_array()
1832 .into_iter()
1833 .flatten()
1834 .filter_map(JsonValue::as_str)
1835 .map(str::to_owned)
1836 .collect::<BTreeSet<_>>();
1837 let source_excluded = ignored
1838 .iter()
1839 .cloned()
1840 .chain(unmapped_release_units.iter().cloned())
1841 .collect();
1842 exclude_release_units(&mut source_config, &source_excluded);
1843 apply_disposition_resolutions(&mut discovery.config, &diagnostics)?;
1844 let proxy_removals = apply_proxy_manifest_resolutions(
1845 &mut discovery.config,
1846 &diagnostics,
1847 &discovery.candidates,
1848 )?;
1849 retain_materialized_npm_dependencies(&mut discovery.config, &mut discovery.npm_dependencies);
1850 discovery.config.validate()?;
1851
1852 let declared: BTreeMap<String, Bump> =
1853 converted_intents
1854 .iter()
1855 .fold(BTreeMap::new(), |mut aggregate, intent| {
1856 for (id, bump) in &intent.release_units {
1857 aggregate
1858 .entry(id.clone())
1859 .and_modify(|existing| *existing = (*existing).max(*bump))
1860 .or_insert(*bump);
1861 }
1862 aggregate
1863 });
1864 let suppress_private_versions =
1865 changesets_private_package_settings(&changesets["privatePackages"])
1866 .is_some_and(|(versions_private, _)| !versions_private);
1867 let merged_ignored = ignored
1868 .iter()
1869 .filter_map(|source| {
1870 identity_map
1871 .get(source)
1872 .map(|target| (source.clone(), target.clone()))
1873 })
1874 .collect::<Vec<_>>();
1875 let merged_ignore_error = (!merged_ignored.is_empty()).then(|| {
1876 let conflicts = merged_ignored
1877 .iter()
1878 .map(|(source, target)| format!("{source} onto {target}"))
1879 .collect::<Vec<_>>()
1880 .join(", ");
1881 format!(
1882 "Changesets ignore names native package identities that candidate resolutions map onto managed release units ({conflicts}); source parity cannot apply a package-scoped ignore after identities are merged. Remove the ignore entries or revise the candidate resolutions before takeover"
1883 )
1884 });
1885 let mut skipped_release_units = ignored.clone();
1886 if suppress_private_versions {
1887 skipped_release_units.extend(discovery.private_packages.iter().cloned());
1888 }
1889 skipped_release_units.extend(
1890 discovery
1891 .config
1892 .release_units
1893 .keys()
1894 .filter(|id| !discovery.versions.contains_key(*id))
1895 .cloned(),
1896 );
1897 let source_semantics = ChangesetsSourceSemantics {
1898 private_packages: &discovery.private_packages,
1899 suppress_private_versions,
1900 npm_dependencies: &discovery.npm_dependencies,
1901 internal_dependency_bump: discovery.config.settings.internal_dependency_bump,
1902 update_internal_dependents_always: changesets
1903 ["___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH"]["updateInternalDependents"]
1904 .as_str()
1905 == Some("always"),
1906 only_update_peer_dependents_when_out_of_range: changesets
1907 ["___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH"]
1908 ["onlyUpdatePeerDependentsWhenOutOfRange"]
1909 .as_bool()
1910 .unwrap_or(false),
1911 preflight_error: merged_ignore_error
1912 .or_else(|| mixed_skipped_changeset(&converted_intents, &skipped_release_units)),
1913 };
1914 let parity = parity_result(
1915 &source_config,
1916 &discovery.config,
1917 &discovery.versions,
1918 &declared,
1919 &source_semantics,
1920 )?;
1921 if let Some(message) = &parity.source_error {
1922 diagnostics.push(InitDiagnostic {
1923 id: "changesets-release-invalid".to_owned(),
1924 code: "changesets-release-invalid".to_owned(),
1925 message: format!("The source Changesets release is invalid: {message}"),
1926 evidence: vec![config_evidence.clone()],
1927 choices: Vec::new(),
1928 recommended: None,
1929 resolution: None,
1930 verified: false,
1931 invalidated_resolution: false,
1932 supporting_evidence: Vec::new(),
1933 contradictory_evidence: Vec::new(),
1934 uncertainty: None,
1935 });
1936 }
1937 if let Some(message) = &parity.proposed_error {
1938 diagnostics.push(InitDiagnostic {
1939 id: "proposed-release-invalid".to_owned(),
1940 code: "proposed-release-invalid".to_owned(),
1941 message: format!("The proposed Intentional release is invalid: {message}"),
1942 evidence: vec![config_evidence.clone()],
1943 choices: Vec::new(),
1944 recommended: None,
1945 resolution: None,
1946 verified: false,
1947 invalidated_resolution: false,
1948 supporting_evidence: Vec::new(),
1949 contradictory_evidence: Vec::new(),
1950 uncertainty: None,
1951 });
1952 diagnostics.sort_by(|left, right| left.id.cmp(&right.id));
1953 }
1954 let unresolved = discovery
1955 .candidates
1956 .iter()
1957 .any(|candidate| candidate.resolution.is_none())
1958 || diagnostics.iter().any(|diagnostic| {
1959 !diagnostic.choices.is_empty()
1960 && (diagnostic.resolution.is_none() || !diagnostic.verified)
1961 });
1962 let state = if unresolved || parity.result.status != "equivalent" {
1963 InitState::NeedsInput
1964 } else {
1965 InitState::Ready
1966 };
1967
1968 let mut evidence_paths = discovery.evidence;
1969 evidence_paths.insert(PathBuf::from(CHANGESETS_CONFIG));
1970 for intent in &converted_intents {
1971 evidence_paths.insert(intent.source.clone());
1972 }
1973 for diagnostic in &diagnostics {
1974 for item in &diagnostic.evidence {
1975 evidence_paths.insert(item.path.clone());
1976 }
1977 }
1978 let source_fingerprint = fingerprint(root, &evidence_paths)?;
1979 let planned_operations = takeover_operations(root, &converted_intents, &proxy_removals);
1980 let mut plan = InitPlan {
1981 schema: INIT_PLAN_SCHEMA.to_owned(),
1982 state,
1983 source_kind: "changesets".to_owned(),
1984 source_fingerprint,
1985 inferred_config: discovery.config,
1986 discovery_candidates: discovery.candidates,
1987 diagnostics,
1988 converted_intents,
1989 parity: parity.result,
1990 planned_operations: planned_operations.clone(),
1991 post_commit_action: "intentional tag --baseline".to_owned(),
1992 };
1993 plan.state = state;
1995 let contents = plan.to_yaml()?;
1996
1997 if take_over {
1998 if state != InitState::Ready {
1999 return Err(Error::Validation(
2000 "takeover requires a ready initialization plan with verified resolutions and parity"
2001 .to_owned(),
2002 ));
2003 }
2004 let mut writes = vec![(PathBuf::from(CONFIG_PATH), plan.inferred_config.to_yaml()?)];
2005 for intent in &plan.converted_intents {
2006 writes.push((intent.target.clone(), intent.contents()?));
2007 }
2008 let deletes = takeover_deletes(root, &proxy_removals);
2009 verify_proxy_removal_preconditions(root, &proxy_removals)?;
2010 verify_takeover_preconditions(root, &writes, &plan.source_fingerprint, &evidence_paths)?;
2011 let takeover_evidence = Some((plan.source_fingerprint.clone(), evidence_paths));
2012 return Ok(InitResult {
2013 state: InitState::Success,
2014 path: PathBuf::from(CONFIG_PATH),
2015 contents: plan.inferred_config.to_yaml()?,
2016 operations: planned_operations,
2017 plan: Some(plan),
2018 writes,
2019 deletes,
2020 proxy_removals,
2021 takeover: true,
2022 takeover_evidence,
2023 });
2024 }
2025
2026 Ok(InitResult {
2027 state,
2028 path: PathBuf::from(INIT_PLAN_PATH),
2029 operations: vec![format!("write {INIT_PLAN_PATH}")],
2030 contents: contents.clone(),
2031 plan: Some(plan),
2032 writes: vec![(PathBuf::from(INIT_PLAN_PATH), contents)],
2033 deletes: Vec::new(),
2034 proxy_removals,
2035 takeover: false,
2036 takeover_evidence: None,
2037 })
2038}
2039
2040fn discover(root: &Path) -> Result<Discovery> {
2041 let workspace_paths = workspace_manifest_paths(root)?;
2042 let manifest_paths = all_manifest_paths(root)?;
2043 let mut discovery = Discovery {
2044 config: Config::default(),
2045 ..Discovery::default()
2046 };
2047 let mut observations = Vec::new();
2048 for path in manifest_paths {
2049 if let Some(candidate) = devcontainer_candidate(root, &path)? {
2050 discovery.evidence.insert(candidate.path.clone());
2051 if let (Some(native_identity), Some(projection), Some(tag)) = (
2052 candidate.native_identity.clone(),
2053 candidate.projection.clone(),
2054 candidate.tag.clone(),
2055 ) {
2056 observations.push(ManifestObservation {
2057 candidate_path: candidate.path.clone(),
2058 native_identity,
2059 private_package: false,
2060 projection,
2061 raw_version: candidate
2062 .raw_version
2063 .as_ref()
2064 .map(|version| version.value.clone()),
2065 tag,
2066 workspace_package: workspace_paths.contains(&path),
2067 });
2068 }
2069 discovery.candidates.push(candidate);
2070 continue;
2071 }
2072 let Some(adapter) = adapter_for(&path) else {
2073 continue;
2074 };
2075 if !is_project_manifest(&path, adapter)? {
2076 continue;
2077 }
2078 let (id, version) = manifest_identity(&path, adapter)?;
2079 let relative_manifest = path
2080 .strip_prefix(root)
2081 .map_err(|error| Error::Validation(format!("manifest is outside workspace: {error}")))?
2082 .to_owned();
2083 let manifest_evidence = evidence(root, &relative_manifest, Vec::new())?;
2084 let detector = detector_for(adapter).to_owned();
2085 let candidate = DiscoveryCandidate {
2086 id: DiscoveryCandidate::stable_id(&detector, &relative_manifest)?,
2087 detector,
2088 path: relative_manifest.clone(),
2089 evidence: vec![manifest_evidence.clone()],
2090 native_identity: Some(id.clone()),
2091 raw_version: version.as_ref().map(|value| RawVersionEvidence {
2092 value: value.clone(),
2093 evidence: vec![manifest_evidence],
2094 }),
2095 projection: Some(CandidateProjectionSuggestion {
2096 adapter,
2097 path: relative_manifest.clone(),
2098 mode: if adapter == Adapter::Go {
2099 ProjectionMode::None
2100 } else {
2101 ProjectionMode::Committed
2102 },
2103 pointer: None,
2104 }),
2105 tag: Some(CandidateTagSuggestion {
2106 id: "primary".to_owned(),
2107 role: TagRole::Primary,
2108 template: "{id}@{version}".to_owned(),
2109 }),
2110 diagnostics: Vec::new(),
2111 resolution: None,
2112 };
2113 observations.push(ManifestObservation {
2114 candidate_path: relative_manifest.clone(),
2115 native_identity: id,
2116 private_package: adapter == Adapter::Npm && npm_manifest_is_private(&path)?,
2117 projection: candidate
2118 .projection
2119 .clone()
2120 .expect("project manifest has projection"),
2121 raw_version: version,
2122 tag: candidate.tag.clone().expect("project manifest has tag"),
2123 workspace_package: workspace_paths.contains(&path),
2124 });
2125 discovery.candidates.push(candidate);
2126 discovery
2127 .evidence
2128 .insert(path.strip_prefix(root).unwrap_or(&path).to_owned());
2129 }
2130 materialize_discovery_inventory(&mut discovery, observations)?;
2131 discovery.npm_dependencies = derive_npm_dependencies(root, &mut discovery.config)?;
2132 discovery
2133 .candidates
2134 .sort_by(|left, right| left.path.cmp(&right.path));
2135 Ok(discovery)
2136}
2137
2138fn materialize_discovery_inventory(
2139 discovery: &mut Discovery,
2140 observations: Vec<ManifestObservation>,
2141) -> Result<()> {
2142 let mut by_identity = BTreeMap::<String, Vec<ManifestObservation>>::new();
2143 for observation in observations {
2144 by_identity
2145 .entry(observation.native_identity.clone())
2146 .or_default()
2147 .push(observation);
2148 }
2149 for (identity, mut observations) in by_identity {
2150 observations.sort_by(|left, right| left.candidate_path.cmp(&right.candidate_path));
2151 let directories = observations
2152 .iter()
2153 .map(|observation| {
2154 observation
2155 .candidate_path
2156 .parent()
2157 .filter(|path| !path.as_os_str().is_empty())
2158 .map(Path::to_path_buf)
2159 .unwrap_or_else(|| PathBuf::from("."))
2160 })
2161 .collect::<BTreeSet<_>>();
2162 let path = if directories.len() == 1 {
2163 directories.into_iter().next().expect("one directory")
2164 } else {
2165 PathBuf::from(".")
2166 };
2167 let projections = observations
2168 .iter()
2169 .map(|observation| -> Result<Projection> {
2170 let file = if path == Path::new(".") {
2171 observation.projection.path.clone()
2172 } else {
2173 observation
2174 .projection
2175 .path
2176 .strip_prefix(&path)
2177 .map_err(|_| {
2178 Error::Validation(format!(
2179 "candidate projection {} is outside native identity directory {}",
2180 observation.projection.path.display(),
2181 path.display()
2182 ))
2183 })?
2184 .to_owned()
2185 };
2186 Ok(Projection {
2187 adapter: observation.projection.adapter,
2188 file,
2189 mode: observation.projection.mode,
2190 pointer: observation.projection.pointer.clone(),
2191 })
2192 })
2193 .collect::<Result<Vec<_>>>()?;
2194 let tag = observations
2195 .first()
2196 .map(|observation| &observation.tag)
2197 .expect("identity group is not empty");
2198 discovery.config.release_units.insert(
2199 identity.clone(),
2200 ReleaseUnitConfig {
2201 path,
2202 disposition: ReleaseUnitDisposition::Managed,
2203 projections,
2204 tags: BTreeMap::from([(
2205 tag.id.clone(),
2206 TagConfig {
2207 role: tag.role,
2208 template: tag.template.clone(),
2209 require_phase: None,
2210 tag_after: Vec::new(),
2211 },
2212 )]),
2213 depends_on: Vec::new(),
2214 },
2215 );
2216 let versions = observations
2217 .iter()
2218 .filter_map(|observation| observation.raw_version.as_deref())
2219 .filter_map(|version| Version::parse(version).ok())
2220 .collect::<BTreeSet<_>>();
2221 if versions.len() == 1 {
2222 discovery.versions.insert(
2223 identity.clone(),
2224 versions.into_iter().next().expect("one version"),
2225 );
2226 }
2227 if observations
2228 .iter()
2229 .any(|observation| observation.workspace_package)
2230 {
2231 discovery.workspace_packages.insert(identity.clone());
2232 }
2233 if observations
2234 .iter()
2235 .any(|observation| observation.private_package)
2236 {
2237 discovery.private_packages.insert(identity);
2238 }
2239 }
2240 Ok(())
2241}
2242
2243fn workspace_manifest_paths(root: &Path) -> Result<BTreeSet<PathBuf>> {
2244 let mut directories = BTreeSet::new();
2245 let mut found_workspace = false;
2246 let pnpm = root.join("pnpm-workspace.yaml");
2247 if pnpm.exists() {
2248 found_workspace = true;
2249 let text = std::fs::read_to_string(&pnpm).map_err(|error| Error::io(&pnpm, error))?;
2250 let value: serde_yaml::Value = serde_yaml::from_str(&text)?;
2251 expand_workspace_patterns(
2252 root,
2253 value["packages"]
2254 .as_sequence()
2255 .into_iter()
2256 .flatten()
2257 .filter_map(serde_yaml::Value::as_str),
2258 &mut directories,
2259 )?;
2260 }
2261 let package_json = root.join("package.json");
2262 if package_json.exists() {
2263 let text = std::fs::read_to_string(&package_json)
2264 .map_err(|error| Error::io(&package_json, error))?;
2265 let value: JsonValue = serde_json::from_str(&text)
2266 .map_err(|error| Error::Validation(format!("invalid package.json: {error}")))?;
2267 let workspaces = value["workspaces"]
2268 .as_array()
2269 .or_else(|| value["workspaces"]["packages"].as_array());
2270 if let Some(workspaces) = workspaces {
2271 found_workspace = true;
2272 expand_workspace_patterns(
2273 root,
2274 workspaces.iter().filter_map(JsonValue::as_str),
2275 &mut directories,
2276 )?;
2277 }
2278 }
2279 let cargo = root.join("Cargo.toml");
2280 if cargo.exists() {
2281 let text = std::fs::read_to_string(&cargo).map_err(|error| Error::io(&cargo, error))?;
2282 let document = text
2283 .parse::<toml_edit::DocumentMut>()
2284 .map_err(|error| Error::Validation(format!("invalid Cargo.toml: {error}")))?;
2285 if let Some(members) = document
2286 .get("workspace")
2287 .and_then(|workspace| workspace.get("members"))
2288 .and_then(toml_edit::Item::as_array)
2289 {
2290 found_workspace = true;
2291 expand_workspace_patterns(
2292 root,
2293 members.iter().filter_map(toml_edit::Value::as_str),
2294 &mut directories,
2295 )?;
2296 }
2297 }
2298 if !found_workspace {
2299 directories.insert(root.to_owned());
2300 }
2301 let mut paths = BTreeSet::new();
2302 for directory in directories {
2303 add_manifests_in_directory(&directory, &mut paths)?;
2304 }
2305 paths.retain(|path| !hard_excluded(root, path));
2306 remove_git_ignored(root, &mut paths)?;
2307 Ok(paths)
2308}
2309
2310fn expand_workspace_pattern(
2311 root: &Path,
2312 pattern: &str,
2313 directories: &mut BTreeSet<PathBuf>,
2314) -> Result<()> {
2315 let (excluded, pattern) = pattern
2316 .strip_prefix('!')
2317 .map_or((false, pattern), |pattern| (true, pattern));
2318 let absolute = root.join(pattern).to_string_lossy().to_string();
2319 for entry in glob(&absolute).map_err(|error| {
2320 Error::Validation(format!("invalid workspace pattern {pattern}: {error}"))
2321 })? {
2322 let path = entry
2323 .map_err(|error| Error::Validation(format!("workspace pattern failed: {error}")))?;
2324 if path.is_dir() {
2325 if excluded {
2326 directories.remove(&path);
2327 } else {
2328 directories.insert(path);
2329 }
2330 }
2331 }
2332 Ok(())
2333}
2334
2335fn expand_workspace_patterns<'a>(
2336 root: &Path,
2337 patterns: impl IntoIterator<Item = &'a str>,
2338 directories: &mut BTreeSet<PathBuf>,
2339) -> Result<()> {
2340 let patterns = patterns.into_iter().collect::<Vec<_>>();
2341 for excluded in [false, true] {
2342 for pattern in &patterns {
2343 if pattern.starts_with('!') == excluded {
2344 expand_workspace_pattern(root, pattern, directories)?;
2345 }
2346 }
2347 }
2348 Ok(())
2349}
2350
2351fn add_manifests_in_directory(directory: &Path, paths: &mut BTreeSet<PathBuf>) -> Result<()> {
2352 for name in [
2353 "package.json",
2354 "Cargo.toml",
2355 "pubspec.yaml",
2356 "pyproject.toml",
2357 "go.mod",
2358 DEVCONTAINER_FEATURE_MANIFEST,
2359 DEVCONTAINER_TEMPLATE_MANIFEST,
2360 ] {
2361 let path = directory.join(name);
2362 if path.is_file() {
2363 paths.insert(path);
2364 }
2365 }
2366 for entry in std::fs::read_dir(directory).map_err(|error| Error::io(directory, error))? {
2367 let path = entry.map_err(|error| Error::io(directory, error))?.path();
2368 if path
2369 .extension()
2370 .is_some_and(|extension| extension == "csproj")
2371 {
2372 paths.insert(path);
2373 }
2374 }
2375 Ok(())
2376}
2377
2378fn all_manifest_paths(root: &Path) -> Result<BTreeSet<PathBuf>> {
2379 let mut paths = WalkDir::new(root)
2380 .into_iter()
2381 .filter_entry(|entry| !hard_excluded(root, entry.path()))
2382 .filter_map(std::result::Result::ok)
2383 .filter(|entry| entry.file_type().is_file() && is_discoverable_manifest(entry.path()))
2384 .map(|entry| entry.into_path())
2385 .collect();
2386 remove_git_ignored(root, &mut paths)?;
2387 Ok(paths)
2388}
2389
2390fn hard_excluded(root: &Path, path: &Path) -> bool {
2391 path.strip_prefix(root)
2392 .unwrap_or(path)
2393 .components()
2394 .filter_map(|component| match component {
2395 std::path::Component::Normal(value) => value.to_str(),
2396 _ => None,
2397 })
2398 .any(|component| {
2399 matches!(
2400 component,
2401 ".git"
2402 | ".intentional"
2403 | "node_modules"
2404 | "target"
2405 | ".pnpm-store"
2406 | ".yarn"
2407 | ".npm"
2408 | ".cache"
2409 | ".venv"
2410 | "venv"
2411 | "__pycache__"
2412 | ".tox"
2413 | ".nox"
2414 | ".pytest_cache"
2415 | ".mypy_cache"
2416 | ".ruff_cache"
2417 | ".dart_tool"
2418 | ".pub-cache"
2419 | "obj"
2420 )
2421 })
2422}
2423
2424fn remove_git_ignored(root: &Path, paths: &mut BTreeSet<PathBuf>) -> Result<()> {
2425 if paths.is_empty() {
2426 return Ok(());
2427 }
2428 let relative_paths = paths
2429 .iter()
2430 .map(|path| path.strip_prefix(root).unwrap_or(path).to_owned())
2431 .collect::<Vec<_>>();
2432 let mut input = Vec::new();
2433 for path in &relative_paths {
2434 input.extend_from_slice(path.to_string_lossy().as_bytes());
2435 input.push(0);
2436 }
2437 let mut child = Command::new("git")
2438 .args(["check-ignore", "--stdin", "-z"])
2439 .current_dir(root)
2440 .stdin(Stdio::piped())
2441 .stdout(Stdio::piped())
2442 .stderr(Stdio::piped())
2443 .spawn()
2444 .map_err(|error| Error::io(root, error))?;
2445 let mut stdin = child.stdin.take().expect("piped git check-ignore stdin");
2446 let writer = std::thread::spawn(move || stdin.write_all(&input));
2447 let output = child
2448 .wait_with_output()
2449 .map_err(|error| Error::io(root, error))?;
2450 writer
2451 .join()
2452 .map_err(|_| Error::Validation("git check-ignore input writer panicked".to_owned()))?
2453 .map_err(|error| Error::io(root, error))?;
2454 if !matches!(output.status.code(), Some(0 | 1)) {
2455 return Err(Error::Validation(format!(
2456 "git check-ignore failed: {}",
2457 String::from_utf8_lossy(&output.stderr).trim()
2458 )));
2459 }
2460 let ignored = output
2461 .stdout
2462 .split(|byte| *byte == 0)
2463 .filter(|path| !path.is_empty())
2464 .map(|path| PathBuf::from(String::from_utf8_lossy(path).into_owned()))
2465 .collect::<BTreeSet<_>>();
2466 paths.retain(|path| {
2467 let relative = path.strip_prefix(root).unwrap_or(path);
2468 !ignored.contains(relative)
2469 });
2470 Ok(())
2471}
2472
2473fn detector_for(adapter: Adapter) -> &'static str {
2474 match adapter {
2475 Adapter::Npm => "npm-package",
2476 Adapter::Cargo => "cargo-package",
2477 Adapter::Go => "go-module",
2478 Adapter::Python => "python-project",
2479 Adapter::Msbuild => "msbuild-project",
2480 Adapter::Pub => "dart-package",
2481 Adapter::Json | Adapter::Toml | Adapter::Yaml => {
2482 unreachable!("generic adapters are not discovered")
2483 }
2484 }
2485}
2486
2487fn devcontainer_detector_for(path: &Path) -> Option<&'static str> {
2488 match path.file_name()?.to_str()? {
2489 DEVCONTAINER_FEATURE_MANIFEST => Some("devcontainer-feature"),
2490 DEVCONTAINER_TEMPLATE_MANIFEST => Some("devcontainer-template"),
2491 _ => None,
2492 }
2493}
2494
2495fn is_discoverable_manifest(path: &Path) -> bool {
2496 adapter_for(path).is_some() || devcontainer_detector_for(path).is_some()
2497}
2498
2499fn devcontainer_candidate(root: &Path, path: &Path) -> Result<Option<DiscoveryCandidate>> {
2500 let Some(detector) = devcontainer_detector_for(path) else {
2501 return Ok(None);
2502 };
2503 let relative = path
2504 .strip_prefix(root)
2505 .map_err(|error| Error::Validation(format!("manifest is outside workspace: {error}")))?
2506 .to_owned();
2507 let bytes = std::fs::read(path).map_err(|error| Error::io(path, error))?;
2508 let source = SourceEvidence {
2509 path: relative.clone(),
2510 digest: format!("sha256:{:x}", Sha256::digest(&bytes)),
2511 lines: Vec::new(),
2512 };
2513 let mut diagnostics = Vec::new();
2514 let value = match std::str::from_utf8(&bytes) {
2515 Ok(text) => serde_json::from_str::<JsonValue>(text).map_err(|error| error.to_string()),
2516 Err(error) => Err(error.to_string()),
2517 };
2518 let (native_identity, raw_version, projection, tag) = match value {
2519 Ok(value) => {
2520 let native_identity = non_empty_json_string(&value["id"]);
2521 if native_identity.is_none() {
2522 diagnostics.push(extraction_diagnostic(
2523 "identity-extraction",
2524 "devcontainer-id-unreadable",
2525 format!(
2526 "{} has no non-empty string id; the detector inspects only id for identity.",
2527 relative.display()
2528 ),
2529 &source,
2530 ));
2531 }
2532
2533 let version = non_empty_json_string(&value["version"]);
2534 if version.is_none() {
2535 diagnostics.push(extraction_diagnostic(
2536 "version-extraction",
2537 "devcontainer-version-unreadable",
2538 format!(
2539 "{} has no non-empty string version; the detector inspects only version for version evidence.",
2540 relative.display()
2541 ),
2542 &source,
2543 ));
2544 }
2545 let valid_version = version.as_deref().is_some_and(|version| {
2546 if let Err(error) = Version::parse(version) {
2547 diagnostics.push(extraction_diagnostic(
2548 "version-semver",
2549 "devcontainer-version-not-semver",
2550 format!(
2551 "{} version {version:?} is not Semantic Versioning 2.0.0: {error}.",
2552 relative.display()
2553 ),
2554 &source,
2555 ));
2556 false
2557 } else {
2558 true
2559 }
2560 });
2561 let raw_version = version.map(|value| RawVersionEvidence {
2562 value,
2563 evidence: vec![source.clone()],
2564 });
2565 let projection = valid_version.then(|| CandidateProjectionSuggestion {
2566 adapter: Adapter::Json,
2567 path: relative.clone(),
2568 mode: ProjectionMode::Committed,
2569 pointer: Some("/version".to_owned()),
2570 });
2571 let tag = native_identity.as_ref().map(|_| CandidateTagSuggestion {
2572 id: "primary".to_owned(),
2573 role: TagRole::Primary,
2574 template: "{id}@{version}".to_owned(),
2575 });
2576 (native_identity, raw_version, projection, tag)
2577 }
2578 Err(error) => {
2579 diagnostics.push(extraction_diagnostic(
2580 "manifest-extraction",
2581 "devcontainer-json-unreadable",
2582 format!(
2583 "{} could not be read as JSON for id and version extraction: {error}.",
2584 relative.display()
2585 ),
2586 &source,
2587 ));
2588 (None, None, None, None)
2589 }
2590 };
2591
2592 Ok(Some(DiscoveryCandidate {
2593 id: DiscoveryCandidate::stable_id(detector, &relative)?,
2594 detector: detector.to_owned(),
2595 path: relative,
2596 evidence: vec![source],
2597 native_identity,
2598 raw_version,
2599 projection,
2600 tag,
2601 diagnostics,
2602 resolution: None,
2603 }))
2604}
2605
2606fn non_empty_json_string(value: &JsonValue) -> Option<String> {
2607 value
2608 .as_str()
2609 .filter(|value| !value.is_empty())
2610 .map(str::to_owned)
2611}
2612
2613fn extraction_diagnostic(
2614 id: &str,
2615 code: &str,
2616 message: String,
2617 evidence: &SourceEvidence,
2618) -> ExtractionDiagnostic {
2619 ExtractionDiagnostic {
2620 id: id.to_owned(),
2621 code: code.to_owned(),
2622 message,
2623 evidence: vec![evidence.clone()],
2624 }
2625}
2626
2627fn adapter_for(path: &Path) -> Option<Adapter> {
2628 let name = path.file_name()?.to_str()?;
2629 match name {
2630 "package.json" => Some(Adapter::Npm),
2631 "Cargo.toml" => Some(Adapter::Cargo),
2632 "pubspec.yaml" => Some(Adapter::Pub),
2633 "pyproject.toml" => Some(Adapter::Python),
2634 "go.mod" => Some(Adapter::Go),
2635 name if name.ends_with(".csproj") => Some(Adapter::Msbuild),
2636 _ => None,
2637 }
2638}
2639
2640fn is_project_manifest(path: &Path, adapter: Adapter) -> Result<bool> {
2641 if adapter != Adapter::Cargo {
2642 return Ok(true);
2643 }
2644 let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2645 let document = text
2646 .parse::<toml_edit::DocumentMut>()
2647 .map_err(|error| Error::Validation(format!("invalid Cargo.toml: {error}")))?;
2648 Ok(document.get("package").is_some())
2649}
2650
2651fn manifest_identity(path: &Path, adapter: Adapter) -> Result<(String, Option<String>)> {
2652 let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2653 match adapter {
2654 Adapter::Npm => {
2655 let value: JsonValue = serde_json::from_str(&text).map_err(|error| {
2656 Error::Validation(format!("invalid {}: {error}", path.display()))
2657 })?;
2658 Ok((
2659 required_string(&value["name"], path, "name")?,
2660 value["version"].as_str().map(str::to_owned),
2661 ))
2662 }
2663 Adapter::Cargo => {
2664 let document = text.parse::<toml_edit::DocumentMut>().map_err(|error| {
2665 Error::Validation(format!("invalid {}: {error}", path.display()))
2666 })?;
2667 let package = document.get("package").ok_or_else(|| {
2668 Error::Validation(format!("{} has no package table", path.display()))
2669 })?;
2670 let name = package
2671 .get("name")
2672 .and_then(toml_edit::Item::as_str)
2673 .ok_or_else(|| {
2674 Error::Validation(format!("{} has no package.name", path.display()))
2675 })?;
2676 let version = package
2677 .get("version")
2678 .and_then(toml_edit::Item::as_str)
2679 .map(str::to_owned);
2680 Ok((name.to_owned(), version))
2681 }
2682 Adapter::Pub => {
2683 let value: serde_yaml::Value = serde_yaml::from_str(&text)?;
2684 let name = value["name"]
2685 .as_str()
2686 .ok_or_else(|| Error::Validation(format!("{} has no name", path.display())))?;
2687 Ok((
2688 name.to_owned(),
2689 value["version"].as_str().map(str::to_owned),
2690 ))
2691 }
2692 Adapter::Python => {
2693 let document = text.parse::<toml_edit::DocumentMut>().map_err(|error| {
2694 Error::Validation(format!("invalid {}: {error}", path.display()))
2695 })?;
2696 let project = document.get("project").ok_or_else(|| {
2697 Error::Validation(format!("{} has no project table", path.display()))
2698 })?;
2699 let name = project
2700 .get("name")
2701 .and_then(toml_edit::Item::as_str)
2702 .ok_or_else(|| {
2703 Error::Validation(format!("{} has no project.name", path.display()))
2704 })?;
2705 let version = project
2706 .get("version")
2707 .and_then(toml_edit::Item::as_str)
2708 .map(str::to_owned);
2709 Ok((name.to_owned(), version))
2710 }
2711 Adapter::Go => {
2712 let name = text
2713 .lines()
2714 .find_map(|line| line.trim().strip_prefix("module "))
2715 .ok_or_else(|| {
2716 Error::Validation(format!("{} has no module directive", path.display()))
2717 })?;
2718 Ok((name.to_owned(), None))
2719 }
2720 Adapter::Msbuild => {
2721 let name = xml_element(&text, "PackageId")
2722 .or_else(|| xml_element(&text, "AssemblyName"))
2723 .or_else(|| {
2724 path.file_stem()
2725 .and_then(|value| value.to_str())
2726 .map(str::to_owned)
2727 })
2728 .ok_or_else(|| {
2729 Error::Validation(format!("{} has no package identity", path.display()))
2730 })?;
2731 Ok((name, xml_element(&text, "Version")))
2732 }
2733 Adapter::Json | Adapter::Toml | Adapter::Yaml => {
2734 unreachable!("generic adapters are not discovered")
2735 }
2736 }
2737}
2738
2739fn npm_manifest_is_private(path: &Path) -> Result<bool> {
2740 let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2741 let value: JsonValue = serde_json::from_str(&text)
2742 .map_err(|error| Error::Validation(format!("invalid {}: {error}", path.display())))?;
2743 Ok(value["private"].as_bool() == Some(true))
2744}
2745
2746fn required_string(value: &JsonValue, path: &Path, field: &str) -> Result<String> {
2747 value
2748 .as_str()
2749 .map(str::to_owned)
2750 .ok_or_else(|| Error::Validation(format!("{} has no string {field}", path.display())))
2751}
2752
2753fn xml_element(text: &str, name: &str) -> Option<String> {
2754 let start = format!("<{name}>");
2755 let end = format!("</{name}>");
2756 text.split_once(&start)?
2757 .1
2758 .split_once(&end)
2759 .map(|(value, _)| value.trim().to_owned())
2760}
2761
2762fn derive_npm_dependencies(root: &Path, config: &mut Config) -> Result<Vec<NpmDependencyEdge>> {
2763 let ids = config
2764 .release_units
2765 .keys()
2766 .cloned()
2767 .collect::<BTreeSet<_>>();
2768 let mut edges = Vec::new();
2769 for (id, release_unit) in &mut config.release_units {
2770 let npm_manifests = release_unit
2771 .projections
2772 .iter()
2773 .filter(|projection| projection.adapter == Adapter::Npm)
2774 .map(|projection| projection_workspace_path(release_unit, projection))
2775 .collect::<Vec<_>>();
2776 if npm_manifests.is_empty() {
2777 continue;
2778 }
2779 let mut dependencies = BTreeSet::new();
2780 for manifest in npm_manifests {
2781 let path = root.join(&manifest);
2782 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
2783 let value: JsonValue = serde_json::from_str(&text).map_err(|error| {
2784 Error::Validation(format!("invalid {}: {error}", path.display()))
2785 })?;
2786 for (group, kind) in [
2787 ("dependencies", NpmDependencyKind::Dependency),
2788 ("optionalDependencies", NpmDependencyKind::Optional),
2789 ("peerDependencies", NpmDependencyKind::Peer),
2790 ("devDependencies", NpmDependencyKind::Dev),
2791 ] {
2792 if let Some(entries) = value[group].as_object() {
2793 for (dependency, range) in entries {
2794 if !ids.contains(dependency) {
2795 continue;
2796 }
2797 if kind != NpmDependencyKind::Dev {
2798 dependencies.insert(dependency.clone());
2799 }
2800 edges.push(NpmDependencyEdge {
2801 dependent: id.clone(),
2802 dependency: dependency.clone(),
2803 kind,
2804 range: range.as_str().unwrap_or_default().to_owned(),
2805 manifest: manifest.clone(),
2806 });
2807 }
2808 }
2809 }
2810 }
2811 release_unit.depends_on = dependencies.into_iter().collect();
2812 }
2813 edges.sort_by(|left, right| {
2814 (
2815 &left.dependent,
2816 &left.dependency,
2817 left.kind as u8,
2818 &left.range,
2819 &left.manifest,
2820 )
2821 .cmp(&(
2822 &right.dependent,
2823 &right.dependency,
2824 right.kind as u8,
2825 &right.range,
2826 &right.manifest,
2827 ))
2828 });
2829 Ok(edges)
2830}
2831
2832fn retain_materialized_npm_dependencies(config: &mut Config, edges: &mut Vec<NpmDependencyEdge>) {
2833 edges.retain(|edge| {
2834 config
2835 .release_units
2836 .get(&edge.dependent)
2837 .is_some_and(|unit| {
2838 unit.projections.iter().any(|projection| {
2839 projection.adapter == Adapter::Npm
2840 && projection_workspace_path(unit, projection) == edge.manifest
2841 })
2842 })
2843 && config.release_units.contains_key(&edge.dependency)
2844 });
2845 for (id, unit) in &mut config.release_units {
2846 unit.depends_on = edges
2847 .iter()
2848 .filter(|edge| edge.dependent == *id && edge.kind != NpmDependencyKind::Dev)
2849 .map(|edge| edge.dependency.clone())
2850 .collect::<BTreeSet<_>>()
2851 .into_iter()
2852 .collect();
2853 }
2854}
2855
2856fn load_changesets_intents(root: &Path) -> Result<Vec<ConvertedIntent>> {
2857 let directory = root.join(".changeset");
2858 let mut paths = std::fs::read_dir(&directory)
2859 .map_err(|error| Error::io(&directory, error))?
2860 .filter_map(std::result::Result::ok)
2861 .map(|entry| entry.path())
2862 .filter(|path| path.extension().is_some_and(|extension| extension == "md"))
2863 .filter(|path| path.file_name().is_none_or(|name| name != "README.md"))
2864 .collect::<Vec<_>>();
2865 paths.sort();
2866 paths
2867 .into_iter()
2868 .map(|path| {
2869 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
2870 let rest = text.strip_prefix("---\n").ok_or_else(|| {
2871 Error::Validation(format!("{} has no Changesets frontmatter", path.display()))
2872 })?;
2873 let (frontmatter, message) = rest.split_once("\n---\n").ok_or_else(|| {
2874 Error::Validation(format!(
2875 "{} has unterminated Changesets frontmatter",
2876 path.display()
2877 ))
2878 })?;
2879 let packages = serde_yaml::from_str(frontmatter)?;
2880 let id = path
2881 .file_stem()
2882 .and_then(|value| value.to_str())
2883 .ok_or_else(|| {
2884 Error::Validation(format!("invalid Changesets filename {}", path.display()))
2885 })?
2886 .to_owned();
2887 Ok(ConvertedIntent {
2888 target: PathBuf::from(crate::intent::INTENTS_PATH).join(format!("{id}.md")),
2889 source: path.strip_prefix(root).unwrap_or(&path).to_owned(),
2890 id,
2891 release_units: packages,
2892 message: message.trim().to_owned(),
2893 })
2894 })
2895 .collect()
2896}
2897
2898fn parse_groups(value: &JsonValue) -> Result<Vec<Vec<String>>> {
2899 value
2900 .as_array()
2901 .into_iter()
2902 .flatten()
2903 .map(|group| {
2904 group
2905 .as_array()
2906 .ok_or_else(|| {
2907 Error::Validation("Changesets release group must be an array".to_owned())
2908 })?
2909 .iter()
2910 .map(|member| {
2911 member.as_str().map(str::to_owned).ok_or_else(|| {
2912 Error::Validation("Changesets group member must be a string".to_owned())
2913 })
2914 })
2915 .collect()
2916 })
2917 .collect()
2918}
2919
2920fn collect_json_strings(value: &JsonValue, output: &mut BTreeSet<String>) {
2921 match value {
2922 JsonValue::String(value) => {
2923 output.insert(value.clone());
2924 }
2925 JsonValue::Array(values) => {
2926 for value in values {
2927 collect_json_strings(value, output);
2928 }
2929 }
2930 _ => {}
2931 }
2932}
2933
2934fn changesets_private_package_settings(value: &JsonValue) -> Option<(bool, bool)> {
2935 match value {
2936 JsonValue::Bool(enabled) => Some((*enabled, *enabled)),
2937 JsonValue::Object(settings) => Some((
2938 settings
2939 .get("version")
2940 .and_then(JsonValue::as_bool)
2941 .unwrap_or(true),
2942 settings
2943 .get("tag")
2944 .and_then(JsonValue::as_bool)
2945 .unwrap_or(true),
2946 )),
2947 _ => None,
2948 }
2949}
2950
2951fn remap_groups(groups: &mut Vec<Vec<String>>, identity_map: &BTreeMap<String, String>) {
2952 for group in groups.iter_mut() {
2953 for release_unit in group.iter_mut() {
2954 if let Some(target) = identity_map.get(release_unit) {
2955 *release_unit = target.clone();
2956 }
2957 }
2958 group.sort();
2959 group.dedup();
2960 }
2961 groups.retain(|group| group.len() > 1);
2962}
2963
2964fn remap_converted_intents(
2965 intents: &mut [ConvertedIntent],
2966 identity_map: &BTreeMap<String, String>,
2967 config: &Config,
2968) -> Result<()> {
2969 for intent in intents {
2970 let mut release_units = BTreeMap::<String, Bump>::new();
2971 for (id, bump) in std::mem::take(&mut intent.release_units) {
2972 let logical_id = identity_map.get(&id).cloned().unwrap_or(id);
2973 if !config.release_units.contains_key(&logical_id) {
2974 return Err(Error::Validation(format!(
2975 "Changesets intent {} references release unit {logical_id}, which has no Intentional release-unit identity",
2976 intent.id
2977 )));
2978 }
2979 release_units
2980 .entry(logical_id)
2981 .and_modify(|existing| *existing = (*existing).max(bump))
2982 .or_insert(bump);
2983 }
2984 intent.release_units = release_units;
2985 }
2986 Ok(())
2987}
2988
2989fn scan_changesets_integrations(root: &Path) -> Result<Vec<SourceEvidence>> {
2990 let mut findings = Vec::new();
2991 for entry in WalkDir::new(root)
2992 .into_iter()
2993 .filter_entry(|entry| !hard_excluded(root, entry.path()))
2994 .filter_map(std::result::Result::ok)
2995 .filter(|entry| entry.file_type().is_file())
2996 {
2997 let path = entry.path();
2998 let relative = path.strip_prefix(root).unwrap_or(path);
2999 if relative.starts_with(".changeset") || relative.starts_with(".intentional") {
3000 continue;
3001 }
3002 let relevant = relative == Path::new("package.json")
3003 || relative == Path::new("pnpm-lock.yaml")
3004 || relative.starts_with(".github")
3005 || relative.starts_with("scripts")
3006 || relative
3007 .components()
3008 .any(|component| component.as_os_str().to_string_lossy().contains("test"));
3009 if !relevant {
3010 continue;
3011 }
3012 let Ok(text) = std::fs::read_to_string(path) else {
3013 continue;
3014 };
3015 let lines = text
3016 .lines()
3017 .enumerate()
3018 .filter(|(_, line)| line.to_ascii_lowercase().contains("changeset"))
3019 .map(|(index, _)| index + 1)
3020 .collect::<Vec<_>>();
3021 if !lines.is_empty() {
3022 findings.push(evidence(root, relative, lines)?);
3023 }
3024 }
3025 findings.sort();
3026 Ok(findings)
3027}
3028
3029fn scan_external_release_evidence(root: &Path) -> Result<Vec<SourceEvidence>> {
3030 let mut findings = Vec::new();
3031 for entry in WalkDir::new(root)
3032 .into_iter()
3033 .filter_entry(|entry| !hard_excluded(root, entry.path()))
3034 .filter_map(std::result::Result::ok)
3035 .filter(|entry| entry.file_type().is_file())
3036 {
3037 let path = entry.path();
3038 let relative = path.strip_prefix(root).unwrap_or(path);
3039 let is_release_file = relative.file_name().is_some_and(|name| {
3040 name.to_string_lossy()
3041 .to_ascii_lowercase()
3042 .contains("release")
3043 });
3044 if (relative.starts_with("scripts") || relative.starts_with(".github")) && is_release_file {
3045 findings.push(evidence(root, relative, Vec::new())?);
3046 }
3047 }
3048 findings.sort();
3049 Ok(findings)
3050}
3051
3052fn release_tool_proxy_diagnostics(
3053 root: &Path,
3054 candidates: &[DiscoveryCandidate],
3055 referenced_names: &BTreeSet<String>,
3056) -> Result<Vec<InitDiagnostic>> {
3057 let workspace_paths = workspace_manifest_paths(root)?;
3058 let mut diagnostics = Vec::new();
3059 for candidate in candidates {
3060 let Some(native_identity) = candidate.native_identity.as_deref() else {
3061 continue;
3062 };
3063 let Some(projection) = candidate.projection.as_ref() else {
3064 continue;
3065 };
3066 if projection.adapter != Adapter::Npm {
3067 continue;
3068 }
3069 let workspace_member = workspace_paths.contains(&root.join(&candidate.path));
3070 if !referenced_names.contains(native_identity) && !workspace_member {
3071 continue;
3072 }
3073 let Some(CandidateResolution::Projection { release_unit, .. }) =
3074 candidate.resolution.as_ref()
3075 else {
3076 continue;
3077 };
3078 if release_unit == native_identity {
3079 continue;
3080 }
3081 let target_candidates = candidates
3082 .iter()
3083 .filter(|target| target.id != candidate.id)
3084 .filter(|target| {
3085 target.projection.as_ref().is_some_and(|projection| {
3086 projection.adapter != Adapter::Npm
3087 && matches!(
3088 target.resolution.as_ref(),
3089 Some(CandidateResolution::Projection {
3090 release_unit: target_release_unit,
3091 ..
3092 }) if target_release_unit == release_unit
3093 )
3094 })
3095 })
3096 .collect::<Vec<_>>();
3097 if target_candidates.is_empty() {
3098 continue;
3099 }
3100
3101 let mut source_references = changesets_identity_evidence(root, native_identity)?;
3102 if source_references.is_empty() && workspace_member {
3103 source_references.push(evidence(root, Path::new(CHANGESETS_CONFIG), Vec::new())?);
3104 source_references.extend(workspace_declaration_evidence(root)?);
3105 source_references.sort();
3106 source_references.dedup();
3107 }
3108 if source_references.is_empty() {
3109 continue;
3110 }
3111 let (private, responsibilities) = npm_manifest_responsibilities(root, &candidate.path)?;
3112 let mut supporting_evidence = candidate.evidence.clone();
3113 supporting_evidence.extend(source_references);
3114 for target in &target_candidates {
3115 supporting_evidence.extend(target.evidence.clone());
3116 }
3117 supporting_evidence.sort();
3118 supporting_evidence.dedup();
3119
3120 let mut contradictory_evidence = Vec::new();
3121 if !private || !responsibilities.is_empty() {
3122 contradictory_evidence.extend(candidate.evidence.clone());
3123 }
3124 contradictory_evidence.extend(non_displaced_manifest_references(
3125 root,
3126 &candidate.path,
3127 native_identity,
3128 )?);
3129 contradictory_evidence.sort();
3130 contradictory_evidence.dedup();
3131
3132 let recommended =
3133 (private && responsibilities.is_empty() && contradictory_evidence.is_empty())
3134 .then(|| "remove".to_owned());
3135 let mut evidence = supporting_evidence.clone();
3136 evidence.extend(contradictory_evidence.clone());
3137 evidence.sort();
3138 evidence.dedup();
3139 let responsibility_message = if responsibilities.is_empty() {
3140 "no independent npm responsibility keys were observed".to_owned()
3141 } else {
3142 format!(
3143 "npm responsibility keys were observed: {}",
3144 responsibilities.join(", ")
3145 )
3146 };
3147 diagnostics.push(InitDiagnostic {
3148 id: format!("release-tool-proxy-disposition:{}", candidate.id),
3149 code: "release-tool-proxy-disposition".to_owned(),
3150 message: format!(
3151 "Choose whether {} remains an npm projection of {release_unit} or is removed during takeover. Its exact source-tool identity maps to a non-npm projection, and {responsibility_message}.",
3152 candidate.path.display()
3153 ),
3154 evidence,
3155 choices: vec!["retain".to_owned(), "remove".to_owned()],
3156 recommended,
3157 resolution: None,
3158 verified: false,
3159 invalidated_resolution: false,
3160 supporting_evidence,
3161 contradictory_evidence,
3162 uncertainty: Some(
3163 "This is a best-effort structural assessment, not proof that the manifest is disposable. Descriptions, comments, prose, and semantic keyword matches are not evidence."
3164 .to_owned(),
3165 ),
3166 });
3167 }
3168 diagnostics.sort_by(|left, right| left.id.cmp(&right.id));
3169 Ok(diagnostics)
3170}
3171
3172fn npm_manifest_responsibilities(root: &Path, relative: &Path) -> Result<(bool, Vec<String>)> {
3173 let path = root.join(relative);
3174 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
3175 let value: JsonValue = serde_json::from_str(&text)
3176 .map_err(|error| Error::Validation(format!("invalid package.json: {error}")))?;
3177 let object = value.as_object().ok_or_else(|| {
3178 Error::Validation(format!("{} must contain a JSON object", relative.display()))
3179 })?;
3180 let private = object
3181 .get("private")
3182 .and_then(JsonValue::as_bool)
3183 .unwrap_or(false);
3184 let responsibility_keys = [
3185 "bin",
3186 "browser",
3187 "bundledDependencies",
3188 "cpu",
3189 "dependencies",
3190 "devDependencies",
3191 "engines",
3192 "exports",
3193 "files",
3194 "main",
3195 "module",
3196 "optionalDependencies",
3197 "os",
3198 "peerDependencies",
3199 "publishConfig",
3200 "repository",
3201 "scripts",
3202 "types",
3203 "typings",
3204 "workspaces",
3205 ];
3206 let responsibilities = responsibility_keys
3207 .into_iter()
3208 .filter(|key| object.get(*key).is_some_and(json_value_has_content))
3209 .map(str::to_owned)
3210 .collect();
3211 Ok((private, responsibilities))
3212}
3213
3214fn json_value_has_content(value: &JsonValue) -> bool {
3215 match value {
3216 JsonValue::Null => false,
3217 JsonValue::Bool(value) => *value,
3218 JsonValue::Number(_) => true,
3219 JsonValue::String(value) => !value.is_empty(),
3220 JsonValue::Array(values) => !values.is_empty(),
3221 JsonValue::Object(values) => !values.is_empty(),
3222 }
3223}
3224
3225fn changesets_identity_evidence(root: &Path, identity: &str) -> Result<Vec<SourceEvidence>> {
3226 let mut findings = Vec::new();
3227 let config_path = PathBuf::from(CHANGESETS_CONFIG);
3228 let config_text = std::fs::read_to_string(root.join(&config_path))
3229 .map_err(|error| Error::io(root.join(&config_path), error))?;
3230 let config: JsonValue = serde_json::from_str(&config_text)
3231 .map_err(|error| Error::Validation(format!("invalid Changesets config: {error}")))?;
3232 if json_contains_exact_string(&config, identity) {
3233 findings.push(evidence(root, &config_path, Vec::new())?);
3234 }
3235 for entry in WalkDir::new(root.join(".changeset"))
3236 .min_depth(1)
3237 .max_depth(1)
3238 .into_iter()
3239 .filter_map(std::result::Result::ok)
3240 .filter(|entry| entry.file_type().is_file())
3241 .filter(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("md"))
3242 .filter(|entry| entry.file_name() != "README.md")
3243 {
3244 let text = std::fs::read_to_string(entry.path())
3245 .map_err(|error| Error::io(entry.path(), error))?;
3246 let Some(frontmatter) = text
3247 .strip_prefix("---\n")
3248 .and_then(|text| text.split_once("\n---"))
3249 .map(|(frontmatter, _)| frontmatter)
3250 else {
3251 continue;
3252 };
3253 let values: BTreeMap<String, Bump> = serde_yaml::from_str(frontmatter)?;
3254 if values.contains_key(identity) {
3255 let relative = entry
3256 .path()
3257 .strip_prefix(root)
3258 .unwrap_or(entry.path())
3259 .to_owned();
3260 findings.push(evidence(root, &relative, Vec::new())?);
3261 }
3262 }
3263 findings.sort();
3264 findings.dedup();
3265 Ok(findings)
3266}
3267
3268fn json_contains_exact_string(value: &JsonValue, expected: &str) -> bool {
3269 match value {
3270 JsonValue::String(value) => value == expected,
3271 JsonValue::Array(values) => values
3272 .iter()
3273 .any(|value| json_contains_exact_string(value, expected)),
3274 JsonValue::Object(values) => values
3275 .iter()
3276 .any(|(key, value)| key == expected || json_contains_exact_string(value, expected)),
3277 JsonValue::Null | JsonValue::Bool(_) | JsonValue::Number(_) => false,
3278 }
3279}
3280
3281fn workspace_declaration_evidence(root: &Path) -> Result<Vec<SourceEvidence>> {
3282 ["pnpm-workspace.yaml", "package.json", "Cargo.toml"]
3283 .into_iter()
3284 .map(PathBuf::from)
3285 .filter(|path| root.join(path).is_file())
3286 .map(|path| evidence(root, &path, Vec::new()))
3287 .collect()
3288}
3289
3290fn non_displaced_manifest_references(
3291 root: &Path,
3292 candidate_path: &Path,
3293 native_identity: &str,
3294) -> Result<Vec<SourceEvidence>> {
3295 let path_text = candidate_path.to_string_lossy().replace('\\', "/");
3296 let mut findings = Vec::new();
3297 let mut paths = WalkDir::new(root)
3298 .into_iter()
3299 .filter_entry(|entry| !hard_excluded(root, entry.path()))
3300 .filter_map(std::result::Result::ok)
3301 .filter(|entry| entry.file_type().is_file())
3302 .map(|entry| entry.into_path())
3303 .collect::<BTreeSet<_>>();
3304 remove_git_ignored(root, &mut paths)?;
3305 for path in paths {
3306 let relative = path.strip_prefix(root).unwrap_or(&path);
3307 if relative == candidate_path
3308 || relative.starts_with(".changeset")
3309 || relative.starts_with(".intentional")
3310 || is_prose_path(relative)
3311 {
3312 continue;
3313 }
3314 let Ok(text) = std::fs::read_to_string(&path) else {
3315 continue;
3316 };
3317 let lines = text
3318 .lines()
3319 .enumerate()
3320 .filter(|(_, line)| structural_line_references_path(line, &path_text))
3321 .map(|(index, _)| index + 1)
3322 .collect::<Vec<_>>();
3323 let has_dependency_reference = relative.file_name().and_then(|name| name.to_str())
3324 == Some("package.json")
3325 && serde_json::from_str::<JsonValue>(&text).is_ok_and(|value| {
3326 [
3327 "dependencies",
3328 "devDependencies",
3329 "optionalDependencies",
3330 "peerDependencies",
3331 ]
3332 .iter()
3333 .any(|key| {
3334 value[*key]
3335 .as_object()
3336 .is_some_and(|dependencies| dependencies.contains_key(native_identity))
3337 })
3338 });
3339 if !lines.is_empty() || has_dependency_reference {
3340 findings.push(evidence(root, relative, lines)?);
3341 }
3342 }
3343 findings.sort();
3344 findings.dedup();
3345 Ok(findings)
3346}
3347
3348fn structural_line_references_path(line: &str, path: &str) -> bool {
3349 let normalized = line.replace('\\', "/");
3350 let Some(reference_offset) = normalized.find(path) else {
3351 return false;
3352 };
3353 let trimmed = normalized.trim_start();
3354 if trimmed.starts_with("//")
3355 || trimmed.starts_with('#')
3356 || trimmed.starts_with("/*")
3357 || trimmed.starts_with("<!--")
3358 || trimmed == "*"
3359 || trimmed.starts_with("* ")
3360 {
3361 return false;
3362 }
3363 let before_reference = &normalized[..reference_offset];
3364 if comment_starts_before_reference(before_reference) {
3365 return false;
3366 }
3367 let Some((field, _)) = trimmed.split_once(':') else {
3368 return true;
3369 };
3370 let field = field
3371 .trim()
3372 .trim_matches(|character| matches!(character, '"' | '\''))
3373 .to_ascii_lowercase();
3374 !matches!(
3375 field.as_str(),
3376 "$comment"
3377 | "comment"
3378 | "comments"
3379 | "description"
3380 | "message"
3381 | "note"
3382 | "notes"
3383 | "summary"
3384 | "title"
3385 )
3386}
3387
3388fn comment_starts_before_reference(line: &str) -> bool {
3389 let bytes = line.as_bytes();
3390 let mut quote = None;
3391 let mut escaped = false;
3392 let mut index = 0;
3393 while index < bytes.len() {
3394 let byte = bytes[index];
3395 if let Some(expected) = quote {
3396 if escaped {
3397 escaped = false;
3398 } else if byte == b'\\' {
3399 escaped = true;
3400 } else if byte == expected {
3401 quote = None;
3402 }
3403 index += 1;
3404 continue;
3405 }
3406 if matches!(byte, b'\'' | b'"') {
3407 quote = Some(byte);
3408 index += 1;
3409 continue;
3410 }
3411 let previous_is_boundary =
3412 index == 0 || bytes[index.saturating_sub(1)].is_ascii_whitespace();
3413 if byte == b'#' && previous_is_boundary {
3414 return true;
3415 }
3416 if bytes[index..].starts_with(b"<!--") {
3417 return true;
3418 }
3419 if bytes[index..].starts_with(b"/*")
3420 && bytes
3421 .get(index + 2)
3422 .is_none_or(|next| next.is_ascii_whitespace())
3423 {
3424 return true;
3425 }
3426 if bytes[index..].starts_with(b"//")
3427 && bytes
3428 .get(index + 2)
3429 .is_none_or(|next| next.is_ascii_whitespace())
3430 {
3431 return true;
3432 }
3433 index += 1;
3434 }
3435 false
3436}
3437
3438fn is_prose_path(path: &Path) -> bool {
3439 matches!(
3440 path.extension().and_then(|extension| extension.to_str()),
3441 Some("md" | "mdx" | "rst" | "txt")
3442 )
3443}
3444
3445fn reconcile_diagnostics(
3446 root: &Path,
3447 current: &mut Vec<InitDiagnostic>,
3448 previous: Option<&InitPlan>,
3449 candidates: &[DiscoveryCandidate],
3450) -> Result<()> {
3451 let Some(previous) = previous else {
3452 return Ok(());
3453 };
3454 let previous_by_id = previous
3455 .diagnostics
3456 .iter()
3457 .map(|diagnostic| (diagnostic.id.as_str(), diagnostic))
3458 .collect::<BTreeMap<_, _>>();
3459 for diagnostic in current.iter_mut() {
3460 let Some(old) = previous_by_id.get(diagnostic.id.as_str()) else {
3461 continue;
3462 };
3463 if old.evidence == diagnostic.evidence {
3464 diagnostic.resolution = old.resolution.clone();
3465 if !diagnostic.choices.is_empty() {
3466 diagnostic.verified = match diagnostic.code.as_str() {
3467 "repository-integration" => false,
3468 "release-tool-proxy-disposition" => {
3469 let candidate = candidates.iter().find(|candidate| {
3470 diagnostic.id
3471 == format!("release-tool-proxy-disposition:{}", candidate.id)
3472 });
3473 match (diagnostic.resolution.as_deref(), candidate) {
3474 (Some("retain"), Some(_)) => true,
3475 (Some("remove"), Some(candidate)) => non_displaced_manifest_references(
3476 root,
3477 &candidate.path,
3478 candidate.native_identity.as_deref().unwrap_or_default(),
3479 )?
3480 .is_empty(),
3481 _ => false,
3482 }
3483 }
3484 _ => diagnostic.resolution.is_some(),
3485 };
3486 }
3487 } else if old.resolution.is_some() {
3488 diagnostic.invalidated_resolution = true;
3489 }
3490 }
3491 let current_ids = current
3492 .iter()
3493 .map(|diagnostic| diagnostic.id.clone())
3494 .collect::<BTreeSet<_>>();
3495 for old in &previous.diagnostics {
3496 if old.code != "repository-integration"
3497 || current_ids.contains(&old.id)
3498 || old.resolution.as_deref() != Some("removed")
3499 {
3500 continue;
3501 }
3502 let Some(path) = old.evidence.first().map(|evidence| evidence.path.clone()) else {
3503 continue;
3504 };
3505 let full = root.join(&path);
3506 if !full.is_file() {
3507 continue;
3508 }
3509 let text = std::fs::read_to_string(&full).map_err(|error| Error::io(&full, error))?;
3510 if text.to_ascii_lowercase().contains("changeset") {
3511 continue;
3512 }
3513 current.push(InitDiagnostic {
3514 id: old.id.clone(),
3515 code: old.code.clone(),
3516 message: old.message.clone(),
3517 evidence: vec![evidence(root, &path, Vec::new())?],
3518 choices: old.choices.clone(),
3519 recommended: old.recommended.clone(),
3520 resolution: old.resolution.clone(),
3521 verified: true,
3522 invalidated_resolution: false,
3523 supporting_evidence: old.supporting_evidence.clone(),
3524 contradictory_evidence: old.contradictory_evidence.clone(),
3525 uncertainty: old.uncertainty.clone(),
3526 });
3527 }
3528 current.sort_by(|left, right| left.id.cmp(&right.id));
3529 Ok(())
3530}
3531
3532fn apply_disposition_resolutions(
3533 config: &mut Config,
3534 diagnostics: &[InitDiagnostic],
3535) -> Result<()> {
3536 let mut excluded = BTreeSet::new();
3537 for diagnostic in diagnostics.iter().filter(|diagnostic| {
3538 diagnostic.code == "ignored-release-unit-disposition"
3539 || diagnostic.code == "unmapped-release-unit-disposition"
3540 }) {
3541 let release_unit = diagnostic
3542 .id
3543 .split_once(':')
3544 .map(|(_, release_unit)| release_unit)
3545 .expect("diagnostic id prefix");
3546 match diagnostic.resolution.as_deref() {
3547 Some("suspended") => {
3548 if let Some(config) = config.release_units.get_mut(release_unit) {
3549 config.disposition = ReleaseUnitDisposition::Suspended;
3550 }
3551 }
3552 Some("excluded") => {
3553 config.release_units.remove(release_unit);
3554 excluded.insert(release_unit.to_owned());
3555 }
3556 Some("managed") | None => {}
3557 Some(value) => {
3558 return Err(Error::Validation(format!(
3559 "invalid resolution {value} for {}",
3560 diagnostic.id
3561 )))
3562 }
3563 }
3564 }
3565 exclude_release_units(config, &excluded);
3566 Ok(())
3567}
3568
3569fn apply_proxy_manifest_resolutions(
3570 config: &mut Config,
3571 diagnostics: &[InitDiagnostic],
3572 candidates: &[DiscoveryCandidate],
3573) -> Result<Vec<ProxyRemoval>> {
3574 let mut removals = Vec::new();
3575 for diagnostic in diagnostics
3576 .iter()
3577 .filter(|diagnostic| diagnostic.code == "release-tool-proxy-disposition")
3578 {
3579 let candidate = candidates
3580 .iter()
3581 .find(|candidate| {
3582 diagnostic.id == format!("release-tool-proxy-disposition:{}", candidate.id)
3583 })
3584 .ok_or_else(|| {
3585 Error::Validation(format!(
3586 "{} does not identify a current discovery candidate",
3587 diagnostic.id
3588 ))
3589 })?;
3590 match diagnostic.resolution.as_deref() {
3591 None | Some("retain") => {}
3592 Some("remove") => {
3593 let Some(CandidateResolution::Projection { release_unit, .. }) =
3594 candidate.resolution.as_ref()
3595 else {
3596 return Err(Error::Validation(format!(
3597 "{} can remove only a candidate resolved as a projection",
3598 diagnostic.id
3599 )));
3600 };
3601 let unit = config.release_units.get_mut(release_unit).ok_or_else(|| {
3602 Error::Validation(format!(
3603 "{} targets absent release unit {release_unit}",
3604 diagnostic.id
3605 ))
3606 })?;
3607 let unit_path = unit.path.clone();
3608 unit.projections.retain(|projection| {
3609 let path = if unit_path == Path::new(".") {
3610 projection.file.clone()
3611 } else {
3612 unit_path.join(&projection.file)
3613 };
3614 path != candidate.path
3615 });
3616 config.discovery.managed_paths.retain(|receipt| {
3617 receipt.detector != candidate.detector || receipt.path != candidate.path
3618 });
3619 removals.push(ProxyRemoval {
3620 path: candidate.path.clone(),
3621 native_identity: candidate
3622 .native_identity
3623 .clone()
3624 .expect("proxy diagnostic requires native identity"),
3625 });
3626 }
3627 Some(value) => {
3628 return Err(Error::Validation(format!(
3629 "invalid resolution {value} for {}",
3630 diagnostic.id
3631 )));
3632 }
3633 }
3634 }
3635 removals.sort_by(|left, right| left.path.cmp(&right.path));
3636 Ok(removals)
3637}
3638
3639fn exclude_release_units(config: &mut Config, excluded: &BTreeSet<String>) {
3640 for id in excluded {
3641 config.release_units.remove(id);
3642 }
3643 if excluded.is_empty() {
3644 return;
3645 }
3646 for release_unit in config.release_units.values_mut() {
3647 release_unit.depends_on.retain(|id| !excluded.contains(id));
3648 }
3649 for groups in [&mut config.fixed, &mut config.linked] {
3650 for group in groups.iter_mut() {
3651 group.retain(|id| !excluded.contains(id));
3652 }
3653 groups.retain(|group| group.len() >= 2);
3654 }
3655}
3656
3657fn parity_result(
3658 source_config: &Config,
3659 proposed_config: &Config,
3660 current: &BTreeMap<String, Version>,
3661 declared: &BTreeMap<String, Bump>,
3662 source_semantics: &ChangesetsSourceSemantics<'_>,
3663) -> Result<ParityComputation> {
3664 let mut source_config = source_config.clone();
3665 let mut proposed_config = proposed_config.clone();
3666 let effective = effective_bumps(&proposed_config, declared);
3667 let non_releasing_without_versions = proposed_config
3668 .release_units
3669 .keys()
3670 .filter(|id| !current.contains_key(*id) && effective[*id] == Bump::None)
3671 .cloned()
3672 .collect::<BTreeSet<_>>();
3673 exclude_release_units(&mut source_config, &non_releasing_without_versions);
3674 exclude_release_units(&mut proposed_config, &non_releasing_without_versions);
3675 let excluded_pending = declared
3676 .keys()
3677 .filter(|id| !proposed_config.release_units.contains_key(*id))
3678 .cloned()
3679 .collect::<Vec<_>>();
3680 let proposed_preflight_error = (!excluded_pending.is_empty()).then(|| {
3681 format!(
3682 "converted intents reference release units excluded from the proposed inventory: {}",
3683 excluded_pending.join(", ")
3684 )
3685 });
3686 if source_semantics.preflight_error.is_some() || proposed_preflight_error.is_some() {
3687 return Ok(ParityComputation {
3688 result: ParityResult {
3689 status: "blocked".to_owned(),
3690 release_units: Vec::new(),
3691 },
3692 source_error: source_semantics.preflight_error.clone(),
3693 proposed_error: proposed_preflight_error,
3694 });
3695 }
3696 let missing = source_config
3697 .release_units
3698 .keys()
3699 .chain(proposed_config.release_units.keys())
3700 .filter(|id| !current.contains_key(*id))
3701 .cloned()
3702 .collect::<BTreeSet<_>>();
3703 if !missing.is_empty() {
3704 return Ok(ParityComputation {
3705 result: ParityResult {
3706 status: "blocked".to_owned(),
3707 release_units: Vec::new(),
3708 },
3709 source_error: None,
3710 proposed_error: Some(format!(
3711 "missing current versions for release units: {}",
3712 missing.into_iter().collect::<Vec<_>>().join(", ")
3713 )),
3714 });
3715 }
3716 let (source, source_error) =
3717 match resolve_changesets_source(&source_config, declared, current, source_semantics) {
3718 Ok(resolved) => (resolved, None),
3719 Err(Error::Validation(message)) => (BTreeMap::new(), Some(message)),
3720 Err(error) => return Err(error),
3721 };
3722 let (proposed, proposed_error) = match resolve_versions(&proposed_config, declared, current) {
3723 Ok(resolved) => (resolved, None),
3724 Err(Error::Validation(message)) => (BTreeMap::new(), Some(message)),
3725 Err(error) => return Err(error),
3726 };
3727 let release_ids = source
3728 .iter()
3729 .chain(&proposed)
3730 .filter(|(_, versions)| versions.bump != Bump::None)
3731 .map(|(id, _)| id.clone())
3732 .collect::<BTreeSet<_>>();
3733 let release_units = release_ids
3734 .into_iter()
3735 .map(|release_unit| ParityReleaseUnit {
3736 current_version: current[&release_unit].to_string(),
3737 source: parity_release(source.get(&release_unit)),
3738 proposed: parity_release(proposed.get(&release_unit)),
3739 release_unit,
3740 })
3741 .collect::<Vec<_>>();
3742 let equivalent = source_error.is_none()
3743 && proposed_error.is_none()
3744 && release_units
3745 .iter()
3746 .all(|release_unit| release_unit.source == release_unit.proposed);
3747 Ok(ParityComputation {
3748 result: ParityResult {
3749 status: if equivalent { "equivalent" } else { "blocked" }.to_owned(),
3750 release_units,
3751 },
3752 source_error,
3753 proposed_error,
3754 })
3755}
3756
3757fn mixed_skipped_changeset(
3758 intents: &[ConvertedIntent],
3759 skipped_release_units: &BTreeSet<String>,
3760) -> Option<String> {
3761 intents.iter().find_map(|intent| {
3762 let has_skipped = intent
3763 .release_units
3764 .keys()
3765 .any(|id| skipped_release_units.contains(id));
3766 let has_managed = intent
3767 .release_units
3768 .keys()
3769 .any(|id| !skipped_release_units.contains(id));
3770 (has_skipped && has_managed).then(|| {
3771 format!(
3772 "Changesets intent {} mixes skipped and managed release units; split or revise the source changeset before takeover",
3773 intent.id
3774 )
3775 })
3776 })
3777}
3778
3779struct ParityComputation {
3780 result: ParityResult,
3781 source_error: Option<String>,
3782 proposed_error: Option<String>,
3783}
3784
3785fn parity_release(version: Option<&ReleaseUnitVersion>) -> Option<ParityRelease> {
3786 version
3787 .filter(|version| version.bump != Bump::None)
3788 .map(|version| ParityRelease {
3789 requested_bump: version.bump,
3790 next_version: version.next.to_string(),
3791 })
3792}
3793
3794fn resolve_changesets_source(
3800 config: &Config,
3801 declared: &BTreeMap<String, Bump>,
3802 current: &BTreeMap<String, Version>,
3803 semantics: &ChangesetsSourceSemantics<'_>,
3804) -> Result<BTreeMap<String, ReleaseUnitVersion>> {
3805 let mut effective = config
3806 .release_units
3807 .keys()
3808 .map(|id| {
3809 let suppressed =
3810 semantics.suppress_private_versions && semantics.private_packages.contains(id);
3811 (
3812 id.clone(),
3813 if suppressed {
3814 Bump::None
3815 } else {
3816 declared.get(id).copied().unwrap_or(Bump::None)
3817 },
3818 )
3819 })
3820 .collect::<BTreeMap<_, _>>();
3821 loop {
3822 let before = effective.clone();
3823 for edge in semantics.npm_dependencies {
3824 if !config.release_units.contains_key(&edge.dependent)
3825 || !config.release_units.contains_key(&edge.dependency)
3826 || (semantics.suppress_private_versions
3827 && semantics.private_packages.contains(&edge.dependent))
3828 {
3829 continue;
3830 }
3831 let dependency_bump = effective[&edge.dependency];
3832 if dependency_bump == Bump::None {
3833 continue;
3834 }
3835 let next = changesets_dependency_next(config, &edge.dependency, &effective, current);
3836 let in_range = npm_range_satisfies(&edge.range, ¤t[&edge.dependency], &next)?;
3837 let required = if edge.kind == NpmDependencyKind::Peer
3838 && dependency_bump >= Bump::Minor
3839 && (!semantics.only_update_peer_dependents_when_out_of_range || !in_range)
3840 {
3841 Bump::Major
3842 } else if semantics.update_internal_dependents_always || !in_range {
3843 semantics.internal_dependency_bump
3844 } else {
3845 Bump::None
3846 };
3847 if required != Bump::None {
3848 effective
3849 .entry(edge.dependent.clone())
3850 .and_modify(|bump| *bump = (*bump).max(required));
3851 }
3852 }
3853 for group in &config.fixed {
3854 let bump = group
3855 .iter()
3856 .map(|id| effective[id])
3857 .max()
3858 .unwrap_or_default();
3859 if bump != Bump::None {
3860 for id in group {
3861 if !semantics.suppress_private_versions
3862 || !semantics.private_packages.contains(id)
3863 {
3864 effective.insert(id.clone(), bump);
3865 }
3866 }
3867 }
3868 }
3869 for group in &config.linked {
3870 let bump = group
3871 .iter()
3872 .map(|id| effective[id])
3873 .max()
3874 .unwrap_or_default();
3875 if bump != Bump::None {
3876 for id in group {
3877 if effective[id] != Bump::None {
3878 effective.insert(id.clone(), bump);
3879 }
3880 }
3881 }
3882 }
3883 if effective == before {
3884 break;
3885 }
3886 }
3887
3888 let mut resolved = config
3889 .release_units
3890 .keys()
3891 .map(|id| {
3892 let current_version = current.get(id).ok_or_else(|| {
3893 Error::Validation(format!("missing current version for release unit {id}"))
3894 })?;
3895 Ok((
3896 id.clone(),
3897 ReleaseUnitVersion::new_with_mapping(
3898 current_version.clone(),
3899 effective[id],
3900 Pre1BumpMapping::Component,
3901 ),
3902 ))
3903 })
3904 .collect::<Result<BTreeMap<_, _>>>()?;
3905 for (fixed, groups) in [(true, &config.fixed), (false, &config.linked)] {
3906 for group in groups {
3907 let highest_current = group
3908 .iter()
3909 .map(|id| ¤t[id])
3910 .max()
3911 .expect("validated release group")
3912 .clone();
3913 let highest_bump = group
3914 .iter()
3915 .map(|id| effective[id])
3916 .max()
3917 .unwrap_or_default();
3918 let next = bump_version_with_mapping(
3919 &highest_current,
3920 highest_bump,
3921 Pre1BumpMapping::Component,
3922 );
3923 for id in group {
3924 let suppressed =
3925 semantics.suppress_private_versions && semantics.private_packages.contains(id);
3926 let releases = !suppressed
3927 && highest_bump != Bump::None
3928 && (fixed || effective[id] != Bump::None);
3929 resolved.insert(
3930 id.clone(),
3931 ReleaseUnitVersion {
3932 current: current[id].clone(),
3933 next: if releases {
3934 next.clone()
3935 } else {
3936 current[id].clone()
3937 },
3938 bump: if releases { highest_bump } else { Bump::None },
3939 },
3940 );
3941 }
3942 }
3943 }
3944 Ok(resolved)
3945}
3946
3947fn changesets_dependency_next(
3948 config: &Config,
3949 id: &str,
3950 effective: &BTreeMap<String, Bump>,
3951 current: &BTreeMap<String, Version>,
3952) -> Version {
3953 for group in config.fixed.iter().chain(&config.linked) {
3954 if group.iter().any(|member| member == id) && effective[id] != Bump::None {
3955 let highest_current = group
3956 .iter()
3957 .map(|member| ¤t[member])
3958 .max()
3959 .expect("validated group");
3960 let highest_bump = group
3961 .iter()
3962 .map(|member| effective[member])
3963 .max()
3964 .unwrap_or_default();
3965 return bump_version_with_mapping(
3966 highest_current,
3967 highest_bump,
3968 Pre1BumpMapping::Component,
3969 );
3970 }
3971 }
3972 bump_version_with_mapping(¤t[id], effective[id], Pre1BumpMapping::Component)
3973}
3974
3975fn npm_range_satisfies(range: &str, current: &Version, next: &Version) -> Result<bool> {
3976 let normalized = match range.strip_prefix("workspace:") {
3977 Some("*") => current.to_string(),
3978 Some("^") => format!("^{current}"),
3979 Some("~") => format!("~{current}"),
3980 Some(range) => range.to_owned(),
3981 None => range.to_owned(),
3982 };
3983 let Ok(range) = NodeRange::parse(&normalized) else {
3984 return Ok(false);
3985 };
3986 let next = NodeVersion::parse(next.to_string())
3987 .map_err(|error| Error::Validation(format!("invalid next npm version: {error}")))?;
3988 Ok(range.satisfies(&next))
3989}
3990
3991fn load_previous_plan(root: &Path) -> Result<Option<InitPlan>> {
3992 let path = root.join(INIT_PLAN_PATH);
3993 match std::fs::read_to_string(&path) {
3994 Ok(text) => {
3995 let plan: InitPlan = serde_yaml::from_str(&text)?;
3996 plan.validate()?;
3997 Ok(Some(plan))
3998 }
3999 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
4000 Err(error) => Err(Error::io(path, error)),
4001 }
4002}
4003
4004fn evidence(root: &Path, relative: &Path, lines: Vec<usize>) -> Result<SourceEvidence> {
4005 let path = root.join(relative);
4006 let bytes = std::fs::read(&path).map_err(|error| Error::io(&path, error))?;
4007 Ok(SourceEvidence {
4008 path: relative.to_owned(),
4009 digest: format!("sha256:{:x}", Sha256::digest(&bytes)),
4010 lines,
4011 })
4012}
4013
4014fn fingerprint(root: &Path, paths: &BTreeSet<PathBuf>) -> Result<String> {
4015 let mut source = BTreeMap::new();
4016 for path in paths {
4017 if root.join(path).is_file() {
4018 source.insert(
4019 path.to_string_lossy().to_string(),
4020 evidence(root, path, Vec::new())?.digest,
4021 );
4022 }
4023 }
4024 for path in ["package.json", "pnpm-workspace.yaml", "Cargo.toml"] {
4025 let path = PathBuf::from(path);
4026 if root.join(&path).is_file() {
4027 source.insert(
4028 path.to_string_lossy().to_string(),
4029 evidence(root, &path, Vec::new())?.digest,
4030 );
4031 }
4032 }
4033 for entry in WalkDir::new(root.join(".changeset"))
4034 .into_iter()
4035 .filter_map(std::result::Result::ok)
4036 .filter(|entry| entry.file_type().is_file())
4037 {
4038 let path = entry
4039 .path()
4040 .strip_prefix(root)
4041 .unwrap_or(entry.path())
4042 .to_owned();
4043 source.insert(
4044 path.to_string_lossy().to_string(),
4045 evidence(root, &path, Vec::new())?.digest,
4046 );
4047 }
4048 Ok(format!(
4049 "sha256:{:x}",
4050 Sha256::digest(canonical_json(&source)?.as_bytes())
4051 ))
4052}
4053
4054fn verify_takeover_preconditions(
4055 root: &Path,
4056 writes: &[(PathBuf, String)],
4057 expected_fingerprint: &str,
4058 evidence_paths: &BTreeSet<PathBuf>,
4059) -> Result<()> {
4060 let actual_fingerprint = fingerprint(root, evidence_paths)?;
4061 if actual_fingerprint != expected_fingerprint {
4062 return Err(Error::Validation(
4063 "initialization plan source evidence became stale; rerun intentional init".to_owned(),
4064 ));
4065 }
4066 for (relative, _) in writes {
4067 if root.join(relative).exists() {
4068 return Err(Error::Validation(format!(
4069 "takeover target {} already exists; resolve the competing Intentional state first",
4070 relative.display()
4071 )));
4072 }
4073 }
4074 Ok(())
4075}
4076
4077fn takeover_operations(
4078 root: &Path,
4079 intents: &[ConvertedIntent],
4080 proxy_removals: &[ProxyRemoval],
4081) -> Vec<String> {
4082 let mut operations = vec![format!("write {CONFIG_PATH}")];
4083 operations.extend(intents.iter().map(|intent| {
4084 format!(
4085 "write {} from {}",
4086 intent.target.display(),
4087 intent.source.display()
4088 )
4089 }));
4090 operations.extend(
4091 takeover_deletes(root, proxy_removals)
4092 .iter()
4093 .map(|path| format!("delete {}", path.display())),
4094 );
4095 operations.push("after commit: intentional tag --baseline".to_owned());
4096 operations
4097}
4098
4099fn takeover_deletes(root: &Path, proxy_removals: &[ProxyRemoval]) -> Vec<PathBuf> {
4100 let mut paths = WalkDir::new(root.join(".changeset"))
4101 .into_iter()
4102 .filter_map(std::result::Result::ok)
4103 .filter(|entry| entry.file_type().is_file())
4104 .map(|entry| {
4105 entry
4106 .path()
4107 .strip_prefix(root)
4108 .unwrap_or(entry.path())
4109 .to_owned()
4110 })
4111 .collect::<Vec<_>>();
4112 paths.push(PathBuf::from(INIT_PLAN_PATH));
4113 paths.extend(proxy_removals.iter().map(|removal| removal.path.clone()));
4114 paths.sort();
4115 paths.dedup();
4116 paths
4117}
4118
4119fn verify_proxy_removal_preconditions(root: &Path, removals: &[ProxyRemoval]) -> Result<()> {
4120 for removal in removals {
4121 let references =
4122 non_displaced_manifest_references(root, &removal.path, &removal.native_identity)?;
4123 if !references.is_empty() {
4124 return Err(Error::Validation(format!(
4125 "cannot remove probable release-tool proxy {} while non-source-tool repository references remain in {}",
4126 removal.path.display(),
4127 references
4128 .iter()
4129 .map(|item| item.path.display().to_string())
4130 .collect::<Vec<_>>()
4131 .join(", ")
4132 )));
4133 }
4134 }
4135 Ok(())
4136}
4137
4138fn apply_takeover_transaction(
4139 root: &Path,
4140 writes: &[(PathBuf, String)],
4141 deletes: &[PathBuf],
4142) -> Result<()> {
4143 let transaction = root.join(TRANSACTION_PATH);
4144 if transaction.exists() {
4145 recover_interrupted_takeover(root)?;
4146 }
4147 let setup = (|| {
4148 std::fs::create_dir_all(transaction.join("original"))
4149 .map_err(|error| Error::io(&transaction, error))?;
4150 let affected = writes
4151 .iter()
4152 .map(|(path, _)| path.clone())
4153 .chain(deletes.iter().cloned())
4154 .collect::<BTreeSet<_>>();
4155 for relative in &affected {
4156 let source = root.join(relative);
4157 if source.is_file() {
4158 let backup = transaction.join("original").join(relative);
4159 if let Some(parent) = backup.parent() {
4160 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4161 }
4162 std::fs::copy(&source, &backup).map_err(|error| Error::io(&source, error))?;
4163 }
4164 }
4165 let manifest = serde_yaml::to_string(
4166 &affected
4167 .iter()
4168 .map(|path| path.to_string_lossy().to_string())
4169 .collect::<Vec<_>>(),
4170 )?;
4171 let manifest_path = transaction.join("manifest.yml");
4172 let manifest_temporary = transaction.join("manifest-tmp.yml");
4173 std::fs::write(&manifest_temporary, manifest)
4174 .map_err(|error| Error::io(&manifest_temporary, error))?;
4175 std::fs::rename(&manifest_temporary, &manifest_path)
4176 .map_err(|error| Error::io(&manifest_path, error))?;
4177 Ok::<(), Error>(())
4178 })();
4179 match setup {
4180 Ok(()) => {}
4181 Err(error) => {
4182 if transaction.exists() {
4183 std::fs::remove_dir_all(&transaction)
4184 .map_err(|cleanup| Error::io(&transaction, cleanup))?;
4185 }
4186 return Err(error);
4187 }
4188 }
4189 let result = (|| {
4190 for (relative, contents) in writes {
4191 let path = root.join(relative);
4192 if let Some(parent) = path.parent() {
4193 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4194 }
4195 let temporary = transaction.join("staged").join(relative);
4196 if let Some(parent) = temporary.parent() {
4197 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4198 }
4199 std::fs::write(&temporary, contents).map_err(|error| Error::io(&temporary, error))?;
4200 std::fs::rename(&temporary, &path).map_err(|error| Error::io(&path, error))?;
4201 }
4202 for relative in deletes {
4203 let path = root.join(relative);
4204 if path.is_file() {
4205 std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
4206 }
4207 }
4208 let changeset_directory = root.join(".changeset");
4209 if changeset_directory.is_dir()
4210 && std::fs::read_dir(&changeset_directory)
4211 .map_err(|error| Error::io(&changeset_directory, error))?
4212 .next()
4213 .is_none()
4214 {
4215 std::fs::remove_dir(&changeset_directory)
4216 .map_err(|error| Error::io(&changeset_directory, error))?;
4217 }
4218 Ok(())
4219 })();
4220 if result.is_err() {
4221 recover_interrupted_takeover(root)?;
4222 return result;
4223 }
4224 if let Err(error) = write_transaction_state(root, "committed") {
4225 recover_interrupted_takeover(root)?;
4226 return Err(error);
4227 }
4228 finish_transaction_cleanup(root)
4229}
4230
4231fn recover_interrupted_takeover(root: &Path) -> Result<()> {
4232 let transaction = root.join(TRANSACTION_PATH);
4233 let state_path = root.join(TRANSACTION_STATE_PATH);
4234 if state_path.is_file() {
4235 return finish_transaction_cleanup(root);
4236 }
4237 if !transaction.exists() {
4238 return Ok(());
4239 }
4240 let manifest_path = transaction.join("manifest.yml");
4241 if !manifest_path.is_file() {
4242 return std::fs::remove_dir_all(&transaction)
4243 .map_err(|error| Error::io(&transaction, error));
4244 }
4245 let manifest = std::fs::read_to_string(&manifest_path)
4246 .map_err(|error| Error::io(&manifest_path, error))?;
4247 let affected: Vec<String> = serde_yaml::from_str(&manifest)?;
4248 for relative in affected {
4249 let relative = PathBuf::from(relative);
4250 let target = root.join(&relative);
4251 let backup = transaction.join("original").join(&relative);
4252 if backup.is_file() {
4253 if let Some(parent) = target.parent() {
4254 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4255 }
4256 std::fs::copy(&backup, &target).map_err(|error| Error::io(&target, error))?;
4257 } else if target.is_file() {
4258 std::fs::remove_file(&target).map_err(|error| Error::io(&target, error))?;
4259 }
4260 }
4261 write_transaction_state(root, "rolled-back")?;
4262 finish_transaction_cleanup(root)
4263}
4264
4265fn write_transaction_state(root: &Path, state: &str) -> Result<()> {
4266 let path = root.join(TRANSACTION_STATE_PATH);
4267 let temporary = root.join(".intentional/.takeover-state-tmp");
4268 std::fs::write(&temporary, state).map_err(|error| Error::io(&temporary, error))?;
4269 std::fs::rename(&temporary, &path).map_err(|error| Error::io(&path, error))
4270}
4271
4272fn finish_transaction_cleanup(root: &Path) -> Result<()> {
4273 let transaction = root.join(TRANSACTION_PATH);
4274 if transaction.exists() {
4275 std::fs::remove_dir_all(&transaction).map_err(|error| Error::io(&transaction, error))?;
4276 }
4277 for path in [
4278 root.join(TRANSACTION_STATE_PATH),
4279 root.join(".intentional/.takeover-state-tmp"),
4280 ] {
4281 if path.is_file() {
4282 std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
4283 }
4284 }
4285 Ok(())
4286}
4287
4288fn annotate_choice_lines(yaml: &str) -> String {
4289 let comments = BTreeMap::from([
4290 ("suspended", "configured, but releases are blocked"),
4291 ("excluded", "outside Intentional's release-unit inventory"),
4292 ("managed", "configured and eligible for release"),
4293 (
4294 "removed",
4295 "reference removed or replaced and verified from the file",
4296 ),
4297 (
4298 "external",
4299 "publication sequencing remains with the external executor",
4300 ),
4301 ("intentional", "Intentional's contract owns this behavior"),
4302 ]);
4303 let mut output = String::new();
4304 let mut choices_indent = None;
4305 for line in yaml.lines() {
4306 let indent = line.len() - line.trim_start().len();
4307 if line.trim() == "choices:" {
4308 choices_indent = Some(indent);
4309 output.push_str(line);
4310 output.push('\n');
4311 continue;
4312 }
4313 if let Some(base) = choices_indent {
4314 if let Some(value) = line.trim().strip_prefix("- ") {
4315 if let Some(comment) = comments.get(value) {
4316 output.push_str(line);
4317 output.push_str(" # ");
4318 output.push_str(comment);
4319 output.push('\n');
4320 continue;
4321 }
4322 } else if indent <= base {
4323 choices_indent = None;
4324 }
4325 }
4326 output.push_str(line);
4327 output.push('\n');
4328 }
4329 output
4330}
4331
4332#[cfg(test)]
4333mod tests {
4334 use super::*;
4335
4336 const DIGEST: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
4337
4338 fn candidate(path: &str, resolution: Option<CandidateResolution>) -> DiscoveryCandidate {
4339 let path = PathBuf::from(path);
4340 let evidence = SourceEvidence {
4341 path: path.clone(),
4342 digest: DIGEST.to_owned(),
4343 lines: vec![1],
4344 };
4345 DiscoveryCandidate {
4346 id: DiscoveryCandidate::stable_id("sample-detector", &path).expect("stable id"),
4347 detector: "sample-detector".to_owned(),
4348 path: path.clone(),
4349 evidence: vec![evidence.clone()],
4350 native_identity: Some("sample-native-id".to_owned()),
4351 raw_version: Some(RawVersionEvidence {
4352 value: "raw-version-text".to_owned(),
4353 evidence: vec![evidence.clone()],
4354 }),
4355 projection: Some(CandidateProjectionSuggestion {
4356 adapter: Adapter::Npm,
4357 path,
4358 mode: ProjectionMode::Committed,
4359 pointer: None,
4360 }),
4361 tag: Some(CandidateTagSuggestion {
4362 id: "primary".to_owned(),
4363 role: TagRole::Primary,
4364 template: "{id}@{version}".to_owned(),
4365 }),
4366 diagnostics: vec![ExtractionDiagnostic {
4367 id: "sample-diagnostic".to_owned(),
4368 code: "sample-extraction".to_owned(),
4369 message: "Sample extraction evidence is incomplete.".to_owned(),
4370 evidence: vec![evidence],
4371 }],
4372 resolution,
4373 }
4374 }
4375
4376 fn candidate_plan(candidates: Vec<DiscoveryCandidate>) -> InitPlan {
4377 let config = Config::from_yaml(
4378 "contract: contract-1\nrelease-units:\n configured:\n path: configured\n tags:\n primary: { role: primary, template: '{id}@{version}' }\n",
4379 )
4380 .expect("candidate test config");
4381 InitPlan {
4382 schema: INIT_PLAN_SCHEMA.to_owned(),
4383 state: InitState::NeedsInput,
4384 source_kind: "changesets".to_owned(),
4385 source_fingerprint: DIGEST.to_owned(),
4386 inferred_config: config,
4387 discovery_candidates: candidates,
4388 diagnostics: Vec::new(),
4389 converted_intents: Vec::new(),
4390 parity: ParityResult {
4391 status: "equivalent".to_owned(),
4392 release_units: Vec::new(),
4393 },
4394 planned_operations: Vec::new(),
4395 post_commit_action: "intentional tag --baseline".to_owned(),
4396 }
4397 }
4398
4399 #[test]
4400 fn recognizes_supported_manifests() {
4401 assert_eq!(adapter_for(Path::new("package.json")), Some(Adapter::Npm));
4402 assert_eq!(
4403 adapter_for(Path::new("module.csproj")),
4404 Some(Adapter::Msbuild)
4405 );
4406 assert_eq!(adapter_for(Path::new("README.md")), None);
4407 }
4408
4409 #[test]
4410 fn choice_rendering_is_compact_and_explained() {
4411 let yaml = "choices:\n- suspended\n- excluded\nresolution: null\n";
4412 let rendered = annotate_choice_lines(yaml);
4413 assert!(rendered.contains("- suspended # configured, but releases are blocked"));
4414 assert!(rendered.contains("- excluded # outside Intentional's release-unit inventory"));
4415 }
4416
4417 #[test]
4418 fn proxy_reference_detection_ignores_prose_and_comments() {
4419 let path = "examples/sample.json";
4420 assert!(!structural_line_references_path(
4421 r#""description": "See examples/sample.json""#,
4422 path
4423 ));
4424 assert!(!structural_line_references_path(
4425 "note: examples/sample.json",
4426 path
4427 ));
4428 assert!(!structural_line_references_path(
4429 "// load examples/sample.json",
4430 path
4431 ));
4432 assert!(!structural_line_references_path(
4433 "const enabled = true; // load examples/sample.json",
4434 path
4435 ));
4436 assert!(!structural_line_references_path(
4437 "/* load examples/sample.json */",
4438 path
4439 ));
4440 assert!(structural_line_references_path(
4441 "run: curl https://registry.invalid && use examples/sample.json",
4442 path
4443 ));
4444 assert!(structural_line_references_path(
4445 r##"run: "curl https://registry.invalid/#fragment && use examples/sample.json""##,
4446 path
4447 ));
4448 assert!(structural_line_references_path(
4449 r#"const manifest = "examples/sample.json";"#,
4450 path
4451 ));
4452 }
4453
4454 #[test]
4455 fn npm_dependency_evidence_follows_the_materialized_projection_path() {
4456 struct TestDirectory(PathBuf);
4457 impl Drop for TestDirectory {
4458 fn drop(&mut self) {
4459 let _ = std::fs::remove_dir_all(&self.0);
4460 }
4461 }
4462
4463 let root = TestDirectory(std::env::temp_dir().join(format!(
4464 "intentional-init-{}-{}",
4465 std::process::id(),
4466 std::time::SystemTime::now()
4467 .duration_since(std::time::UNIX_EPOCH)
4468 .expect("system time")
4469 .as_nanos()
4470 )));
4471 for directory in ["base", "duplicates/alpha", "duplicates/beta"] {
4472 std::fs::create_dir_all(root.0.join(directory)).expect("create fixture directory");
4473 }
4474 std::fs::write(
4475 root.0.join("base/package.json"),
4476 r#"{"name":"sample-base","version":"1.0.0"}"#,
4477 )
4478 .expect("write base manifest");
4479 std::fs::write(
4480 root.0.join("duplicates/alpha/package.json"),
4481 r#"{"name":"sample-duplicate","version":"1.0.0"}"#,
4482 )
4483 .expect("write first duplicate");
4484 std::fs::write(
4485 root.0.join("duplicates/beta/package.json"),
4486 r#"{"name":"sample-duplicate","version":"1.0.0","dependencies":{"sample-base":"^1.0.0"}}"#,
4487 )
4488 .expect("write second duplicate");
4489 let mut config = Config::from_yaml(
4490 r#"contract: contract-1
4491release-units:
4492 sample-base:
4493 path: base
4494 projections:
4495 - { adapter: npm, file: package.json, mode: committed }
4496 tags:
4497 primary: { role: primary, template: '{id}@{version}' }
4498 sample-duplicate:
4499 path: .
4500 projections:
4501 - { adapter: npm, file: duplicates/alpha/package.json, mode: committed }
4502 - { adapter: npm, file: duplicates/beta/package.json, mode: committed }
4503 tags:
4504 primary: { role: primary, template: '{id}@{version}' }
4505"#,
4506 )
4507 .expect("fixture config");
4508
4509 let mut edges = derive_npm_dependencies(&root.0, &mut config).expect("dependency evidence");
4510 assert_eq!(edges.len(), 1);
4511 assert_eq!(edges[0].manifest, Path::new("duplicates/beta/package.json"));
4512 assert_eq!(
4513 config.release_units["sample-duplicate"].depends_on,
4514 vec!["sample-base"]
4515 );
4516
4517 config
4518 .release_units
4519 .get_mut("sample-duplicate")
4520 .expect("duplicate unit")
4521 .projections
4522 .retain(|projection| projection.file != Path::new("duplicates/beta/package.json"));
4523 retain_materialized_npm_dependencies(&mut config, &mut edges);
4524 assert!(edges.is_empty());
4525 assert!(config.release_units["sample-duplicate"]
4526 .depends_on
4527 .is_empty());
4528
4529 let duplicate = config
4530 .release_units
4531 .get_mut("sample-duplicate")
4532 .expect("duplicate unit");
4533 duplicate.projections.clear();
4534 duplicate.depends_on = vec!["sample-base".to_owned()];
4535 retain_materialized_npm_dependencies(&mut config, &mut edges);
4536 assert!(config.release_units["sample-duplicate"]
4537 .depends_on
4538 .is_empty());
4539 }
4540
4541 #[test]
4542 fn candidate_identity_and_detector_results_are_stable() {
4543 let first = candidate(
4544 "examples/first.json",
4545 Some(CandidateResolution::Independent {
4546 release_unit: "sample-unit".to_owned(),
4547 }),
4548 );
4549 let second = candidate("examples/second.json", Some(CandidateResolution::Excluded));
4550 assert_eq!(
4551 first.id,
4552 DiscoveryCandidate::stable_id("sample-detector", Path::new("examples/first.json"))
4553 .expect("same identity")
4554 );
4555 assert_ne!(first.id, second.id);
4556 assert_ne!(
4557 first.id,
4558 DiscoveryCandidate::stable_id("other-detector", Path::new("examples/first.json"))
4559 .expect("different detector")
4560 );
4561 assert!(DiscoveryCandidate::stable_id(
4562 "-invalid-detector",
4563 Path::new("examples/first.json")
4564 )
4565 .is_err());
4566
4567 let result = DetectorResult {
4568 detector: "sample-detector".to_owned(),
4569 candidates: vec![second.clone(), first.clone()],
4570 };
4571 let ordered = result.into_candidates().expect("detector result valid");
4572 assert!(ordered[0].path < ordered[1].path);
4573
4574 let mut tampered = first.clone();
4575 tampered.id = format!("candidate:{}", "0".repeat(64));
4576 assert!(candidate_plan(vec![tampered])
4577 .validate()
4578 .expect_err("tampered candidate id rejected")
4579 .to_string()
4580 .contains("does not match detector"));
4581
4582 let foreign = DetectorResult {
4583 detector: "other-detector".to_owned(),
4584 candidates: vec![first.clone()],
4585 };
4586 assert!(foreign
4587 .validate()
4588 .expect_err("foreign detector candidate rejected")
4589 .to_string()
4590 .contains("returned candidate owned by"));
4591
4592 let yaml = candidate_plan(vec![first, second])
4593 .to_yaml()
4594 .expect("candidate plan serializes");
4595 assert!(yaml.contains("kind: independent"));
4596 assert!(yaml.contains("kind: excluded"));
4597 assert!(yaml.contains("raw-version:"));
4598 assert!(yaml.contains("diagnostics:"));
4599 }
4600
4601 #[test]
4602 fn changesets_projection_dedup_rejects_adapter_or_mode_conflicts() {
4603 for (adapter, mode) in [
4604 (Adapter::Cargo, ProjectionMode::Committed),
4605 (Adapter::Npm, ProjectionMode::Injected),
4606 ] {
4607 let mut config = candidate_plan(Vec::new()).inferred_config;
4608 let release_unit = config
4609 .release_units
4610 .get_mut("configured")
4611 .expect("configured release unit");
4612 release_unit.path = PathBuf::from("examples");
4613 release_unit.projections = vec![Projection {
4614 adapter: Adapter::Npm,
4615 file: PathBuf::from("sample.json"),
4616 mode: ProjectionMode::Committed,
4617 pointer: None,
4618 }];
4619 let mut candidate = candidate(
4620 "examples/sample.json",
4621 Some(CandidateResolution::Projection {
4622 release_unit: "configured".to_owned(),
4623 target_candidate: None,
4624 }),
4625 );
4626 let suggestion = candidate
4627 .projection
4628 .as_mut()
4629 .expect("projection suggestion");
4630 suggestion.adapter = adapter;
4631 suggestion.mode = mode;
4632 let mut discovery = Discovery {
4633 config,
4634 ..Discovery::default()
4635 };
4636
4637 let error = apply_changesets_candidate_resolutions(
4638 Path::new("."),
4639 &mut discovery,
4640 &[candidate],
4641 )
4642 .expect_err("same target with different semantics rejected");
4643 assert!(error
4644 .to_string()
4645 .contains("conflicts with existing projection"));
4646 }
4647 }
4648
4649 #[test]
4650 fn resolved_candidates_reject_genuine_version_conflicts() {
4651 let mut first = candidate(
4652 "examples/first.json",
4653 Some(CandidateResolution::Projection {
4654 release_unit: "configured".to_owned(),
4655 target_candidate: None,
4656 }),
4657 );
4658 first.raw_version.as_mut().expect("first version").value = "1.0.0".to_owned();
4659 let mut second = candidate(
4660 "examples/second.json",
4661 Some(CandidateResolution::Projection {
4662 release_unit: "configured".to_owned(),
4663 target_candidate: None,
4664 }),
4665 );
4666 second.raw_version.as_mut().expect("second version").value = "2.0.0".to_owned();
4667 let mut discovery = Discovery {
4668 config: candidate_plan(Vec::new()).inferred_config,
4669 ..Discovery::default()
4670 };
4671
4672 let error = recompute_resolved_versions(&mut discovery, &[first, second])
4673 .expect_err("resolved version conflict");
4674 assert!(error
4675 .to_string()
4676 .contains("disagree on current version: 1.0.0, 2.0.0"));
4677 }
4678
4679 #[test]
4680 fn changesets_projection_rejects_an_unrelated_existing_owner() {
4681 let config = Config::from_yaml(
4682 r#"contract: contract-1
4683release-units:
4684 alpha:
4685 path: examples
4686 tags:
4687 primary: { role: primary, template: '{id}@{version}' }
4688 gamma:
4689 path: examples
4690 projections:
4691 - adapter: json
4692 file: shared.json
4693 mode: committed
4694 pointer: /version
4695 tags:
4696 primary: { role: primary, template: 'gamma@{version}' }
4697"#,
4698 )
4699 .expect("unrelated-owner config");
4700 let mut candidate = candidate(
4701 "examples/shared.json",
4702 Some(CandidateResolution::Projection {
4703 release_unit: "alpha".to_owned(),
4704 target_candidate: None,
4705 }),
4706 );
4707 candidate.projection = Some(CandidateProjectionSuggestion {
4708 adapter: Adapter::Json,
4709 path: candidate.path.clone(),
4710 mode: ProjectionMode::Committed,
4711 pointer: Some("/version".to_owned()),
4712 });
4713 let candidate_id = candidate.id.clone();
4714 let mut discovery = Discovery {
4715 config,
4716 ..Discovery::default()
4717 };
4718
4719 let error =
4720 apply_changesets_candidate_resolutions(Path::new("."), &mut discovery, &[candidate])
4721 .expect_err("unrelated projection ownership rejected");
4722 assert_eq!(
4723 error.to_string(),
4724 format!(
4725 "validation failed: Changesets discovery candidate {candidate_id} projection is already owned by release unit gamma, not alpha"
4726 )
4727 );
4728 }
4729
4730 #[test]
4731 fn rejects_plan_without_required_discovery_candidates() {
4732 let yaml = candidate_plan(Vec::new())
4733 .to_yaml()
4734 .expect("complete plan serializes");
4735 assert!(yaml.contains("discovery-candidates: []"));
4736 let incomplete = yaml.replace("discovery-candidates: []\n", "");
4737 let error = serde_yaml::from_str::<InitPlan>(&incomplete)
4738 .expect_err("missing discovery candidate collection rejected");
4739 assert!(error
4740 .to_string()
4741 .contains("missing field `discovery-candidates`"));
4742 }
4743
4744 #[test]
4745 fn empty_inferred_config_obeys_its_published_schema_shape() {
4746 let mut plan = candidate_plan(vec![candidate("examples/sample.json", None)]);
4747 plan.inferred_config = Config::default();
4748 plan.validate().expect("schema-compatible empty config");
4749
4750 plan.inferred_config
4751 .discovery
4752 .excluded_paths
4753 .push(ExcludedPathReceipt {
4754 detector: "sample-detector".to_owned(),
4755 path: PathBuf::from("examples/sample.json"),
4756 evidence_digest: DIGEST.to_owned(),
4757 });
4758 assert!(plan
4759 .validate()
4760 .expect_err("discovery is forbidden on an empty inferred config")
4761 .to_string()
4762 .contains("may only contain schema, contract, settings, and release-units"));
4763
4764 plan.inferred_config.discovery.excluded_paths.clear();
4765 plan.inferred_config.schema = Some("https://example.invalid/config.yml".to_owned());
4766 assert!(plan
4767 .validate()
4768 .expect_err("foreign schema is forbidden on an empty inferred config")
4769 .to_string()
4770 .contains("schema must be"));
4771 }
4772
4773 #[test]
4774 fn candidate_tag_templates_use_the_shared_contract() {
4775 for template in ["v{version}", "{version}-{version}", "{id}-{id}@{version}"] {
4776 let mut invalid = candidate("examples/template.json", None);
4777 invalid.tag.as_mut().expect("tag suggestion").template = template.to_owned();
4778 assert!(candidate_plan(vec![invalid])
4779 .validate()
4780 .expect_err("invalid candidate tag template rejected")
4781 .to_string()
4782 .contains("tag template"));
4783 }
4784 }
4785
4786 #[test]
4787 fn published_init_schema_carries_candidate_resolution_variants() {
4788 let schema: serde_yaml::Value =
4789 serde_yaml::from_str(include_str!("../../../schemas/init-plan.yml"))
4790 .expect("initialization-plan schema parses");
4791 assert!(schema["required"]
4792 .as_sequence()
4793 .expect("required plan fields")
4794 .iter()
4795 .any(|field| field.as_str() == Some("discovery-candidates")));
4796 let variants = schema["$defs"]["candidate-resolution"]["oneOf"]
4797 .as_sequence()
4798 .expect("candidate resolution variants");
4799 assert_eq!(variants.len(), 3);
4800 let kinds = variants
4801 .iter()
4802 .map(|variant| {
4803 variant["properties"]["kind"]["const"]
4804 .as_str()
4805 .expect("resolution kind")
4806 })
4807 .collect::<BTreeSet<_>>();
4808 assert_eq!(
4809 kinds,
4810 BTreeSet::from(["excluded", "independent", "projection"])
4811 );
4812 assert_eq!(
4813 schema["$defs"]["discovery-candidate"]["properties"]["tag"]["properties"]["template"]
4814 ["pattern"]
4815 .as_str(),
4816 Some(
4817 r"^(?![\s\S]*v\{version\})(?![\s\S]*\{version\}[\s\S]*\{version\})(?![\s\S]*\{id\}[\s\S]*\{id\})(?=[\s\S]*\{version\})[\s\S]*$"
4818 )
4819 );
4820 }
4821
4822 #[test]
4823 fn accepts_configured_and_same_plan_projection_targets() {
4824 let configured = candidate(
4825 "examples/configured.json",
4826 Some(CandidateResolution::Projection {
4827 release_unit: "configured".to_owned(),
4828 target_candidate: None,
4829 }),
4830 );
4831 let independent = candidate(
4832 "examples/independent.json",
4833 Some(CandidateResolution::Independent {
4834 release_unit: "planned".to_owned(),
4835 }),
4836 );
4837 let projection = candidate(
4838 "examples/projection.json",
4839 Some(CandidateResolution::Projection {
4840 release_unit: "planned".to_owned(),
4841 target_candidate: Some(independent.id.clone()),
4842 }),
4843 );
4844 candidate_plan(vec![configured, independent, projection])
4845 .validate()
4846 .expect("both projection target kinds accepted");
4847 }
4848
4849 #[test]
4850 fn changesets_materializes_any_unrepresented_candidate_projection_once() {
4851 let config = candidate_plan(Vec::new()).inferred_config;
4852 let mut generic = candidate(
4853 "configured/metadata.json",
4854 Some(CandidateResolution::Projection {
4855 release_unit: "configured".to_owned(),
4856 target_candidate: None,
4857 }),
4858 );
4859 generic.projection = Some(CandidateProjectionSuggestion {
4860 adapter: Adapter::Json,
4861 path: generic.path.clone(),
4862 mode: ProjectionMode::Committed,
4863 pointer: Some("/version".to_owned()),
4864 });
4865
4866 let mut discovery = Discovery {
4867 config,
4868 ..Discovery::default()
4869 };
4870 apply_changesets_candidate_resolutions(
4871 Path::new("."),
4872 &mut discovery,
4873 std::slice::from_ref(&generic),
4874 )
4875 .expect("generic projection materialized");
4876 assert_eq!(
4877 discovery.config.release_units["configured"]
4878 .projections
4879 .len(),
4880 1
4881 );
4882 assert_eq!(
4883 discovery.config.release_units["configured"].projections[0]
4884 .pointer
4885 .as_deref(),
4886 Some("/version")
4887 );
4888
4889 apply_changesets_candidate_resolutions(
4890 Path::new("."),
4891 &mut discovery,
4892 std::slice::from_ref(&generic),
4893 )
4894 .expect("represented projection not duplicated");
4895 assert_eq!(
4896 discovery.config.release_units["configured"]
4897 .projections
4898 .len(),
4899 1
4900 );
4901 }
4902
4903 #[test]
4904 fn rejects_duplicate_creators_and_absent_projection_targets() {
4905 let first = candidate(
4906 "examples/first.json",
4907 Some(CandidateResolution::Independent {
4908 release_unit: "duplicate".to_owned(),
4909 }),
4910 );
4911 let second = candidate(
4912 "examples/second.json",
4913 Some(CandidateResolution::Independent {
4914 release_unit: "duplicate".to_owned(),
4915 }),
4916 );
4917 assert!(candidate_plan(vec![first, second])
4918 .validate()
4919 .expect_err("duplicate creators rejected")
4920 .to_string()
4921 .contains("duplicate creator"));
4922
4923 let absent = candidate(
4924 "examples/absent.json",
4925 Some(CandidateResolution::Projection {
4926 release_unit: "missing".to_owned(),
4927 target_candidate: None,
4928 }),
4929 );
4930 assert!(candidate_plan(vec![absent])
4931 .validate()
4932 .expect_err("absent configured target rejected")
4933 .to_string()
4934 .contains("absent configured release unit"));
4935 }
4936
4937 #[test]
4938 fn rejects_projection_cycles_and_release_unit_mismatches() {
4939 let mut first = candidate("examples/first.json", None);
4940 let mut second = candidate("examples/second.json", None);
4941 first.resolution = Some(CandidateResolution::Projection {
4942 release_unit: "planned".to_owned(),
4943 target_candidate: Some(second.id.clone()),
4944 });
4945 second.resolution = Some(CandidateResolution::Projection {
4946 release_unit: "planned".to_owned(),
4947 target_candidate: Some(first.id.clone()),
4948 });
4949 assert!(candidate_plan(vec![first, second])
4950 .validate()
4951 .expect_err("candidate cycle rejected")
4952 .to_string()
4953 .contains("projection cycle"));
4954
4955 let independent = candidate(
4956 "examples/independent.json",
4957 Some(CandidateResolution::Independent {
4958 release_unit: "planned".to_owned(),
4959 }),
4960 );
4961 let mismatched = candidate(
4962 "examples/mismatched.json",
4963 Some(CandidateResolution::Projection {
4964 release_unit: "other".to_owned(),
4965 target_candidate: Some(independent.id.clone()),
4966 }),
4967 );
4968 assert!(candidate_plan(vec![independent, mismatched])
4969 .validate()
4970 .expect_err("mismatched target rejected")
4971 .to_string()
4972 .contains("target"));
4973
4974 let independent = candidate(
4975 "examples/independent.json",
4976 Some(CandidateResolution::Independent {
4977 release_unit: "planned".to_owned(),
4978 }),
4979 );
4980 let intermediate = candidate(
4981 "examples/intermediate.json",
4982 Some(CandidateResolution::Projection {
4983 release_unit: "planned".to_owned(),
4984 target_candidate: Some(independent.id.clone()),
4985 }),
4986 );
4987 let chained = candidate(
4988 "examples/chained.json",
4989 Some(CandidateResolution::Projection {
4990 release_unit: "planned".to_owned(),
4991 target_candidate: Some(intermediate.id.clone()),
4992 }),
4993 );
4994 assert!(candidate_plan(vec![independent, intermediate, chained])
4995 .validate()
4996 .expect_err("projection chain rejected")
4997 .to_string()
4998 .contains("not an independent creator"));
4999 }
5000}