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