1use crate::config::{
9 validate_detector_id, validate_exact_discovery_path, validate_sha256, validate_tag_template,
10 Config, ExcludedPathReceipt, ManagedPathReceipt, Projection, ProjectionFile, ReleaseUnitConfig,
11 TagConfig, 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: ProjectionFile::unit_relative(&file),
1288 mode: suggestion.mode,
1289 pointer: suggestion.pointer.clone(),
1290 })
1291}
1292
1293fn apply_changesets_candidate_resolutions(
1294 discovery: &mut Discovery,
1295 candidates: &[DiscoveryCandidate],
1296) -> Result<BTreeMap<String, String>> {
1297 let mut removed_release_units = BTreeSet::new();
1298 let mut identity_map = BTreeMap::new();
1299 for candidate in candidates {
1300 match &candidate.resolution {
1301 None => {}
1302 Some(CandidateResolution::Projection { release_unit, .. }) => {
1303 let projection_owner =
1304 candidate_projection_owner(&discovery.config, candidate).map(str::to_owned);
1305 let Some(target) = discovery.config.release_units.get(release_unit) else {
1306 return Err(Error::Validation(format!(
1307 "Changesets discovery candidate {} projects onto absent release unit {release_unit}",
1308 candidate.id
1309 )));
1310 };
1311 let target_path = target.path.clone();
1312 if candidate.projection.is_some() {
1313 if let Some(owner) = projection_owner.as_deref() {
1314 let native_owner = candidate.native_identity.as_deref() == Some(owner);
1315 if owner != release_unit && !native_owner {
1316 return Err(Error::Validation(format!(
1317 "Changesets discovery candidate {} projection is already owned by release unit {owner}, not {release_unit}",
1318 candidate.id
1319 )));
1320 }
1321 }
1322 let projection = candidate_projection(candidate, &target_path)?;
1323 let target = discovery
1324 .config
1325 .release_units
1326 .get_mut(release_unit)
1327 .expect("validated target release unit");
1328 if let Some(existing) = target.projections.iter().find(|existing| {
1329 existing.file == projection.file && existing.pointer == projection.pointer
1330 }) {
1331 if existing.adapter != projection.adapter
1332 || existing.mode != projection.mode
1333 {
1334 return Err(Error::Validation(format!(
1335 "Changesets discovery candidate {} conflicts with existing projection {} on release unit {release_unit}: adapter or mode differs",
1336 candidate.id,
1337 projection.file
1338 )));
1339 }
1340 } else {
1341 target.projections.push(projection);
1342 }
1343 }
1344 if let Some(native_identity) = &candidate.native_identity {
1345 if native_identity != release_unit {
1346 if let Some(existing) =
1347 identity_map.insert(native_identity.clone(), release_unit.clone())
1348 {
1349 if existing != *release_unit {
1350 return Err(Error::Validation(format!(
1351 "Changesets discovery candidates map native identity {native_identity} to both {existing} and {release_unit}"
1352 )));
1353 }
1354 }
1355 if let Some(source) =
1356 discovery.config.release_units.get_mut(native_identity)
1357 {
1358 source.projections.retain(|projection| {
1359 projection.file.workspace_path(&source.path) != candidate.path
1360 });
1361 if source.projections.is_empty() {
1362 removed_release_units.insert(native_identity.clone());
1363 }
1364 }
1365 }
1366 }
1367 discovery
1368 .config
1369 .discovery
1370 .managed_paths
1371 .push(ManagedPathReceipt {
1372 detector: candidate.detector.clone(),
1373 path: candidate.path.clone(),
1374 release_unit: release_unit.clone(),
1375 });
1376 }
1377 Some(CandidateResolution::Excluded) => {
1378 if let Some(native_identity) = &candidate.native_identity {
1379 if let Some(unit) = discovery.config.release_units.get_mut(native_identity) {
1380 let unit_path = unit.path.clone();
1381 unit.projections.retain(|projection| {
1382 projection.file.workspace_path(&unit_path) != candidate.path
1383 });
1384 if unit.projections.is_empty() {
1385 removed_release_units.insert(native_identity.clone());
1386 }
1387 }
1388 }
1389 let digest = candidate
1390 .evidence
1391 .iter()
1392 .find(|item| item.path == candidate.path)
1393 .expect("candidate path evidence validated")
1394 .digest
1395 .clone();
1396 discovery
1397 .config
1398 .discovery
1399 .excluded_paths
1400 .push(ExcludedPathReceipt {
1401 detector: candidate.detector.clone(),
1402 path: candidate.path.clone(),
1403 evidence_digest: digest,
1404 });
1405 }
1406 Some(CandidateResolution::Independent { release_unit }) => {
1407 return Err(Error::Validation(format!(
1408 "Changesets already establishes release unit {release_unit}; candidate {} must project onto it or be excluded",
1409 candidate.id
1410 )));
1411 }
1412 }
1413 }
1414 for release_unit in &removed_release_units {
1415 discovery.config.release_units.remove(release_unit);
1416 }
1417 for (source, target) in &identity_map {
1418 if discovery.config.release_units.contains_key(source) {
1419 return Err(Error::Validation(format!(
1420 "Changesets discovery candidates leave native identity {source} split between release units {source} and {target}; resolve every projection for that identity consistently"
1421 )));
1422 }
1423 if discovery.workspace_packages.remove(source) {
1424 discovery.workspace_packages.insert(target.clone());
1425 }
1426 if discovery.private_packages.remove(source) {
1427 discovery.private_packages.insert(target.clone());
1428 }
1429 discovery.versions.remove(source);
1430 }
1431 for unit in discovery.config.release_units.values_mut() {
1432 for dependency in &mut unit.depends_on {
1433 if let Some(target) = identity_map.get(dependency) {
1434 *dependency = target.clone();
1435 }
1436 }
1437 unit.depends_on
1438 .retain(|dependency| !removed_release_units.contains(dependency));
1439 unit.depends_on.sort();
1440 unit.depends_on.dedup();
1441 }
1442 for edge in &mut discovery.npm_dependencies {
1443 if let Some(target) = identity_map.get(&edge.dependent) {
1444 edge.dependent = target.clone();
1445 }
1446 if let Some(target) = identity_map.get(&edge.dependency) {
1447 edge.dependency = target.clone();
1448 }
1449 }
1450 discovery.npm_dependencies.retain(|edge| {
1451 edge.dependent != edge.dependency
1452 && discovery.config.release_units.contains_key(&edge.dependent)
1453 && discovery
1454 .config
1455 .release_units
1456 .contains_key(&edge.dependency)
1457 });
1458 retain_materialized_npm_dependencies(&mut discovery.config, &mut discovery.npm_dependencies);
1459 discovery
1460 .config
1461 .discovery
1462 .managed_paths
1463 .sort_by(|left, right| (&left.detector, &left.path).cmp(&(&right.detector, &right.path)));
1464 discovery
1465 .config
1466 .discovery
1467 .excluded_paths
1468 .sort_by(|left, right| (&left.detector, &left.path).cmp(&(&right.detector, &right.path)));
1469 recompute_resolved_versions(discovery, candidates)?;
1470 Ok(identity_map)
1471}
1472
1473fn recompute_resolved_versions(
1474 discovery: &mut Discovery,
1475 candidates: &[DiscoveryCandidate],
1476) -> Result<()> {
1477 let mut versions = BTreeMap::<String, BTreeSet<Version>>::new();
1478 let mut unresolved = BTreeSet::new();
1479 for candidate in candidates {
1480 let Some(raw_version) = candidate.raw_version.as_ref() else {
1481 continue;
1482 };
1483 let Some(native_identity) = candidate.native_identity.as_ref() else {
1484 continue;
1485 };
1486 let release_unit = match candidate.resolution.as_ref() {
1487 Some(CandidateResolution::Projection { release_unit, .. })
1488 | Some(CandidateResolution::Independent { release_unit }) => release_unit,
1489 Some(CandidateResolution::Excluded) => continue,
1490 None => {
1491 unresolved.insert(native_identity.clone());
1492 native_identity
1493 }
1494 };
1495 if !discovery.config.release_units.contains_key(release_unit) {
1496 continue;
1497 }
1498 if let Ok(version) = Version::parse(&raw_version.value) {
1499 versions
1500 .entry(release_unit.clone())
1501 .or_default()
1502 .insert(version);
1503 }
1504 }
1505
1506 discovery.versions.clear();
1507 for (release_unit, observed) in versions {
1508 if observed.len() == 1 {
1509 discovery.versions.insert(
1510 release_unit,
1511 observed.into_iter().next().expect("one observed version"),
1512 );
1513 } else if !unresolved.contains(&release_unit) {
1514 return Err(Error::Validation(format!(
1515 "resolved discovery candidates for release unit {release_unit} disagree on current version: {}",
1516 observed
1517 .into_iter()
1518 .map(|version| version.to_string())
1519 .collect::<Vec<_>>()
1520 .join(", ")
1521 )));
1522 }
1523 }
1524 Ok(())
1525}
1526
1527fn candidate_projection_owner<'a>(
1528 config: &'a Config,
1529 candidate: &DiscoveryCandidate,
1530) -> Option<&'a str> {
1531 let Some(suggestion) = &candidate.projection else {
1532 return None;
1533 };
1534 config.release_units.iter().find_map(|(id, unit)| {
1535 unit.projections
1536 .iter()
1537 .any(|projection| {
1538 projection.file.workspace_path(&unit.path) == suggestion.path
1539 && projection.pointer == suggestion.pointer
1540 })
1541 .then_some(id.as_str())
1542 })
1543}
1544
1545fn changesets_plan(root: &Path, take_over: bool) -> Result<InitResult> {
1546 let previous = load_previous_plan(root)?;
1547 let changesets_config_path = root.join(CHANGESETS_CONFIG);
1548 let changesets_text = std::fs::read_to_string(&changesets_config_path)
1549 .map_err(|error| Error::io(&changesets_config_path, error))?;
1550 let changesets: JsonValue = serde_json::from_str(&changesets_text)
1551 .map_err(|error| Error::Validation(format!("invalid Changesets config: {error}")))?;
1552 let mut converted_intents = load_changesets_intents(root)?;
1553 let mut referenced_names = converted_intents
1554 .iter()
1555 .flat_map(|intent| intent.release_units.keys().cloned())
1556 .collect::<BTreeSet<_>>();
1557 for key in ["ignore", "fixed", "linked"] {
1558 collect_json_strings(&changesets[key], &mut referenced_names);
1559 }
1560 let mut discovery = discover(root)?;
1561 reconcile_candidate_resolutions(&mut discovery.candidates, previous.as_ref());
1562 let candidates = discovery.candidates.clone();
1563 let identity_map = apply_changesets_candidate_resolutions(&mut discovery, &candidates)?;
1564 discovery.config.settings.pre_1_0_bump_mapping = Pre1BumpMapping::Component;
1565 discovery.config.settings.internal_dependency_bump = changesets["updateInternalDependencies"]
1566 .as_str()
1567 .and_then(|value| value.parse().ok())
1568 .unwrap_or(Bump::Patch);
1569 discovery.config.fixed = parse_groups(&changesets["fixed"])?;
1570 discovery.config.linked = parse_groups(&changesets["linked"])?;
1571 remap_groups(&mut discovery.config.fixed, &identity_map);
1572 remap_groups(&mut discovery.config.linked, &identity_map);
1573 remap_converted_intents(&mut converted_intents, &identity_map, &discovery.config)?;
1574
1575 let mut diagnostics = Vec::new();
1576 let config_evidence = evidence(root, Path::new(CHANGESETS_CONFIG), Vec::new())?;
1577 for ignored in changesets["ignore"].as_array().into_iter().flatten() {
1578 let Some(source_package) = ignored.as_str() else {
1579 continue;
1580 };
1581 diagnostics.push(InitDiagnostic {
1582 id: format!("ignored-release-unit-disposition:{source_package}"),
1583 code: "ignored-release-unit-disposition".to_owned(),
1584 message: format!(
1585 "Choose whether Changesets-ignored package {source_package} is suspended, excluded, or managed. Selecting managed requires removing it from Changesets ignore before takeover."
1586 ),
1587 evidence: vec![config_evidence.clone()],
1588 choices: vec!["suspended".to_owned(), "excluded".to_owned(), "managed".to_owned()],
1589 recommended: Some("suspended".to_owned()),
1590 resolution: None,
1591 verified: false,
1592 invalidated_resolution: false,
1593 supporting_evidence: Vec::new(),
1594 contradictory_evidence: Vec::new(),
1595 uncertainty: None,
1596 });
1597 }
1598 if let Some((versions_private, tags_private)) =
1599 changesets_private_package_settings(&changesets["privatePackages"])
1600 {
1601 diagnostics.push(InitDiagnostic {
1602 id: "private-package-versioning".to_owned(),
1603 code: "private-package-versioning".to_owned(),
1604 message: if versions_private {
1605 "Changesets versions private packages; Intentional preserves that behavior because package privacy is independent from version management.".to_owned()
1606 } else {
1607 "Changesets suppresses private-package versions; Intentional manages versions independently from publication privacy, so accepting Intentional semantics is a deliberate contract change.".to_owned()
1608 },
1609 evidence: vec![config_evidence.clone()],
1610 choices: if versions_private {
1611 Vec::new()
1612 } else {
1613 vec!["intentional".to_owned()]
1614 },
1615 recommended: (!versions_private).then(|| "intentional".to_owned()),
1616 resolution: None,
1617 verified: versions_private,
1618 invalidated_resolution: false,
1619 supporting_evidence: Vec::new(),
1620 contradictory_evidence: Vec::new(),
1621 uncertainty: None,
1622 });
1623 if !tags_private {
1624 diagnostics.push(InitDiagnostic {
1625 id: "private-package-tagging".to_owned(),
1626 code: "private-package-tagging".to_owned(),
1627 message: "Changesets suppresses private-package tags; Intentional creates annotated records for every managed release unit, independently from publication privacy.".to_owned(),
1628 evidence: vec![config_evidence.clone()],
1629 choices: vec!["intentional".to_owned()],
1630 recommended: Some("intentional".to_owned()),
1631 resolution: None,
1632 verified: false,
1633 invalidated_resolution: false,
1634 supporting_evidence: Vec::new(),
1635 contradictory_evidence: Vec::new(),
1636 uncertainty: None,
1637 });
1638 }
1639 }
1640 if changesets["changelog"] != JsonValue::Bool(false) && !changesets["changelog"].is_null() {
1641 diagnostics.push(InitDiagnostic {
1642 id: "changesets-changelog".to_owned(),
1643 code: "changesets-changelog".to_owned(),
1644 message: "Intentional renders its contract-defined release-unit changelogs instead of invoking the configured Changesets changelog generator.".to_owned(),
1645 evidence: vec![config_evidence.clone()],
1646 choices: vec!["intentional".to_owned()],
1647 recommended: Some("intentional".to_owned()),
1648 resolution: None,
1649 verified: false,
1650 invalidated_resolution: false,
1651 supporting_evidence: Vec::new(),
1652 contradictory_evidence: Vec::new(),
1653 uncertainty: None,
1654 });
1655 }
1656 if changesets["commit"] != JsonValue::Bool(false) && !changesets["commit"].is_null() {
1657 diagnostics.push(InitDiagnostic {
1658 id: "changesets-commit".to_owned(),
1659 code: "changesets-commit".to_owned(),
1660 message: "Intentional never creates commits; repository orchestration must retain the configured commit behavior externally.".to_owned(),
1661 evidence: vec![config_evidence.clone()],
1662 choices: vec!["external".to_owned()],
1663 recommended: Some("external".to_owned()),
1664 resolution: None,
1665 verified: false,
1666 invalidated_resolution: false,
1667 supporting_evidence: Vec::new(),
1668 contradictory_evidence: Vec::new(),
1669 uncertainty: None,
1670 });
1671 }
1672 if changesets["___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH"]
1673 ["onlyUpdatePeerDependentsWhenOutOfRange"]
1674 .as_bool()
1675 == Some(true)
1676 {
1677 diagnostics.push(InitDiagnostic {
1678 id: "changesets-peer-dependent-policy".to_owned(),
1679 code: "changesets-peer-dependent-policy".to_owned(),
1680 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(),
1681 evidence: vec![config_evidence.clone()],
1682 choices: vec!["intentional".to_owned()],
1683 recommended: Some("intentional".to_owned()),
1684 resolution: None,
1685 verified: false,
1686 invalidated_resolution: false,
1687 supporting_evidence: Vec::new(),
1688 contradictory_evidence: Vec::new(),
1689 uncertainty: None,
1690 });
1691 }
1692 let dev_dependents = discovery
1693 .npm_dependencies
1694 .iter()
1695 .filter(|edge| edge.kind == NpmDependencyKind::Dev)
1696 .map(|edge| edge.dependent.clone())
1697 .collect::<BTreeSet<_>>();
1698 if !dev_dependents.is_empty() {
1699 let evidence = dev_dependents
1700 .iter()
1701 .filter_map(|id| {
1702 let release_unit = &discovery.config.release_units[id];
1703 release_unit
1704 .projections
1705 .iter()
1706 .find(|projection| projection.adapter == Adapter::Npm)
1707 .map(|projection| projection.file.workspace_path(&release_unit.path))
1708 })
1709 .map(|path| evidence(root, &path, Vec::new()))
1710 .collect::<Result<Vec<_>>>()?;
1711 diagnostics.push(InitDiagnostic {
1712 id: "changesets-dev-dependency-policy".to_owned(),
1713 code: "changesets-dev-dependency-policy".to_owned(),
1714 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(),
1715 evidence,
1716 choices: vec!["intentional".to_owned()],
1717 recommended: Some("intentional".to_owned()),
1718 resolution: None,
1719 verified: false,
1720 invalidated_resolution: false,
1721 supporting_evidence: Vec::new(),
1722 contradictory_evidence: Vec::new(),
1723 uncertainty: None,
1724 });
1725 }
1726 diagnostics.extend(release_tool_proxy_diagnostics(
1727 root,
1728 &discovery.candidates,
1729 &referenced_names,
1730 )?);
1731 let mut unmapped_release_units = BTreeSet::new();
1732 for release_unit in discovery.config.release_units.keys() {
1733 if referenced_names.contains(release_unit)
1734 || discovery.workspace_packages.contains(release_unit)
1735 || identity_map.values().any(|target| target == release_unit)
1736 {
1737 continue;
1738 }
1739 unmapped_release_units.insert(release_unit.clone());
1740 let release_unit_config = &discovery.config.release_units[release_unit];
1741 let manifest = release_unit_config
1742 .projections
1743 .first()
1744 .map(|projection| projection.file.workspace_path(&release_unit_config.path))
1745 .ok_or_else(|| {
1746 Error::Validation(format!(
1747 "discovered release unit {release_unit} has no manifest evidence"
1748 ))
1749 })?;
1750 diagnostics.push(InitDiagnostic {
1751 id: format!("unmapped-release-unit-disposition:{release_unit}"),
1752 code: "unmapped-release-unit-disposition".to_owned(),
1753 message: format!(
1754 "Choose whether workspace package {release_unit}, which is outside the Changesets release inventory, is excluded, suspended, or managed."
1755 ),
1756 evidence: vec![evidence(root, &manifest, Vec::new())?],
1757 choices: vec!["excluded".to_owned(), "suspended".to_owned(), "managed".to_owned()],
1758 recommended: Some("excluded".to_owned()),
1759 resolution: None,
1760 verified: false,
1761 invalidated_resolution: false,
1762 supporting_evidence: Vec::new(),
1763 contradictory_evidence: Vec::new(),
1764 uncertainty: None,
1765 });
1766 }
1767 let current_integrations = scan_changesets_integrations(root)?;
1768 for item in ¤t_integrations {
1769 diagnostics.push(InitDiagnostic {
1770 id: format!("repository-integration:{}", item.path.display()),
1771 code: "repository-integration".to_owned(),
1772 message: format!(
1773 "Remove or replace executable Changesets references in {} and rerun init for verification.",
1774 item.path.display()
1775 ),
1776 evidence: vec![item.clone()],
1777 choices: vec!["removed".to_owned()],
1778 recommended: Some("removed".to_owned()),
1779 resolution: None,
1780 verified: false,
1781 invalidated_resolution: false,
1782 supporting_evidence: Vec::new(),
1783 contradictory_evidence: Vec::new(),
1784 uncertainty: None,
1785 });
1786 }
1787 let integration_paths = current_integrations
1788 .iter()
1789 .map(|item| item.path.as_path())
1790 .collect::<BTreeSet<_>>();
1791 for item in scan_external_release_evidence(root)?
1792 .into_iter()
1793 .filter(|item| !integration_paths.contains(item.path.as_path()))
1794 {
1795 diagnostics.push(InitDiagnostic {
1796 id: format!("external-release-evidence:{}", item.path.display()),
1797 code: "external-release-evidence".to_owned(),
1798 message: format!(
1799 "Keep repository-specific release behavior from {} in the external release executor; Intentional records this file as evidence without interpreting its contents.",
1800 item.path.display()
1801 ),
1802 evidence: vec![item],
1803 choices: vec!["external".to_owned()],
1804 recommended: Some("external".to_owned()),
1805 resolution: None,
1806 verified: false,
1807 invalidated_resolution: false,
1808 supporting_evidence: Vec::new(),
1809 contradictory_evidence: Vec::new(),
1810 uncertainty: None,
1811 });
1812 }
1813 reconcile_diagnostics(
1814 root,
1815 &mut diagnostics,
1816 previous.as_ref(),
1817 &discovery.candidates,
1818 )?;
1819 let mut source_config = discovery.config.clone();
1820 let ignored = changesets["ignore"]
1821 .as_array()
1822 .into_iter()
1823 .flatten()
1824 .filter_map(JsonValue::as_str)
1825 .map(str::to_owned)
1826 .collect::<BTreeSet<_>>();
1827 let source_excluded = ignored
1828 .iter()
1829 .cloned()
1830 .chain(unmapped_release_units.iter().cloned())
1831 .collect();
1832 exclude_release_units(&mut source_config, &source_excluded);
1833 apply_disposition_resolutions(&mut discovery.config, &diagnostics)?;
1834 let proxy_removals = apply_proxy_manifest_resolutions(
1835 &mut discovery.config,
1836 &diagnostics,
1837 &discovery.candidates,
1838 )?;
1839 retain_materialized_npm_dependencies(&mut discovery.config, &mut discovery.npm_dependencies);
1840 discovery.config.validate()?;
1841
1842 let declared: BTreeMap<String, Bump> =
1843 converted_intents
1844 .iter()
1845 .fold(BTreeMap::new(), |mut aggregate, intent| {
1846 for (id, bump) in &intent.release_units {
1847 aggregate
1848 .entry(id.clone())
1849 .and_modify(|existing| *existing = (*existing).max(*bump))
1850 .or_insert(*bump);
1851 }
1852 aggregate
1853 });
1854 let suppress_private_versions =
1855 changesets_private_package_settings(&changesets["privatePackages"])
1856 .is_some_and(|(versions_private, _)| !versions_private);
1857 let merged_ignored = ignored
1858 .iter()
1859 .filter_map(|source| {
1860 identity_map
1861 .get(source)
1862 .map(|target| (source.clone(), target.clone()))
1863 })
1864 .collect::<Vec<_>>();
1865 let merged_ignore_error = (!merged_ignored.is_empty()).then(|| {
1866 let conflicts = merged_ignored
1867 .iter()
1868 .map(|(source, target)| format!("{source} onto {target}"))
1869 .collect::<Vec<_>>()
1870 .join(", ");
1871 format!(
1872 "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"
1873 )
1874 });
1875 let mut skipped_release_units = ignored.clone();
1876 if suppress_private_versions {
1877 skipped_release_units.extend(discovery.private_packages.iter().cloned());
1878 }
1879 skipped_release_units.extend(
1880 discovery
1881 .config
1882 .release_units
1883 .keys()
1884 .filter(|id| !discovery.versions.contains_key(*id))
1885 .cloned(),
1886 );
1887 let source_semantics = ChangesetsSourceSemantics {
1888 private_packages: &discovery.private_packages,
1889 suppress_private_versions,
1890 npm_dependencies: &discovery.npm_dependencies,
1891 internal_dependency_bump: discovery.config.settings.internal_dependency_bump,
1892 update_internal_dependents_always: changesets
1893 ["___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH"]["updateInternalDependents"]
1894 .as_str()
1895 == Some("always"),
1896 only_update_peer_dependents_when_out_of_range: changesets
1897 ["___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH"]
1898 ["onlyUpdatePeerDependentsWhenOutOfRange"]
1899 .as_bool()
1900 .unwrap_or(false),
1901 preflight_error: merged_ignore_error
1902 .or_else(|| mixed_skipped_changeset(&converted_intents, &skipped_release_units)),
1903 };
1904 let parity = parity_result(
1905 &source_config,
1906 &discovery.config,
1907 &discovery.versions,
1908 &declared,
1909 &source_semantics,
1910 )?;
1911 if let Some(message) = &parity.source_error {
1912 diagnostics.push(InitDiagnostic {
1913 id: "changesets-release-invalid".to_owned(),
1914 code: "changesets-release-invalid".to_owned(),
1915 message: format!("The source Changesets release is invalid: {message}"),
1916 evidence: vec![config_evidence.clone()],
1917 choices: Vec::new(),
1918 recommended: None,
1919 resolution: None,
1920 verified: false,
1921 invalidated_resolution: false,
1922 supporting_evidence: Vec::new(),
1923 contradictory_evidence: Vec::new(),
1924 uncertainty: None,
1925 });
1926 }
1927 if let Some(message) = &parity.proposed_error {
1928 diagnostics.push(InitDiagnostic {
1929 id: "proposed-release-invalid".to_owned(),
1930 code: "proposed-release-invalid".to_owned(),
1931 message: format!("The proposed Intentional release is invalid: {message}"),
1932 evidence: vec![config_evidence.clone()],
1933 choices: Vec::new(),
1934 recommended: None,
1935 resolution: None,
1936 verified: false,
1937 invalidated_resolution: false,
1938 supporting_evidence: Vec::new(),
1939 contradictory_evidence: Vec::new(),
1940 uncertainty: None,
1941 });
1942 diagnostics.sort_by(|left, right| left.id.cmp(&right.id));
1943 }
1944 let unresolved = discovery
1945 .candidates
1946 .iter()
1947 .any(|candidate| candidate.resolution.is_none())
1948 || diagnostics.iter().any(|diagnostic| {
1949 !diagnostic.choices.is_empty()
1950 && (diagnostic.resolution.is_none() || !diagnostic.verified)
1951 });
1952 let state = if unresolved || parity.result.status != "equivalent" {
1953 InitState::NeedsInput
1954 } else {
1955 InitState::Ready
1956 };
1957
1958 let mut evidence_paths = discovery.evidence;
1959 evidence_paths.insert(PathBuf::from(CHANGESETS_CONFIG));
1960 for intent in &converted_intents {
1961 evidence_paths.insert(intent.source.clone());
1962 }
1963 for diagnostic in &diagnostics {
1964 for item in &diagnostic.evidence {
1965 evidence_paths.insert(item.path.clone());
1966 }
1967 }
1968 let source_fingerprint = fingerprint(root, &evidence_paths)?;
1969 let planned_operations = takeover_operations(root, &converted_intents, &proxy_removals);
1970 let mut plan = InitPlan {
1971 schema: INIT_PLAN_SCHEMA.to_owned(),
1972 state,
1973 source_kind: "changesets".to_owned(),
1974 source_fingerprint,
1975 inferred_config: discovery.config,
1976 discovery_candidates: discovery.candidates,
1977 diagnostics,
1978 converted_intents,
1979 parity: parity.result,
1980 planned_operations: planned_operations.clone(),
1981 post_commit_action: "intentional tag --baseline".to_owned(),
1982 };
1983 plan.state = state;
1985 let contents = plan.to_yaml()?;
1986
1987 if take_over {
1988 if state != InitState::Ready {
1989 return Err(Error::Validation(
1990 "takeover requires a ready initialization plan with verified resolutions and parity"
1991 .to_owned(),
1992 ));
1993 }
1994 let mut writes = vec![(PathBuf::from(CONFIG_PATH), plan.inferred_config.to_yaml()?)];
1995 for intent in &plan.converted_intents {
1996 writes.push((intent.target.clone(), intent.contents()?));
1997 }
1998 let deletes = takeover_deletes(root, &proxy_removals);
1999 verify_proxy_removal_preconditions(root, &proxy_removals)?;
2000 verify_takeover_preconditions(root, &writes, &plan.source_fingerprint, &evidence_paths)?;
2001 let takeover_evidence = Some((plan.source_fingerprint.clone(), evidence_paths));
2002 return Ok(InitResult {
2003 state: InitState::Success,
2004 path: PathBuf::from(CONFIG_PATH),
2005 contents: plan.inferred_config.to_yaml()?,
2006 operations: planned_operations,
2007 plan: Some(plan),
2008 writes,
2009 deletes,
2010 proxy_removals,
2011 takeover: true,
2012 takeover_evidence,
2013 });
2014 }
2015
2016 Ok(InitResult {
2017 state,
2018 path: PathBuf::from(INIT_PLAN_PATH),
2019 operations: vec![format!("write {INIT_PLAN_PATH}")],
2020 contents: contents.clone(),
2021 plan: Some(plan),
2022 writes: vec![(PathBuf::from(INIT_PLAN_PATH), contents)],
2023 deletes: Vec::new(),
2024 proxy_removals,
2025 takeover: false,
2026 takeover_evidence: None,
2027 })
2028}
2029
2030fn discover(root: &Path) -> Result<Discovery> {
2031 let workspace_paths = workspace_manifest_paths(root)?;
2032 let manifest_paths = all_manifest_paths(root)?;
2033 let mut discovery = Discovery {
2034 config: Config::default(),
2035 ..Discovery::default()
2036 };
2037 let mut observations = Vec::new();
2038 for path in manifest_paths {
2039 if let Some(candidate) = devcontainer_candidate(root, &path)? {
2040 discovery.evidence.insert(candidate.path.clone());
2041 if let (Some(native_identity), Some(projection), Some(tag)) = (
2042 candidate.native_identity.clone(),
2043 candidate.projection.clone(),
2044 candidate.tag.clone(),
2045 ) {
2046 observations.push(ManifestObservation {
2047 candidate_path: candidate.path.clone(),
2048 native_identity,
2049 private_package: false,
2050 projection,
2051 raw_version: candidate
2052 .raw_version
2053 .as_ref()
2054 .map(|version| version.value.clone()),
2055 tag,
2056 workspace_package: workspace_paths.contains(&path),
2057 });
2058 }
2059 discovery.candidates.push(candidate);
2060 continue;
2061 }
2062 let Some(adapter) = adapter_for(&path) else {
2063 continue;
2064 };
2065 if !is_project_manifest(&path, adapter)? {
2066 continue;
2067 }
2068 let (id, version) = manifest_identity(&path, adapter)?;
2069 let relative_manifest = path
2070 .strip_prefix(root)
2071 .map_err(|error| Error::Validation(format!("manifest is outside workspace: {error}")))?
2072 .to_owned();
2073 let manifest_evidence = evidence(root, &relative_manifest, Vec::new())?;
2074 let detector = detector_for(adapter).to_owned();
2075 let candidate = DiscoveryCandidate {
2076 id: DiscoveryCandidate::stable_id(&detector, &relative_manifest)?,
2077 detector,
2078 path: relative_manifest.clone(),
2079 evidence: vec![manifest_evidence.clone()],
2080 native_identity: Some(id.clone()),
2081 raw_version: version.as_ref().map(|value| RawVersionEvidence {
2082 value: value.clone(),
2083 evidence: vec![manifest_evidence],
2084 }),
2085 projection: Some(CandidateProjectionSuggestion {
2086 adapter,
2087 path: relative_manifest.clone(),
2088 mode: if adapter == Adapter::Go {
2089 ProjectionMode::None
2090 } else {
2091 ProjectionMode::Committed
2092 },
2093 pointer: None,
2094 }),
2095 tag: Some(CandidateTagSuggestion {
2096 id: "primary".to_owned(),
2097 role: TagRole::Primary,
2098 template: "{id}@{version}".to_owned(),
2099 }),
2100 diagnostics: Vec::new(),
2101 resolution: None,
2102 };
2103 observations.push(ManifestObservation {
2104 candidate_path: relative_manifest.clone(),
2105 native_identity: id,
2106 private_package: adapter == Adapter::Npm && npm_manifest_is_private(&path)?,
2107 projection: candidate
2108 .projection
2109 .clone()
2110 .expect("project manifest has projection"),
2111 raw_version: version,
2112 tag: candidate.tag.clone().expect("project manifest has tag"),
2113 workspace_package: workspace_paths.contains(&path),
2114 });
2115 discovery.candidates.push(candidate);
2116 discovery
2117 .evidence
2118 .insert(path.strip_prefix(root).unwrap_or(&path).to_owned());
2119 }
2120 materialize_discovery_inventory(&mut discovery, observations)?;
2121 discovery.npm_dependencies = derive_npm_dependencies(root, &mut discovery.config)?;
2122 discovery
2123 .candidates
2124 .sort_by(|left, right| left.path.cmp(&right.path));
2125 Ok(discovery)
2126}
2127
2128fn materialize_discovery_inventory(
2129 discovery: &mut Discovery,
2130 observations: Vec<ManifestObservation>,
2131) -> Result<()> {
2132 let mut by_identity = BTreeMap::<String, Vec<ManifestObservation>>::new();
2133 for observation in observations {
2134 by_identity
2135 .entry(observation.native_identity.clone())
2136 .or_default()
2137 .push(observation);
2138 }
2139 for (identity, mut observations) in by_identity {
2140 observations.sort_by(|left, right| left.candidate_path.cmp(&right.candidate_path));
2141 let directories = observations
2142 .iter()
2143 .map(|observation| {
2144 observation
2145 .candidate_path
2146 .parent()
2147 .filter(|path| !path.as_os_str().is_empty())
2148 .map(Path::to_path_buf)
2149 .unwrap_or_else(|| PathBuf::from("."))
2150 })
2151 .collect::<BTreeSet<_>>();
2152 let path = if directories.len() == 1 {
2153 directories.into_iter().next().expect("one directory")
2154 } else {
2155 PathBuf::from(".")
2156 };
2157 let projections = observations
2158 .iter()
2159 .map(|observation| -> Result<Projection> {
2160 let file = if path == Path::new(".") {
2161 observation.projection.path.clone()
2162 } else {
2163 observation
2164 .projection
2165 .path
2166 .strip_prefix(&path)
2167 .map_err(|_| {
2168 Error::Validation(format!(
2169 "candidate projection {} is outside native identity directory {}",
2170 observation.projection.path.display(),
2171 path.display()
2172 ))
2173 })?
2174 .to_owned()
2175 };
2176 Ok(Projection {
2177 adapter: observation.projection.adapter,
2178 file: ProjectionFile::unit_relative(&file),
2179 mode: observation.projection.mode,
2180 pointer: observation.projection.pointer.clone(),
2181 })
2182 })
2183 .collect::<Result<Vec<_>>>()?;
2184 let tag = observations
2185 .first()
2186 .map(|observation| &observation.tag)
2187 .expect("identity group is not empty");
2188 discovery.config.release_units.insert(
2189 identity.clone(),
2190 ReleaseUnitConfig {
2191 path,
2192 disposition: ReleaseUnitDisposition::Managed,
2193 projections,
2194 tags: BTreeMap::from([(
2195 tag.id.clone(),
2196 TagConfig {
2197 role: tag.role,
2198 template: tag.template.clone(),
2199 require_phase: None,
2200 tag_after: Vec::new(),
2201 },
2202 )]),
2203 depends_on: Vec::new(),
2204 },
2205 );
2206 let versions = observations
2207 .iter()
2208 .filter_map(|observation| observation.raw_version.as_deref())
2209 .filter_map(|version| Version::parse(version).ok())
2210 .collect::<BTreeSet<_>>();
2211 if versions.len() == 1 {
2212 discovery.versions.insert(
2213 identity.clone(),
2214 versions.into_iter().next().expect("one version"),
2215 );
2216 }
2217 if observations
2218 .iter()
2219 .any(|observation| observation.workspace_package)
2220 {
2221 discovery.workspace_packages.insert(identity.clone());
2222 }
2223 if observations
2224 .iter()
2225 .any(|observation| observation.private_package)
2226 {
2227 discovery.private_packages.insert(identity);
2228 }
2229 }
2230 Ok(())
2231}
2232
2233fn workspace_manifest_paths(root: &Path) -> Result<BTreeSet<PathBuf>> {
2234 let mut directories = BTreeSet::new();
2235 let mut found_workspace = false;
2236 let pnpm = root.join("pnpm-workspace.yaml");
2237 if pnpm.exists() {
2238 found_workspace = true;
2239 let text = std::fs::read_to_string(&pnpm).map_err(|error| Error::io(&pnpm, error))?;
2240 let value: serde_yaml::Value = serde_yaml::from_str(&text)?;
2241 expand_workspace_patterns(
2242 root,
2243 value["packages"]
2244 .as_sequence()
2245 .into_iter()
2246 .flatten()
2247 .filter_map(serde_yaml::Value::as_str),
2248 &mut directories,
2249 )?;
2250 }
2251 let package_json = root.join("package.json");
2252 if package_json.exists() {
2253 let text = std::fs::read_to_string(&package_json)
2254 .map_err(|error| Error::io(&package_json, error))?;
2255 let value: JsonValue = serde_json::from_str(&text)
2256 .map_err(|error| Error::Validation(format!("invalid package.json: {error}")))?;
2257 let workspaces = value["workspaces"]
2258 .as_array()
2259 .or_else(|| value["workspaces"]["packages"].as_array());
2260 if let Some(workspaces) = workspaces {
2261 found_workspace = true;
2262 expand_workspace_patterns(
2263 root,
2264 workspaces.iter().filter_map(JsonValue::as_str),
2265 &mut directories,
2266 )?;
2267 }
2268 }
2269 let cargo = root.join("Cargo.toml");
2270 if cargo.exists() {
2271 let text = std::fs::read_to_string(&cargo).map_err(|error| Error::io(&cargo, error))?;
2272 let document = text
2273 .parse::<toml_edit::DocumentMut>()
2274 .map_err(|error| Error::Validation(format!("invalid Cargo.toml: {error}")))?;
2275 if let Some(members) = document
2276 .get("workspace")
2277 .and_then(|workspace| workspace.get("members"))
2278 .and_then(toml_edit::Item::as_array)
2279 {
2280 found_workspace = true;
2281 expand_workspace_patterns(
2282 root,
2283 members.iter().filter_map(toml_edit::Value::as_str),
2284 &mut directories,
2285 )?;
2286 }
2287 }
2288 if !found_workspace {
2289 directories.insert(root.to_owned());
2290 }
2291 let mut paths = BTreeSet::new();
2292 for directory in directories {
2293 add_manifests_in_directory(&directory, &mut paths)?;
2294 }
2295 paths.retain(|path| !hard_excluded(root, path));
2296 remove_git_ignored(root, &mut paths)?;
2297 Ok(paths)
2298}
2299
2300fn expand_workspace_pattern(
2301 root: &Path,
2302 pattern: &str,
2303 directories: &mut BTreeSet<PathBuf>,
2304) -> Result<()> {
2305 let (excluded, pattern) = pattern
2306 .strip_prefix('!')
2307 .map_or((false, pattern), |pattern| (true, pattern));
2308 let absolute = root.join(pattern).to_string_lossy().to_string();
2309 for entry in glob(&absolute).map_err(|error| {
2310 Error::Validation(format!("invalid workspace pattern {pattern}: {error}"))
2311 })? {
2312 let path = entry
2313 .map_err(|error| Error::Validation(format!("workspace pattern failed: {error}")))?;
2314 if path.is_dir() {
2315 if excluded {
2316 directories.remove(&path);
2317 } else {
2318 directories.insert(path);
2319 }
2320 }
2321 }
2322 Ok(())
2323}
2324
2325fn expand_workspace_patterns<'a>(
2326 root: &Path,
2327 patterns: impl IntoIterator<Item = &'a str>,
2328 directories: &mut BTreeSet<PathBuf>,
2329) -> Result<()> {
2330 let patterns = patterns.into_iter().collect::<Vec<_>>();
2331 for excluded in [false, true] {
2332 for pattern in &patterns {
2333 if pattern.starts_with('!') == excluded {
2334 expand_workspace_pattern(root, pattern, directories)?;
2335 }
2336 }
2337 }
2338 Ok(())
2339}
2340
2341fn add_manifests_in_directory(directory: &Path, paths: &mut BTreeSet<PathBuf>) -> Result<()> {
2342 for name in [
2343 "package.json",
2344 "Cargo.toml",
2345 "pubspec.yaml",
2346 "pyproject.toml",
2347 "go.mod",
2348 DEVCONTAINER_FEATURE_MANIFEST,
2349 DEVCONTAINER_TEMPLATE_MANIFEST,
2350 ] {
2351 let path = directory.join(name);
2352 if path.is_file() {
2353 paths.insert(path);
2354 }
2355 }
2356 for entry in std::fs::read_dir(directory).map_err(|error| Error::io(directory, error))? {
2357 let path = entry.map_err(|error| Error::io(directory, error))?.path();
2358 if path
2359 .extension()
2360 .is_some_and(|extension| extension == "csproj")
2361 {
2362 paths.insert(path);
2363 }
2364 }
2365 Ok(())
2366}
2367
2368fn all_manifest_paths(root: &Path) -> Result<BTreeSet<PathBuf>> {
2369 let mut paths = WalkDir::new(root)
2370 .into_iter()
2371 .filter_entry(|entry| !hard_excluded(root, entry.path()))
2372 .filter_map(std::result::Result::ok)
2373 .filter(|entry| entry.file_type().is_file() && is_discoverable_manifest(entry.path()))
2374 .map(|entry| entry.into_path())
2375 .collect();
2376 remove_git_ignored(root, &mut paths)?;
2377 Ok(paths)
2378}
2379
2380fn hard_excluded(root: &Path, path: &Path) -> bool {
2381 path.strip_prefix(root)
2382 .unwrap_or(path)
2383 .components()
2384 .filter_map(|component| match component {
2385 std::path::Component::Normal(value) => value.to_str(),
2386 _ => None,
2387 })
2388 .any(|component| {
2389 matches!(
2390 component,
2391 ".git"
2392 | ".intentional"
2393 | "node_modules"
2394 | "target"
2395 | ".pnpm-store"
2396 | ".yarn"
2397 | ".npm"
2398 | ".cache"
2399 | ".venv"
2400 | "venv"
2401 | "__pycache__"
2402 | ".tox"
2403 | ".nox"
2404 | ".pytest_cache"
2405 | ".mypy_cache"
2406 | ".ruff_cache"
2407 | ".dart_tool"
2408 | ".pub-cache"
2409 | "obj"
2410 )
2411 })
2412}
2413
2414fn remove_git_ignored(root: &Path, paths: &mut BTreeSet<PathBuf>) -> Result<()> {
2415 if paths.is_empty() {
2416 return Ok(());
2417 }
2418 let relative_paths = paths
2419 .iter()
2420 .map(|path| path.strip_prefix(root).unwrap_or(path).to_owned())
2421 .collect::<Vec<_>>();
2422 let mut input = Vec::new();
2423 for path in &relative_paths {
2424 input.extend_from_slice(path.to_string_lossy().as_bytes());
2425 input.push(0);
2426 }
2427 let mut child = Command::new("git")
2428 .args(["check-ignore", "--stdin", "-z"])
2429 .current_dir(root)
2430 .stdin(Stdio::piped())
2431 .stdout(Stdio::piped())
2432 .stderr(Stdio::piped())
2433 .spawn()
2434 .map_err(|error| Error::io(root, error))?;
2435 let mut stdin = child.stdin.take().expect("piped git check-ignore stdin");
2436 let writer = std::thread::spawn(move || stdin.write_all(&input));
2437 let output = child
2438 .wait_with_output()
2439 .map_err(|error| Error::io(root, error))?;
2440 writer
2441 .join()
2442 .map_err(|_| Error::Validation("git check-ignore input writer panicked".to_owned()))?
2443 .map_err(|error| Error::io(root, error))?;
2444 if !matches!(output.status.code(), Some(0 | 1)) {
2445 return Err(Error::Validation(format!(
2446 "git check-ignore failed: {}",
2447 String::from_utf8_lossy(&output.stderr).trim()
2448 )));
2449 }
2450 let ignored = output
2451 .stdout
2452 .split(|byte| *byte == 0)
2453 .filter(|path| !path.is_empty())
2454 .map(|path| PathBuf::from(String::from_utf8_lossy(path).into_owned()))
2455 .collect::<BTreeSet<_>>();
2456 paths.retain(|path| {
2457 let relative = path.strip_prefix(root).unwrap_or(path);
2458 !ignored.contains(relative)
2459 });
2460 Ok(())
2461}
2462
2463fn detector_for(adapter: Adapter) -> &'static str {
2464 match adapter {
2465 Adapter::Npm => "npm-package",
2466 Adapter::Cargo => "cargo-package",
2467 Adapter::Go => "go-module",
2468 Adapter::Python => "python-project",
2469 Adapter::Msbuild => "msbuild-project",
2470 Adapter::Pub => "dart-package",
2471 Adapter::Json | Adapter::Toml | Adapter::Yaml => {
2472 unreachable!("generic adapters are not discovered")
2473 }
2474 }
2475}
2476
2477fn devcontainer_detector_for(path: &Path) -> Option<&'static str> {
2478 match path.file_name()?.to_str()? {
2479 DEVCONTAINER_FEATURE_MANIFEST => Some("devcontainer-feature"),
2480 DEVCONTAINER_TEMPLATE_MANIFEST => Some("devcontainer-template"),
2481 _ => None,
2482 }
2483}
2484
2485fn is_discoverable_manifest(path: &Path) -> bool {
2486 adapter_for(path).is_some() || devcontainer_detector_for(path).is_some()
2487}
2488
2489fn devcontainer_candidate(root: &Path, path: &Path) -> Result<Option<DiscoveryCandidate>> {
2490 let Some(detector) = devcontainer_detector_for(path) else {
2491 return Ok(None);
2492 };
2493 let relative = path
2494 .strip_prefix(root)
2495 .map_err(|error| Error::Validation(format!("manifest is outside workspace: {error}")))?
2496 .to_owned();
2497 let bytes = std::fs::read(path).map_err(|error| Error::io(path, error))?;
2498 let source = SourceEvidence {
2499 path: relative.clone(),
2500 digest: sha256_digest(&bytes),
2501 lines: Vec::new(),
2502 };
2503 let mut diagnostics = Vec::new();
2504 let value = match std::str::from_utf8(&bytes) {
2505 Ok(text) => serde_json::from_str::<JsonValue>(text).map_err(|error| error.to_string()),
2506 Err(error) => Err(error.to_string()),
2507 };
2508 let (native_identity, raw_version, projection, tag) = match value {
2509 Ok(value) => {
2510 let native_identity = non_empty_json_string(&value["id"]);
2511 if native_identity.is_none() {
2512 diagnostics.push(extraction_diagnostic(
2513 "identity-extraction",
2514 "devcontainer-id-unreadable",
2515 format!(
2516 "{} has no non-empty string id; the detector inspects only id for identity.",
2517 relative.display()
2518 ),
2519 &source,
2520 ));
2521 }
2522
2523 let version = non_empty_json_string(&value["version"]);
2524 if version.is_none() {
2525 diagnostics.push(extraction_diagnostic(
2526 "version-extraction",
2527 "devcontainer-version-unreadable",
2528 format!(
2529 "{} has no non-empty string version; the detector inspects only version for version evidence.",
2530 relative.display()
2531 ),
2532 &source,
2533 ));
2534 }
2535 let valid_version = version.as_deref().is_some_and(|version| {
2536 if let Err(error) = Version::parse(version) {
2537 diagnostics.push(extraction_diagnostic(
2538 "version-semver",
2539 "devcontainer-version-not-semver",
2540 format!(
2541 "{} version {version:?} is not Semantic Versioning 2.0.0: {error}.",
2542 relative.display()
2543 ),
2544 &source,
2545 ));
2546 false
2547 } else {
2548 true
2549 }
2550 });
2551 let raw_version = version.map(|value| RawVersionEvidence {
2552 value,
2553 evidence: vec![source.clone()],
2554 });
2555 let projection = valid_version.then(|| CandidateProjectionSuggestion {
2556 adapter: Adapter::Json,
2557 path: relative.clone(),
2558 mode: ProjectionMode::Committed,
2559 pointer: Some("/version".to_owned()),
2560 });
2561 let tag = native_identity.as_ref().map(|_| CandidateTagSuggestion {
2562 id: "primary".to_owned(),
2563 role: TagRole::Primary,
2564 template: "{id}@{version}".to_owned(),
2565 });
2566 (native_identity, raw_version, projection, tag)
2567 }
2568 Err(error) => {
2569 diagnostics.push(extraction_diagnostic(
2570 "manifest-extraction",
2571 "devcontainer-json-unreadable",
2572 format!(
2573 "{} could not be read as JSON for id and version extraction: {error}.",
2574 relative.display()
2575 ),
2576 &source,
2577 ));
2578 (None, None, None, None)
2579 }
2580 };
2581
2582 Ok(Some(DiscoveryCandidate {
2583 id: DiscoveryCandidate::stable_id(detector, &relative)?,
2584 detector: detector.to_owned(),
2585 path: relative,
2586 evidence: vec![source],
2587 native_identity,
2588 raw_version,
2589 projection,
2590 tag,
2591 diagnostics,
2592 resolution: None,
2593 }))
2594}
2595
2596fn non_empty_json_string(value: &JsonValue) -> Option<String> {
2597 value
2598 .as_str()
2599 .filter(|value| !value.is_empty())
2600 .map(str::to_owned)
2601}
2602
2603fn extraction_diagnostic(
2604 id: &str,
2605 code: &str,
2606 message: String,
2607 evidence: &SourceEvidence,
2608) -> ExtractionDiagnostic {
2609 ExtractionDiagnostic {
2610 id: id.to_owned(),
2611 code: code.to_owned(),
2612 message,
2613 evidence: vec![evidence.clone()],
2614 }
2615}
2616
2617fn adapter_for(path: &Path) -> Option<Adapter> {
2618 let name = path.file_name()?.to_str()?;
2619 match name {
2620 "package.json" => Some(Adapter::Npm),
2621 "Cargo.toml" => Some(Adapter::Cargo),
2622 "pubspec.yaml" => Some(Adapter::Pub),
2623 "pyproject.toml" => Some(Adapter::Python),
2624 "go.mod" => Some(Adapter::Go),
2625 name if name.ends_with(".csproj") => Some(Adapter::Msbuild),
2626 _ => None,
2627 }
2628}
2629
2630fn is_project_manifest(path: &Path, adapter: Adapter) -> Result<bool> {
2631 if adapter != Adapter::Cargo {
2632 return Ok(true);
2633 }
2634 let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2635 let document = text
2636 .parse::<toml_edit::DocumentMut>()
2637 .map_err(|error| Error::Validation(format!("invalid Cargo.toml: {error}")))?;
2638 Ok(document.get("package").is_some())
2639}
2640
2641fn manifest_identity(path: &Path, adapter: Adapter) -> Result<(String, Option<String>)> {
2642 let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2643 match adapter {
2644 Adapter::Npm => {
2645 let value: JsonValue = serde_json::from_str(&text).map_err(|error| {
2646 Error::Validation(format!("invalid {}: {error}", path.display()))
2647 })?;
2648 Ok((
2649 required_string(&value["name"], path, "name")?,
2650 value["version"].as_str().map(str::to_owned),
2651 ))
2652 }
2653 Adapter::Cargo => {
2654 let document = text.parse::<toml_edit::DocumentMut>().map_err(|error| {
2655 Error::Validation(format!("invalid {}: {error}", path.display()))
2656 })?;
2657 let package = document.get("package").ok_or_else(|| {
2658 Error::Validation(format!("{} has no package table", path.display()))
2659 })?;
2660 let name = package
2661 .get("name")
2662 .and_then(toml_edit::Item::as_str)
2663 .ok_or_else(|| {
2664 Error::Validation(format!("{} has no package.name", path.display()))
2665 })?;
2666 let version = package
2667 .get("version")
2668 .and_then(toml_edit::Item::as_str)
2669 .map(str::to_owned);
2670 Ok((name.to_owned(), version))
2671 }
2672 Adapter::Pub => {
2673 let value: serde_yaml::Value = serde_yaml::from_str(&text)?;
2674 let name = value["name"]
2675 .as_str()
2676 .ok_or_else(|| Error::Validation(format!("{} has no name", path.display())))?;
2677 Ok((
2678 name.to_owned(),
2679 value["version"].as_str().map(str::to_owned),
2680 ))
2681 }
2682 Adapter::Python => {
2683 let document = text.parse::<toml_edit::DocumentMut>().map_err(|error| {
2684 Error::Validation(format!("invalid {}: {error}", path.display()))
2685 })?;
2686 let project = document.get("project").ok_or_else(|| {
2687 Error::Validation(format!("{} has no project table", path.display()))
2688 })?;
2689 let name = project
2690 .get("name")
2691 .and_then(toml_edit::Item::as_str)
2692 .ok_or_else(|| {
2693 Error::Validation(format!("{} has no project.name", path.display()))
2694 })?;
2695 let version = project
2696 .get("version")
2697 .and_then(toml_edit::Item::as_str)
2698 .map(str::to_owned);
2699 Ok((name.to_owned(), version))
2700 }
2701 Adapter::Go => {
2702 let name = text
2703 .lines()
2704 .find_map(|line| line.trim().strip_prefix("module "))
2705 .ok_or_else(|| {
2706 Error::Validation(format!("{} has no module directive", path.display()))
2707 })?;
2708 Ok((name.to_owned(), None))
2709 }
2710 Adapter::Msbuild => {
2711 let name = xml_element(&text, "PackageId")
2712 .or_else(|| xml_element(&text, "AssemblyName"))
2713 .or_else(|| {
2714 path.file_stem()
2715 .and_then(|value| value.to_str())
2716 .map(str::to_owned)
2717 })
2718 .ok_or_else(|| {
2719 Error::Validation(format!("{} has no package identity", path.display()))
2720 })?;
2721 Ok((name, xml_element(&text, "Version")))
2722 }
2723 Adapter::Json | Adapter::Toml | Adapter::Yaml => {
2724 unreachable!("generic adapters are not discovered")
2725 }
2726 }
2727}
2728
2729fn npm_manifest_is_private(path: &Path) -> Result<bool> {
2730 let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2731 let value: JsonValue = serde_json::from_str(&text)
2732 .map_err(|error| Error::Validation(format!("invalid {}: {error}", path.display())))?;
2733 Ok(value["private"].as_bool() == Some(true))
2734}
2735
2736fn required_string(value: &JsonValue, path: &Path, field: &str) -> Result<String> {
2737 value
2738 .as_str()
2739 .map(str::to_owned)
2740 .ok_or_else(|| Error::Validation(format!("{} has no string {field}", path.display())))
2741}
2742
2743fn xml_element(text: &str, name: &str) -> Option<String> {
2744 let start = format!("<{name}>");
2745 let end = format!("</{name}>");
2746 text.split_once(&start)?
2747 .1
2748 .split_once(&end)
2749 .map(|(value, _)| value.trim().to_owned())
2750}
2751
2752fn derive_npm_dependencies(root: &Path, config: &mut Config) -> Result<Vec<NpmDependencyEdge>> {
2753 let ids = config
2754 .release_units
2755 .keys()
2756 .cloned()
2757 .collect::<BTreeSet<_>>();
2758 let mut edges = Vec::new();
2759 for (id, release_unit) in &mut config.release_units {
2760 let npm_manifests = release_unit
2761 .projections
2762 .iter()
2763 .filter(|projection| projection.adapter == Adapter::Npm)
2764 .map(|projection| projection.file.workspace_path(&release_unit.path))
2765 .collect::<Vec<_>>();
2766 if npm_manifests.is_empty() {
2767 continue;
2768 }
2769 let mut dependencies = BTreeSet::new();
2770 for manifest in npm_manifests {
2771 let path = root.join(&manifest);
2772 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
2773 let value: JsonValue = serde_json::from_str(&text).map_err(|error| {
2774 Error::Validation(format!("invalid {}: {error}", path.display()))
2775 })?;
2776 for (group, kind) in [
2777 ("dependencies", NpmDependencyKind::Dependency),
2778 ("optionalDependencies", NpmDependencyKind::Optional),
2779 ("peerDependencies", NpmDependencyKind::Peer),
2780 ("devDependencies", NpmDependencyKind::Dev),
2781 ] {
2782 if let Some(entries) = value[group].as_object() {
2783 for (dependency, range) in entries {
2784 if !ids.contains(dependency) {
2785 continue;
2786 }
2787 if kind != NpmDependencyKind::Dev {
2788 dependencies.insert(dependency.clone());
2789 }
2790 edges.push(NpmDependencyEdge {
2791 dependent: id.clone(),
2792 dependency: dependency.clone(),
2793 kind,
2794 range: range.as_str().unwrap_or_default().to_owned(),
2795 manifest: manifest.clone(),
2796 });
2797 }
2798 }
2799 }
2800 }
2801 release_unit.depends_on = dependencies.into_iter().collect();
2802 }
2803 edges.sort_by(|left, right| {
2804 (
2805 &left.dependent,
2806 &left.dependency,
2807 left.kind as u8,
2808 &left.range,
2809 &left.manifest,
2810 )
2811 .cmp(&(
2812 &right.dependent,
2813 &right.dependency,
2814 right.kind as u8,
2815 &right.range,
2816 &right.manifest,
2817 ))
2818 });
2819 Ok(edges)
2820}
2821
2822fn retain_materialized_npm_dependencies(config: &mut Config, edges: &mut Vec<NpmDependencyEdge>) {
2823 edges.retain(|edge| {
2824 config
2825 .release_units
2826 .get(&edge.dependent)
2827 .is_some_and(|unit| {
2828 unit.projections.iter().any(|projection| {
2829 projection.adapter == Adapter::Npm
2830 && projection.file.workspace_path(&unit.path) == edge.manifest
2831 })
2832 })
2833 && config.release_units.contains_key(&edge.dependency)
2834 });
2835 for (id, unit) in &mut config.release_units {
2836 unit.depends_on = edges
2837 .iter()
2838 .filter(|edge| edge.dependent == *id && edge.kind != NpmDependencyKind::Dev)
2839 .map(|edge| edge.dependency.clone())
2840 .collect::<BTreeSet<_>>()
2841 .into_iter()
2842 .collect();
2843 }
2844}
2845
2846fn load_changesets_intents(root: &Path) -> Result<Vec<ConvertedIntent>> {
2847 let directory = root.join(".changeset");
2848 let mut paths = std::fs::read_dir(&directory)
2849 .map_err(|error| Error::io(&directory, error))?
2850 .filter_map(std::result::Result::ok)
2851 .map(|entry| entry.path())
2852 .filter(|path| path.extension().is_some_and(|extension| extension == "md"))
2853 .filter(|path| path.file_name().is_none_or(|name| name != "README.md"))
2854 .collect::<Vec<_>>();
2855 paths.sort();
2856 paths
2857 .into_iter()
2858 .map(|path| {
2859 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
2860 let rest = text.strip_prefix("---\n").ok_or_else(|| {
2861 Error::Validation(format!("{} has no Changesets frontmatter", path.display()))
2862 })?;
2863 let (frontmatter, message) = rest.split_once("\n---\n").ok_or_else(|| {
2864 Error::Validation(format!(
2865 "{} has unterminated Changesets frontmatter",
2866 path.display()
2867 ))
2868 })?;
2869 let packages = serde_yaml::from_str(frontmatter)?;
2870 let id = path
2871 .file_stem()
2872 .and_then(|value| value.to_str())
2873 .ok_or_else(|| {
2874 Error::Validation(format!("invalid Changesets filename {}", path.display()))
2875 })?
2876 .to_owned();
2877 Ok(ConvertedIntent {
2878 target: PathBuf::from(crate::intent::INTENTS_PATH).join(format!("{id}.md")),
2879 source: path.strip_prefix(root).unwrap_or(&path).to_owned(),
2880 id,
2881 release_units: packages,
2882 message: message.trim().to_owned(),
2883 })
2884 })
2885 .collect()
2886}
2887
2888fn parse_groups(value: &JsonValue) -> Result<Vec<Vec<String>>> {
2889 value
2890 .as_array()
2891 .into_iter()
2892 .flatten()
2893 .map(|group| {
2894 group
2895 .as_array()
2896 .ok_or_else(|| {
2897 Error::Validation("Changesets release group must be an array".to_owned())
2898 })?
2899 .iter()
2900 .map(|member| {
2901 member.as_str().map(str::to_owned).ok_or_else(|| {
2902 Error::Validation("Changesets group member must be a string".to_owned())
2903 })
2904 })
2905 .collect()
2906 })
2907 .collect()
2908}
2909
2910fn collect_json_strings(value: &JsonValue, output: &mut BTreeSet<String>) {
2911 match value {
2912 JsonValue::String(value) => {
2913 output.insert(value.clone());
2914 }
2915 JsonValue::Array(values) => {
2916 for value in values {
2917 collect_json_strings(value, output);
2918 }
2919 }
2920 _ => {}
2921 }
2922}
2923
2924fn changesets_private_package_settings(value: &JsonValue) -> Option<(bool, bool)> {
2925 match value {
2926 JsonValue::Bool(enabled) => Some((*enabled, *enabled)),
2927 JsonValue::Object(settings) => Some((
2928 settings
2929 .get("version")
2930 .and_then(JsonValue::as_bool)
2931 .unwrap_or(true),
2932 settings
2933 .get("tag")
2934 .and_then(JsonValue::as_bool)
2935 .unwrap_or(true),
2936 )),
2937 _ => None,
2938 }
2939}
2940
2941fn remap_groups(groups: &mut Vec<Vec<String>>, identity_map: &BTreeMap<String, String>) {
2942 for group in groups.iter_mut() {
2943 for release_unit in group.iter_mut() {
2944 if let Some(target) = identity_map.get(release_unit) {
2945 *release_unit = target.clone();
2946 }
2947 }
2948 group.sort();
2949 group.dedup();
2950 }
2951 groups.retain(|group| group.len() > 1);
2952}
2953
2954fn remap_converted_intents(
2955 intents: &mut [ConvertedIntent],
2956 identity_map: &BTreeMap<String, String>,
2957 config: &Config,
2958) -> Result<()> {
2959 for intent in intents {
2960 let mut release_units = BTreeMap::<String, Bump>::new();
2961 for (id, bump) in std::mem::take(&mut intent.release_units) {
2962 let logical_id = identity_map.get(&id).cloned().unwrap_or(id);
2963 if !config.release_units.contains_key(&logical_id) {
2964 return Err(Error::Validation(format!(
2965 "Changesets intent {} references release unit {logical_id}, which has no Intentional release-unit identity",
2966 intent.id
2967 )));
2968 }
2969 release_units
2970 .entry(logical_id)
2971 .and_modify(|existing| *existing = (*existing).max(bump))
2972 .or_insert(bump);
2973 }
2974 intent.release_units = release_units;
2975 }
2976 Ok(())
2977}
2978
2979fn scan_changesets_integrations(root: &Path) -> Result<Vec<SourceEvidence>> {
2980 let mut findings = Vec::new();
2981 for entry in WalkDir::new(root)
2982 .into_iter()
2983 .filter_entry(|entry| !hard_excluded(root, entry.path()))
2984 .filter_map(std::result::Result::ok)
2985 .filter(|entry| entry.file_type().is_file())
2986 {
2987 let path = entry.path();
2988 let relative = path.strip_prefix(root).unwrap_or(path);
2989 if relative.starts_with(".changeset") || relative.starts_with(".intentional") {
2990 continue;
2991 }
2992 let relevant = relative == Path::new("package.json")
2993 || relative == Path::new("pnpm-lock.yaml")
2994 || relative.starts_with(".github")
2995 || relative.starts_with("scripts")
2996 || relative
2997 .components()
2998 .any(|component| component.as_os_str().to_string_lossy().contains("test"));
2999 if !relevant {
3000 continue;
3001 }
3002 let Ok(text) = std::fs::read_to_string(path) else {
3003 continue;
3004 };
3005 let lines = text
3006 .lines()
3007 .enumerate()
3008 .filter(|(_, line)| line.to_ascii_lowercase().contains("changeset"))
3009 .map(|(index, _)| index + 1)
3010 .collect::<Vec<_>>();
3011 if !lines.is_empty() {
3012 findings.push(evidence(root, relative, lines)?);
3013 }
3014 }
3015 findings.sort();
3016 Ok(findings)
3017}
3018
3019fn scan_external_release_evidence(root: &Path) -> Result<Vec<SourceEvidence>> {
3020 let mut findings = Vec::new();
3021 for entry in WalkDir::new(root)
3022 .into_iter()
3023 .filter_entry(|entry| !hard_excluded(root, entry.path()))
3024 .filter_map(std::result::Result::ok)
3025 .filter(|entry| entry.file_type().is_file())
3026 {
3027 let path = entry.path();
3028 let relative = path.strip_prefix(root).unwrap_or(path);
3029 let is_release_file = relative.file_name().is_some_and(|name| {
3030 name.to_string_lossy()
3031 .to_ascii_lowercase()
3032 .contains("release")
3033 });
3034 if (relative.starts_with("scripts") || relative.starts_with(".github")) && is_release_file {
3035 findings.push(evidence(root, relative, Vec::new())?);
3036 }
3037 }
3038 findings.sort();
3039 Ok(findings)
3040}
3041
3042fn release_tool_proxy_diagnostics(
3043 root: &Path,
3044 candidates: &[DiscoveryCandidate],
3045 referenced_names: &BTreeSet<String>,
3046) -> Result<Vec<InitDiagnostic>> {
3047 let workspace_paths = workspace_manifest_paths(root)?;
3048 let mut diagnostics = Vec::new();
3049 for candidate in candidates {
3050 let Some(native_identity) = candidate.native_identity.as_deref() else {
3051 continue;
3052 };
3053 let Some(projection) = candidate.projection.as_ref() else {
3054 continue;
3055 };
3056 if projection.adapter != Adapter::Npm {
3057 continue;
3058 }
3059 let workspace_member = workspace_paths.contains(&root.join(&candidate.path));
3060 if !referenced_names.contains(native_identity) && !workspace_member {
3061 continue;
3062 }
3063 let Some(CandidateResolution::Projection { release_unit, .. }) =
3064 candidate.resolution.as_ref()
3065 else {
3066 continue;
3067 };
3068 if release_unit == native_identity {
3069 continue;
3070 }
3071 let target_candidates = candidates
3072 .iter()
3073 .filter(|target| target.id != candidate.id)
3074 .filter(|target| {
3075 target.projection.as_ref().is_some_and(|projection| {
3076 projection.adapter != Adapter::Npm
3077 && matches!(
3078 target.resolution.as_ref(),
3079 Some(CandidateResolution::Projection {
3080 release_unit: target_release_unit,
3081 ..
3082 }) if target_release_unit == release_unit
3083 )
3084 })
3085 })
3086 .collect::<Vec<_>>();
3087 if target_candidates.is_empty() {
3088 continue;
3089 }
3090
3091 let mut source_references = changesets_identity_evidence(root, native_identity)?;
3092 if source_references.is_empty() && workspace_member {
3093 source_references.push(evidence(root, Path::new(CHANGESETS_CONFIG), Vec::new())?);
3094 source_references.extend(workspace_declaration_evidence(root)?);
3095 source_references.sort();
3096 source_references.dedup();
3097 }
3098 if source_references.is_empty() {
3099 continue;
3100 }
3101 let (private, responsibilities) = npm_manifest_responsibilities(root, &candidate.path)?;
3102 let mut supporting_evidence = candidate.evidence.clone();
3103 supporting_evidence.extend(source_references);
3104 for target in &target_candidates {
3105 supporting_evidence.extend(target.evidence.clone());
3106 }
3107 supporting_evidence.sort();
3108 supporting_evidence.dedup();
3109
3110 let mut contradictory_evidence = Vec::new();
3111 if !private || !responsibilities.is_empty() {
3112 contradictory_evidence.extend(candidate.evidence.clone());
3113 }
3114 contradictory_evidence.extend(non_displaced_manifest_references(
3115 root,
3116 &candidate.path,
3117 native_identity,
3118 )?);
3119 contradictory_evidence.sort();
3120 contradictory_evidence.dedup();
3121
3122 let recommended =
3123 (private && responsibilities.is_empty() && contradictory_evidence.is_empty())
3124 .then(|| "remove".to_owned());
3125 let mut evidence = supporting_evidence.clone();
3126 evidence.extend(contradictory_evidence.clone());
3127 evidence.sort();
3128 evidence.dedup();
3129 let responsibility_message = if responsibilities.is_empty() {
3130 "no independent npm responsibility keys were observed".to_owned()
3131 } else {
3132 format!(
3133 "npm responsibility keys were observed: {}",
3134 responsibilities.join(", ")
3135 )
3136 };
3137 diagnostics.push(InitDiagnostic {
3138 id: format!("release-tool-proxy-disposition:{}", candidate.id),
3139 code: "release-tool-proxy-disposition".to_owned(),
3140 message: format!(
3141 "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}.",
3142 candidate.path.display()
3143 ),
3144 evidence,
3145 choices: vec!["retain".to_owned(), "remove".to_owned()],
3146 recommended,
3147 resolution: None,
3148 verified: false,
3149 invalidated_resolution: false,
3150 supporting_evidence,
3151 contradictory_evidence,
3152 uncertainty: Some(
3153 "This is a best-effort structural assessment, not proof that the manifest is disposable. Descriptions, comments, prose, and semantic keyword matches are not evidence."
3154 .to_owned(),
3155 ),
3156 });
3157 }
3158 diagnostics.sort_by(|left, right| left.id.cmp(&right.id));
3159 Ok(diagnostics)
3160}
3161
3162fn npm_manifest_responsibilities(root: &Path, relative: &Path) -> Result<(bool, Vec<String>)> {
3163 let path = root.join(relative);
3164 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
3165 let value: JsonValue = serde_json::from_str(&text)
3166 .map_err(|error| Error::Validation(format!("invalid package.json: {error}")))?;
3167 let object = value.as_object().ok_or_else(|| {
3168 Error::Validation(format!("{} must contain a JSON object", relative.display()))
3169 })?;
3170 let private = object
3171 .get("private")
3172 .and_then(JsonValue::as_bool)
3173 .unwrap_or(false);
3174 let responsibility_keys = [
3175 "bin",
3176 "browser",
3177 "bundledDependencies",
3178 "cpu",
3179 "dependencies",
3180 "devDependencies",
3181 "engines",
3182 "exports",
3183 "files",
3184 "main",
3185 "module",
3186 "optionalDependencies",
3187 "os",
3188 "peerDependencies",
3189 "publishConfig",
3190 "repository",
3191 "scripts",
3192 "types",
3193 "typings",
3194 "workspaces",
3195 ];
3196 let responsibilities = responsibility_keys
3197 .into_iter()
3198 .filter(|key| object.get(*key).is_some_and(json_value_has_content))
3199 .map(str::to_owned)
3200 .collect();
3201 Ok((private, responsibilities))
3202}
3203
3204fn json_value_has_content(value: &JsonValue) -> bool {
3205 match value {
3206 JsonValue::Null => false,
3207 JsonValue::Bool(value) => *value,
3208 JsonValue::Number(_) => true,
3209 JsonValue::String(value) => !value.is_empty(),
3210 JsonValue::Array(values) => !values.is_empty(),
3211 JsonValue::Object(values) => !values.is_empty(),
3212 }
3213}
3214
3215fn changesets_identity_evidence(root: &Path, identity: &str) -> Result<Vec<SourceEvidence>> {
3216 let mut findings = Vec::new();
3217 let config_path = PathBuf::from(CHANGESETS_CONFIG);
3218 let config_text = std::fs::read_to_string(root.join(&config_path))
3219 .map_err(|error| Error::io(root.join(&config_path), error))?;
3220 let config: JsonValue = serde_json::from_str(&config_text)
3221 .map_err(|error| Error::Validation(format!("invalid Changesets config: {error}")))?;
3222 if json_contains_exact_string(&config, identity) {
3223 findings.push(evidence(root, &config_path, Vec::new())?);
3224 }
3225 for entry in WalkDir::new(root.join(".changeset"))
3226 .min_depth(1)
3227 .max_depth(1)
3228 .into_iter()
3229 .filter_map(std::result::Result::ok)
3230 .filter(|entry| entry.file_type().is_file())
3231 .filter(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("md"))
3232 .filter(|entry| entry.file_name() != "README.md")
3233 {
3234 let text = std::fs::read_to_string(entry.path())
3235 .map_err(|error| Error::io(entry.path(), error))?;
3236 let Some(frontmatter) = text
3237 .strip_prefix("---\n")
3238 .and_then(|text| text.split_once("\n---"))
3239 .map(|(frontmatter, _)| frontmatter)
3240 else {
3241 continue;
3242 };
3243 let values: BTreeMap<String, Bump> = serde_yaml::from_str(frontmatter)?;
3244 if values.contains_key(identity) {
3245 let relative = entry
3246 .path()
3247 .strip_prefix(root)
3248 .unwrap_or(entry.path())
3249 .to_owned();
3250 findings.push(evidence(root, &relative, Vec::new())?);
3251 }
3252 }
3253 findings.sort();
3254 findings.dedup();
3255 Ok(findings)
3256}
3257
3258fn json_contains_exact_string(value: &JsonValue, expected: &str) -> bool {
3259 match value {
3260 JsonValue::String(value) => value == expected,
3261 JsonValue::Array(values) => values
3262 .iter()
3263 .any(|value| json_contains_exact_string(value, expected)),
3264 JsonValue::Object(values) => values
3265 .iter()
3266 .any(|(key, value)| key == expected || json_contains_exact_string(value, expected)),
3267 JsonValue::Null | JsonValue::Bool(_) | JsonValue::Number(_) => false,
3268 }
3269}
3270
3271fn workspace_declaration_evidence(root: &Path) -> Result<Vec<SourceEvidence>> {
3272 ["pnpm-workspace.yaml", "package.json", "Cargo.toml"]
3273 .into_iter()
3274 .map(PathBuf::from)
3275 .filter(|path| root.join(path).is_file())
3276 .map(|path| evidence(root, &path, Vec::new()))
3277 .collect()
3278}
3279
3280fn non_displaced_manifest_references(
3281 root: &Path,
3282 candidate_path: &Path,
3283 native_identity: &str,
3284) -> Result<Vec<SourceEvidence>> {
3285 let path_text = candidate_path.to_string_lossy().replace('\\', "/");
3286 let mut findings = Vec::new();
3287 let mut paths = WalkDir::new(root)
3288 .into_iter()
3289 .filter_entry(|entry| !hard_excluded(root, entry.path()))
3290 .filter_map(std::result::Result::ok)
3291 .filter(|entry| entry.file_type().is_file())
3292 .map(|entry| entry.into_path())
3293 .collect::<BTreeSet<_>>();
3294 remove_git_ignored(root, &mut paths)?;
3295 for path in paths {
3296 let relative = path.strip_prefix(root).unwrap_or(&path);
3297 if relative == candidate_path
3298 || relative.starts_with(".changeset")
3299 || relative.starts_with(".intentional")
3300 || is_prose_path(relative)
3301 {
3302 continue;
3303 }
3304 let Ok(text) = std::fs::read_to_string(&path) else {
3305 continue;
3306 };
3307 let lines = text
3308 .lines()
3309 .enumerate()
3310 .filter(|(_, line)| structural_line_references_path(line, &path_text))
3311 .map(|(index, _)| index + 1)
3312 .collect::<Vec<_>>();
3313 let has_dependency_reference = relative.file_name().and_then(|name| name.to_str())
3314 == Some("package.json")
3315 && serde_json::from_str::<JsonValue>(&text).is_ok_and(|value| {
3316 [
3317 "dependencies",
3318 "devDependencies",
3319 "optionalDependencies",
3320 "peerDependencies",
3321 ]
3322 .iter()
3323 .any(|key| {
3324 value[*key]
3325 .as_object()
3326 .is_some_and(|dependencies| dependencies.contains_key(native_identity))
3327 })
3328 });
3329 if !lines.is_empty() || has_dependency_reference {
3330 findings.push(evidence(root, relative, lines)?);
3331 }
3332 }
3333 findings.sort();
3334 findings.dedup();
3335 Ok(findings)
3336}
3337
3338fn structural_line_references_path(line: &str, path: &str) -> bool {
3339 let normalized = line.replace('\\', "/");
3340 let Some(reference_offset) = normalized.find(path) else {
3341 return false;
3342 };
3343 let trimmed = normalized.trim_start();
3344 if trimmed.starts_with("//")
3345 || trimmed.starts_with('#')
3346 || trimmed.starts_with("/*")
3347 || trimmed.starts_with("<!--")
3348 || trimmed == "*"
3349 || trimmed.starts_with("* ")
3350 {
3351 return false;
3352 }
3353 let before_reference = &normalized[..reference_offset];
3354 if comment_starts_before_reference(before_reference) {
3355 return false;
3356 }
3357 let Some((field, _)) = trimmed.split_once(':') else {
3358 return true;
3359 };
3360 let field = field
3361 .trim()
3362 .trim_matches(|character| matches!(character, '"' | '\''))
3363 .to_ascii_lowercase();
3364 !matches!(
3365 field.as_str(),
3366 "$comment"
3367 | "comment"
3368 | "comments"
3369 | "description"
3370 | "message"
3371 | "note"
3372 | "notes"
3373 | "summary"
3374 | "title"
3375 )
3376}
3377
3378fn comment_starts_before_reference(line: &str) -> bool {
3379 let bytes = line.as_bytes();
3380 let mut quote = None;
3381 let mut escaped = false;
3382 let mut index = 0;
3383 while index < bytes.len() {
3384 let byte = bytes[index];
3385 if let Some(expected) = quote {
3386 if escaped {
3387 escaped = false;
3388 } else if byte == b'\\' {
3389 escaped = true;
3390 } else if byte == expected {
3391 quote = None;
3392 }
3393 index += 1;
3394 continue;
3395 }
3396 if matches!(byte, b'\'' | b'"') {
3397 quote = Some(byte);
3398 index += 1;
3399 continue;
3400 }
3401 let previous_is_boundary =
3402 index == 0 || bytes[index.saturating_sub(1)].is_ascii_whitespace();
3403 if byte == b'#' && previous_is_boundary {
3404 return true;
3405 }
3406 if bytes[index..].starts_with(b"<!--") {
3407 return true;
3408 }
3409 if bytes[index..].starts_with(b"/*")
3410 && bytes
3411 .get(index + 2)
3412 .is_none_or(|next| next.is_ascii_whitespace())
3413 {
3414 return true;
3415 }
3416 if bytes[index..].starts_with(b"//")
3417 && bytes
3418 .get(index + 2)
3419 .is_none_or(|next| next.is_ascii_whitespace())
3420 {
3421 return true;
3422 }
3423 index += 1;
3424 }
3425 false
3426}
3427
3428fn is_prose_path(path: &Path) -> bool {
3429 matches!(
3430 path.extension().and_then(|extension| extension.to_str()),
3431 Some("md" | "mdx" | "rst" | "txt")
3432 )
3433}
3434
3435fn reconcile_diagnostics(
3436 root: &Path,
3437 current: &mut Vec<InitDiagnostic>,
3438 previous: Option<&InitPlan>,
3439 candidates: &[DiscoveryCandidate],
3440) -> Result<()> {
3441 let Some(previous) = previous else {
3442 return Ok(());
3443 };
3444 let previous_by_id = previous
3445 .diagnostics
3446 .iter()
3447 .map(|diagnostic| (diagnostic.id.as_str(), diagnostic))
3448 .collect::<BTreeMap<_, _>>();
3449 for diagnostic in current.iter_mut() {
3450 let Some(old) = previous_by_id.get(diagnostic.id.as_str()) else {
3451 continue;
3452 };
3453 if old.evidence == diagnostic.evidence {
3454 diagnostic.resolution = old.resolution.clone();
3455 if !diagnostic.choices.is_empty() {
3456 diagnostic.verified = match diagnostic.code.as_str() {
3457 "repository-integration" => false,
3458 "release-tool-proxy-disposition" => {
3459 let candidate = candidates.iter().find(|candidate| {
3460 diagnostic.id
3461 == format!("release-tool-proxy-disposition:{}", candidate.id)
3462 });
3463 match (diagnostic.resolution.as_deref(), candidate) {
3464 (Some("retain"), Some(_)) => true,
3465 (Some("remove"), Some(candidate)) => non_displaced_manifest_references(
3466 root,
3467 &candidate.path,
3468 candidate.native_identity.as_deref().unwrap_or_default(),
3469 )?
3470 .is_empty(),
3471 _ => false,
3472 }
3473 }
3474 _ => diagnostic.resolution.is_some(),
3475 };
3476 }
3477 } else if old.resolution.is_some() {
3478 diagnostic.invalidated_resolution = true;
3479 }
3480 }
3481 let current_ids = current
3482 .iter()
3483 .map(|diagnostic| diagnostic.id.clone())
3484 .collect::<BTreeSet<_>>();
3485 for old in &previous.diagnostics {
3486 if old.code != "repository-integration"
3487 || current_ids.contains(&old.id)
3488 || old.resolution.as_deref() != Some("removed")
3489 {
3490 continue;
3491 }
3492 let Some(path) = old.evidence.first().map(|evidence| evidence.path.clone()) else {
3493 continue;
3494 };
3495 let full = root.join(&path);
3496 if !full.is_file() {
3497 continue;
3498 }
3499 let text = std::fs::read_to_string(&full).map_err(|error| Error::io(&full, error))?;
3500 if text.to_ascii_lowercase().contains("changeset") {
3501 continue;
3502 }
3503 current.push(InitDiagnostic {
3504 id: old.id.clone(),
3505 code: old.code.clone(),
3506 message: old.message.clone(),
3507 evidence: vec![evidence(root, &path, Vec::new())?],
3508 choices: old.choices.clone(),
3509 recommended: old.recommended.clone(),
3510 resolution: old.resolution.clone(),
3511 verified: true,
3512 invalidated_resolution: false,
3513 supporting_evidence: old.supporting_evidence.clone(),
3514 contradictory_evidence: old.contradictory_evidence.clone(),
3515 uncertainty: old.uncertainty.clone(),
3516 });
3517 }
3518 current.sort_by(|left, right| left.id.cmp(&right.id));
3519 Ok(())
3520}
3521
3522fn apply_disposition_resolutions(
3523 config: &mut Config,
3524 diagnostics: &[InitDiagnostic],
3525) -> Result<()> {
3526 let mut excluded = BTreeSet::new();
3527 for diagnostic in diagnostics.iter().filter(|diagnostic| {
3528 diagnostic.code == "ignored-release-unit-disposition"
3529 || diagnostic.code == "unmapped-release-unit-disposition"
3530 }) {
3531 let release_unit = diagnostic
3532 .id
3533 .split_once(':')
3534 .map(|(_, release_unit)| release_unit)
3535 .expect("diagnostic id prefix");
3536 match diagnostic.resolution.as_deref() {
3537 Some("suspended") => {
3538 if let Some(config) = config.release_units.get_mut(release_unit) {
3539 config.disposition = ReleaseUnitDisposition::Suspended;
3540 }
3541 }
3542 Some("excluded") => {
3543 config.release_units.remove(release_unit);
3544 excluded.insert(release_unit.to_owned());
3545 }
3546 Some("managed") | None => {}
3547 Some(value) => {
3548 return Err(Error::Validation(format!(
3549 "invalid resolution {value} for {}",
3550 diagnostic.id
3551 )))
3552 }
3553 }
3554 }
3555 exclude_release_units(config, &excluded);
3556 Ok(())
3557}
3558
3559fn apply_proxy_manifest_resolutions(
3560 config: &mut Config,
3561 diagnostics: &[InitDiagnostic],
3562 candidates: &[DiscoveryCandidate],
3563) -> Result<Vec<ProxyRemoval>> {
3564 let mut removals = Vec::new();
3565 for diagnostic in diagnostics
3566 .iter()
3567 .filter(|diagnostic| diagnostic.code == "release-tool-proxy-disposition")
3568 {
3569 let candidate = candidates
3570 .iter()
3571 .find(|candidate| {
3572 diagnostic.id == format!("release-tool-proxy-disposition:{}", candidate.id)
3573 })
3574 .ok_or_else(|| {
3575 Error::Validation(format!(
3576 "{} does not identify a current discovery candidate",
3577 diagnostic.id
3578 ))
3579 })?;
3580 match diagnostic.resolution.as_deref() {
3581 None | Some("retain") => {}
3582 Some("remove") => {
3583 let Some(CandidateResolution::Projection { release_unit, .. }) =
3584 candidate.resolution.as_ref()
3585 else {
3586 return Err(Error::Validation(format!(
3587 "{} can remove only a candidate resolved as a projection",
3588 diagnostic.id
3589 )));
3590 };
3591 let unit = config.release_units.get_mut(release_unit).ok_or_else(|| {
3592 Error::Validation(format!(
3593 "{} targets absent release unit {release_unit}",
3594 diagnostic.id
3595 ))
3596 })?;
3597 let unit_path = unit.path.clone();
3598 unit.projections.retain(|projection| {
3599 projection.file.workspace_path(&unit_path) != candidate.path
3600 });
3601 config.discovery.managed_paths.retain(|receipt| {
3602 receipt.detector != candidate.detector || receipt.path != candidate.path
3603 });
3604 removals.push(ProxyRemoval {
3605 path: candidate.path.clone(),
3606 native_identity: candidate
3607 .native_identity
3608 .clone()
3609 .expect("proxy diagnostic requires native identity"),
3610 });
3611 }
3612 Some(value) => {
3613 return Err(Error::Validation(format!(
3614 "invalid resolution {value} for {}",
3615 diagnostic.id
3616 )));
3617 }
3618 }
3619 }
3620 removals.sort_by(|left, right| left.path.cmp(&right.path));
3621 Ok(removals)
3622}
3623
3624fn exclude_release_units(config: &mut Config, excluded: &BTreeSet<String>) {
3625 for id in excluded {
3626 config.release_units.remove(id);
3627 }
3628 if excluded.is_empty() {
3629 return;
3630 }
3631 for release_unit in config.release_units.values_mut() {
3632 release_unit.depends_on.retain(|id| !excluded.contains(id));
3633 }
3634 for groups in [&mut config.fixed, &mut config.linked] {
3635 for group in groups.iter_mut() {
3636 group.retain(|id| !excluded.contains(id));
3637 }
3638 groups.retain(|group| group.len() >= 2);
3639 }
3640}
3641
3642fn parity_result(
3643 source_config: &Config,
3644 proposed_config: &Config,
3645 current: &BTreeMap<String, Version>,
3646 declared: &BTreeMap<String, Bump>,
3647 source_semantics: &ChangesetsSourceSemantics<'_>,
3648) -> Result<ParityComputation> {
3649 let mut source_config = source_config.clone();
3650 let mut proposed_config = proposed_config.clone();
3651 let effective = effective_bumps(&proposed_config, declared);
3652 let non_releasing_without_versions = proposed_config
3653 .release_units
3654 .keys()
3655 .filter(|id| !current.contains_key(*id) && effective[*id] == Bump::None)
3656 .cloned()
3657 .collect::<BTreeSet<_>>();
3658 exclude_release_units(&mut source_config, &non_releasing_without_versions);
3659 exclude_release_units(&mut proposed_config, &non_releasing_without_versions);
3660 let excluded_pending = declared
3661 .keys()
3662 .filter(|id| !proposed_config.release_units.contains_key(*id))
3663 .cloned()
3664 .collect::<Vec<_>>();
3665 let proposed_preflight_error = (!excluded_pending.is_empty()).then(|| {
3666 format!(
3667 "converted intents reference release units excluded from the proposed inventory: {}",
3668 excluded_pending.join(", ")
3669 )
3670 });
3671 if source_semantics.preflight_error.is_some() || proposed_preflight_error.is_some() {
3672 return Ok(ParityComputation {
3673 result: ParityResult {
3674 status: "blocked".to_owned(),
3675 release_units: Vec::new(),
3676 },
3677 source_error: source_semantics.preflight_error.clone(),
3678 proposed_error: proposed_preflight_error,
3679 });
3680 }
3681 let missing = source_config
3682 .release_units
3683 .keys()
3684 .chain(proposed_config.release_units.keys())
3685 .filter(|id| !current.contains_key(*id))
3686 .cloned()
3687 .collect::<BTreeSet<_>>();
3688 if !missing.is_empty() {
3689 return Ok(ParityComputation {
3690 result: ParityResult {
3691 status: "blocked".to_owned(),
3692 release_units: Vec::new(),
3693 },
3694 source_error: None,
3695 proposed_error: Some(format!(
3696 "missing current versions for release units: {}",
3697 missing.into_iter().collect::<Vec<_>>().join(", ")
3698 )),
3699 });
3700 }
3701 let (source, source_error) =
3702 match resolve_changesets_source(&source_config, declared, current, source_semantics) {
3703 Ok(resolved) => (resolved, None),
3704 Err(Error::Validation(message)) => (BTreeMap::new(), Some(message)),
3705 Err(error) => return Err(error),
3706 };
3707 let (proposed, proposed_error) = match resolve_versions(&proposed_config, declared, current) {
3708 Ok(resolved) => (resolved, None),
3709 Err(Error::Validation(message)) => (BTreeMap::new(), Some(message)),
3710 Err(error) => return Err(error),
3711 };
3712 let release_ids = source
3713 .iter()
3714 .chain(&proposed)
3715 .filter(|(_, versions)| versions.bump != Bump::None)
3716 .map(|(id, _)| id.clone())
3717 .collect::<BTreeSet<_>>();
3718 let release_units = release_ids
3719 .into_iter()
3720 .map(|release_unit| ParityReleaseUnit {
3721 current_version: current[&release_unit].to_string(),
3722 source: parity_release(source.get(&release_unit)),
3723 proposed: parity_release(proposed.get(&release_unit)),
3724 release_unit,
3725 })
3726 .collect::<Vec<_>>();
3727 let equivalent = source_error.is_none()
3728 && proposed_error.is_none()
3729 && release_units
3730 .iter()
3731 .all(|release_unit| release_unit.source == release_unit.proposed);
3732 Ok(ParityComputation {
3733 result: ParityResult {
3734 status: if equivalent { "equivalent" } else { "blocked" }.to_owned(),
3735 release_units,
3736 },
3737 source_error,
3738 proposed_error,
3739 })
3740}
3741
3742fn mixed_skipped_changeset(
3743 intents: &[ConvertedIntent],
3744 skipped_release_units: &BTreeSet<String>,
3745) -> Option<String> {
3746 intents.iter().find_map(|intent| {
3747 let has_skipped = intent
3748 .release_units
3749 .keys()
3750 .any(|id| skipped_release_units.contains(id));
3751 let has_managed = intent
3752 .release_units
3753 .keys()
3754 .any(|id| !skipped_release_units.contains(id));
3755 (has_skipped && has_managed).then(|| {
3756 format!(
3757 "Changesets intent {} mixes skipped and managed release units; split or revise the source changeset before takeover",
3758 intent.id
3759 )
3760 })
3761 })
3762}
3763
3764struct ParityComputation {
3765 result: ParityResult,
3766 source_error: Option<String>,
3767 proposed_error: Option<String>,
3768}
3769
3770fn parity_release(version: Option<&ReleaseUnitVersion>) -> Option<ParityRelease> {
3771 version
3772 .filter(|version| version.bump != Bump::None)
3773 .map(|version| ParityRelease {
3774 requested_bump: version.bump,
3775 next_version: version.next.to_string(),
3776 })
3777}
3778
3779fn resolve_changesets_source(
3785 config: &Config,
3786 declared: &BTreeMap<String, Bump>,
3787 current: &BTreeMap<String, Version>,
3788 semantics: &ChangesetsSourceSemantics<'_>,
3789) -> Result<BTreeMap<String, ReleaseUnitVersion>> {
3790 let mut effective = config
3791 .release_units
3792 .keys()
3793 .map(|id| {
3794 let suppressed =
3795 semantics.suppress_private_versions && semantics.private_packages.contains(id);
3796 (
3797 id.clone(),
3798 if suppressed {
3799 Bump::None
3800 } else {
3801 declared.get(id).copied().unwrap_or(Bump::None)
3802 },
3803 )
3804 })
3805 .collect::<BTreeMap<_, _>>();
3806 loop {
3807 let before = effective.clone();
3808 for edge in semantics.npm_dependencies {
3809 if !config.release_units.contains_key(&edge.dependent)
3810 || !config.release_units.contains_key(&edge.dependency)
3811 || (semantics.suppress_private_versions
3812 && semantics.private_packages.contains(&edge.dependent))
3813 {
3814 continue;
3815 }
3816 let dependency_bump = effective[&edge.dependency];
3817 if dependency_bump == Bump::None {
3818 continue;
3819 }
3820 let next = changesets_dependency_next(config, &edge.dependency, &effective, current);
3821 let in_range = npm_range_satisfies(&edge.range, ¤t[&edge.dependency], &next)?;
3822 let required = if edge.kind == NpmDependencyKind::Peer
3823 && dependency_bump >= Bump::Minor
3824 && (!semantics.only_update_peer_dependents_when_out_of_range || !in_range)
3825 {
3826 Bump::Major
3827 } else if semantics.update_internal_dependents_always || !in_range {
3828 semantics.internal_dependency_bump
3829 } else {
3830 Bump::None
3831 };
3832 if required != Bump::None {
3833 effective
3834 .entry(edge.dependent.clone())
3835 .and_modify(|bump| *bump = (*bump).max(required));
3836 }
3837 }
3838 for group in &config.fixed {
3839 let bump = group
3840 .iter()
3841 .map(|id| effective[id])
3842 .max()
3843 .unwrap_or_default();
3844 if bump != Bump::None {
3845 for id in group {
3846 if !semantics.suppress_private_versions
3847 || !semantics.private_packages.contains(id)
3848 {
3849 effective.insert(id.clone(), bump);
3850 }
3851 }
3852 }
3853 }
3854 for group in &config.linked {
3855 let bump = group
3856 .iter()
3857 .map(|id| effective[id])
3858 .max()
3859 .unwrap_or_default();
3860 if bump != Bump::None {
3861 for id in group {
3862 if effective[id] != Bump::None {
3863 effective.insert(id.clone(), bump);
3864 }
3865 }
3866 }
3867 }
3868 if effective == before {
3869 break;
3870 }
3871 }
3872
3873 let mut resolved = config
3874 .release_units
3875 .keys()
3876 .map(|id| {
3877 let current_version = current.get(id).ok_or_else(|| {
3878 Error::Validation(format!("missing current version for release unit {id}"))
3879 })?;
3880 Ok((
3881 id.clone(),
3882 ReleaseUnitVersion::new_with_mapping(
3883 current_version.clone(),
3884 effective[id],
3885 Pre1BumpMapping::Component,
3886 ),
3887 ))
3888 })
3889 .collect::<Result<BTreeMap<_, _>>>()?;
3890 for (fixed, groups) in [(true, &config.fixed), (false, &config.linked)] {
3891 for group in groups {
3892 let highest_current = group
3893 .iter()
3894 .map(|id| ¤t[id])
3895 .max()
3896 .expect("validated release group")
3897 .clone();
3898 let highest_bump = group
3899 .iter()
3900 .map(|id| effective[id])
3901 .max()
3902 .unwrap_or_default();
3903 let next = bump_version_with_mapping(
3904 &highest_current,
3905 highest_bump,
3906 Pre1BumpMapping::Component,
3907 );
3908 for id in group {
3909 let suppressed =
3910 semantics.suppress_private_versions && semantics.private_packages.contains(id);
3911 let releases = !suppressed
3912 && highest_bump != Bump::None
3913 && (fixed || effective[id] != Bump::None);
3914 resolved.insert(
3915 id.clone(),
3916 ReleaseUnitVersion {
3917 current: current[id].clone(),
3918 next: if releases {
3919 next.clone()
3920 } else {
3921 current[id].clone()
3922 },
3923 bump: if releases { highest_bump } else { Bump::None },
3924 },
3925 );
3926 }
3927 }
3928 }
3929 Ok(resolved)
3930}
3931
3932fn changesets_dependency_next(
3933 config: &Config,
3934 id: &str,
3935 effective: &BTreeMap<String, Bump>,
3936 current: &BTreeMap<String, Version>,
3937) -> Version {
3938 for group in config.fixed.iter().chain(&config.linked) {
3939 if group.iter().any(|member| member == id) && effective[id] != Bump::None {
3940 let highest_current = group
3941 .iter()
3942 .map(|member| ¤t[member])
3943 .max()
3944 .expect("validated group");
3945 let highest_bump = group
3946 .iter()
3947 .map(|member| effective[member])
3948 .max()
3949 .unwrap_or_default();
3950 return bump_version_with_mapping(
3951 highest_current,
3952 highest_bump,
3953 Pre1BumpMapping::Component,
3954 );
3955 }
3956 }
3957 bump_version_with_mapping(¤t[id], effective[id], Pre1BumpMapping::Component)
3958}
3959
3960fn npm_range_satisfies(range: &str, current: &Version, next: &Version) -> Result<bool> {
3961 let normalized = match range.strip_prefix("workspace:") {
3962 Some("*") => current.to_string(),
3963 Some("^") => format!("^{current}"),
3964 Some("~") => format!("~{current}"),
3965 Some(range) => range.to_owned(),
3966 None => range.to_owned(),
3967 };
3968 let Ok(range) = NodeRange::parse(&normalized) else {
3969 return Ok(false);
3970 };
3971 let next = NodeVersion::parse(next.to_string())
3972 .map_err(|error| Error::Validation(format!("invalid next npm version: {error}")))?;
3973 Ok(range.satisfies(&next))
3974}
3975
3976fn load_previous_plan(root: &Path) -> Result<Option<InitPlan>> {
3977 let path = root.join(INIT_PLAN_PATH);
3978 match std::fs::read_to_string(&path) {
3979 Ok(text) => {
3980 let plan: InitPlan = serde_yaml::from_str(&text)?;
3981 plan.validate()?;
3982 Ok(Some(plan))
3983 }
3984 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
3985 Err(error) => Err(Error::io(path, error)),
3986 }
3987}
3988
3989fn evidence(root: &Path, relative: &Path, lines: Vec<usize>) -> Result<SourceEvidence> {
3990 let path = root.join(relative);
3991 let bytes = std::fs::read(&path).map_err(|error| Error::io(&path, error))?;
3992 Ok(SourceEvidence {
3993 path: relative.to_owned(),
3994 digest: sha256_digest(&bytes),
3995 lines,
3996 })
3997}
3998
3999fn fingerprint(root: &Path, paths: &BTreeSet<PathBuf>) -> Result<String> {
4000 let mut source = BTreeMap::new();
4001 for path in paths {
4002 if root.join(path).is_file() {
4003 source.insert(
4004 path.to_string_lossy().to_string(),
4005 evidence(root, path, Vec::new())?.digest,
4006 );
4007 }
4008 }
4009 for path in ["package.json", "pnpm-workspace.yaml", "Cargo.toml"] {
4010 let path = PathBuf::from(path);
4011 if root.join(&path).is_file() {
4012 source.insert(
4013 path.to_string_lossy().to_string(),
4014 evidence(root, &path, Vec::new())?.digest,
4015 );
4016 }
4017 }
4018 for entry in WalkDir::new(root.join(".changeset"))
4019 .into_iter()
4020 .filter_map(std::result::Result::ok)
4021 .filter(|entry| entry.file_type().is_file())
4022 {
4023 let path = entry
4024 .path()
4025 .strip_prefix(root)
4026 .unwrap_or(entry.path())
4027 .to_owned();
4028 source.insert(
4029 path.to_string_lossy().to_string(),
4030 evidence(root, &path, Vec::new())?.digest,
4031 );
4032 }
4033 Ok(sha256_digest(canonical_json(&source)?.as_bytes()))
4034}
4035
4036fn verify_takeover_preconditions(
4037 root: &Path,
4038 writes: &[(PathBuf, String)],
4039 expected_fingerprint: &str,
4040 evidence_paths: &BTreeSet<PathBuf>,
4041) -> Result<()> {
4042 let actual_fingerprint = fingerprint(root, evidence_paths)?;
4043 if actual_fingerprint != expected_fingerprint {
4044 return Err(Error::Validation(
4045 "initialization plan source evidence became stale; rerun intentional init".to_owned(),
4046 ));
4047 }
4048 for (relative, _) in writes {
4049 if root.join(relative).exists() {
4050 return Err(Error::Validation(format!(
4051 "takeover target {} already exists; resolve the competing Intentional state first",
4052 relative.display()
4053 )));
4054 }
4055 }
4056 Ok(())
4057}
4058
4059fn takeover_operations(
4060 root: &Path,
4061 intents: &[ConvertedIntent],
4062 proxy_removals: &[ProxyRemoval],
4063) -> Vec<String> {
4064 let mut operations = vec![format!("write {CONFIG_PATH}")];
4065 operations.extend(intents.iter().map(|intent| {
4066 format!(
4067 "write {} from {}",
4068 intent.target.display(),
4069 intent.source.display()
4070 )
4071 }));
4072 operations.extend(
4073 takeover_deletes(root, proxy_removals)
4074 .iter()
4075 .map(|path| format!("delete {}", path.display())),
4076 );
4077 operations.push("after commit: intentional tag --baseline".to_owned());
4078 operations
4079}
4080
4081fn takeover_deletes(root: &Path, proxy_removals: &[ProxyRemoval]) -> Vec<PathBuf> {
4082 let mut paths = WalkDir::new(root.join(".changeset"))
4083 .into_iter()
4084 .filter_map(std::result::Result::ok)
4085 .filter(|entry| entry.file_type().is_file())
4086 .map(|entry| {
4087 entry
4088 .path()
4089 .strip_prefix(root)
4090 .unwrap_or(entry.path())
4091 .to_owned()
4092 })
4093 .collect::<Vec<_>>();
4094 paths.push(PathBuf::from(INIT_PLAN_PATH));
4095 paths.extend(proxy_removals.iter().map(|removal| removal.path.clone()));
4096 paths.sort();
4097 paths.dedup();
4098 paths
4099}
4100
4101fn verify_proxy_removal_preconditions(root: &Path, removals: &[ProxyRemoval]) -> Result<()> {
4102 for removal in removals {
4103 let references =
4104 non_displaced_manifest_references(root, &removal.path, &removal.native_identity)?;
4105 if !references.is_empty() {
4106 return Err(Error::Validation(format!(
4107 "cannot remove probable release-tool proxy {} while non-source-tool repository references remain in {}",
4108 removal.path.display(),
4109 references
4110 .iter()
4111 .map(|item| item.path.display().to_string())
4112 .collect::<Vec<_>>()
4113 .join(", ")
4114 )));
4115 }
4116 }
4117 Ok(())
4118}
4119
4120fn apply_takeover_transaction(
4121 root: &Path,
4122 writes: &[(PathBuf, String)],
4123 deletes: &[PathBuf],
4124) -> Result<()> {
4125 let transaction = root.join(TRANSACTION_PATH);
4126 if transaction.exists() {
4127 recover_interrupted_takeover(root)?;
4128 }
4129 let setup = (|| {
4130 std::fs::create_dir_all(transaction.join("original"))
4131 .map_err(|error| Error::io(&transaction, error))?;
4132 let affected = writes
4133 .iter()
4134 .map(|(path, _)| path.clone())
4135 .chain(deletes.iter().cloned())
4136 .collect::<BTreeSet<_>>();
4137 for relative in &affected {
4138 let source = root.join(relative);
4139 if source.is_file() {
4140 let backup = transaction.join("original").join(relative);
4141 if let Some(parent) = backup.parent() {
4142 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4143 }
4144 std::fs::copy(&source, &backup).map_err(|error| Error::io(&source, error))?;
4145 }
4146 }
4147 let manifest = serde_yaml::to_string(
4148 &affected
4149 .iter()
4150 .map(|path| path.to_string_lossy().to_string())
4151 .collect::<Vec<_>>(),
4152 )?;
4153 let manifest_path = transaction.join("manifest.yml");
4154 let manifest_temporary = transaction.join("manifest-tmp.yml");
4155 std::fs::write(&manifest_temporary, manifest)
4156 .map_err(|error| Error::io(&manifest_temporary, error))?;
4157 std::fs::rename(&manifest_temporary, &manifest_path)
4158 .map_err(|error| Error::io(&manifest_path, error))?;
4159 Ok::<(), Error>(())
4160 })();
4161 match setup {
4162 Ok(()) => {}
4163 Err(error) => {
4164 if transaction.exists() {
4165 std::fs::remove_dir_all(&transaction)
4166 .map_err(|cleanup| Error::io(&transaction, cleanup))?;
4167 }
4168 return Err(error);
4169 }
4170 }
4171 let result = (|| {
4172 for (relative, contents) in writes {
4173 let path = root.join(relative);
4174 if let Some(parent) = path.parent() {
4175 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4176 }
4177 let temporary = transaction.join("staged").join(relative);
4178 if let Some(parent) = temporary.parent() {
4179 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4180 }
4181 std::fs::write(&temporary, contents).map_err(|error| Error::io(&temporary, error))?;
4182 std::fs::rename(&temporary, &path).map_err(|error| Error::io(&path, error))?;
4183 }
4184 for relative in deletes {
4185 let path = root.join(relative);
4186 if path.is_file() {
4187 std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
4188 }
4189 }
4190 let changeset_directory = root.join(".changeset");
4191 if changeset_directory.is_dir()
4192 && std::fs::read_dir(&changeset_directory)
4193 .map_err(|error| Error::io(&changeset_directory, error))?
4194 .next()
4195 .is_none()
4196 {
4197 std::fs::remove_dir(&changeset_directory)
4198 .map_err(|error| Error::io(&changeset_directory, error))?;
4199 }
4200 Ok(())
4201 })();
4202 if result.is_err() {
4203 recover_interrupted_takeover(root)?;
4204 return result;
4205 }
4206 if let Err(error) = write_transaction_state(root, "committed") {
4207 recover_interrupted_takeover(root)?;
4208 return Err(error);
4209 }
4210 finish_transaction_cleanup(root)
4211}
4212
4213fn recover_interrupted_takeover(root: &Path) -> Result<()> {
4214 let transaction = root.join(TRANSACTION_PATH);
4215 let state_path = root.join(TRANSACTION_STATE_PATH);
4216 if state_path.is_file() {
4217 return finish_transaction_cleanup(root);
4218 }
4219 if !transaction.exists() {
4220 return Ok(());
4221 }
4222 let manifest_path = transaction.join("manifest.yml");
4223 if !manifest_path.is_file() {
4224 return std::fs::remove_dir_all(&transaction)
4225 .map_err(|error| Error::io(&transaction, error));
4226 }
4227 let manifest = std::fs::read_to_string(&manifest_path)
4228 .map_err(|error| Error::io(&manifest_path, error))?;
4229 let affected: Vec<String> = serde_yaml::from_str(&manifest)?;
4230 for relative in affected {
4231 let relative = PathBuf::from(relative);
4232 let target = root.join(&relative);
4233 let backup = transaction.join("original").join(&relative);
4234 if backup.is_file() {
4235 if let Some(parent) = target.parent() {
4236 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4237 }
4238 std::fs::copy(&backup, &target).map_err(|error| Error::io(&target, error))?;
4239 } else if target.is_file() {
4240 std::fs::remove_file(&target).map_err(|error| Error::io(&target, error))?;
4241 }
4242 }
4243 write_transaction_state(root, "rolled-back")?;
4244 finish_transaction_cleanup(root)
4245}
4246
4247fn write_transaction_state(root: &Path, state: &str) -> Result<()> {
4248 let path = root.join(TRANSACTION_STATE_PATH);
4249 let temporary = root.join(".intentional/.takeover-state-tmp");
4250 std::fs::write(&temporary, state).map_err(|error| Error::io(&temporary, error))?;
4251 std::fs::rename(&temporary, &path).map_err(|error| Error::io(&path, error))
4252}
4253
4254fn finish_transaction_cleanup(root: &Path) -> Result<()> {
4255 let transaction = root.join(TRANSACTION_PATH);
4256 if transaction.exists() {
4257 std::fs::remove_dir_all(&transaction).map_err(|error| Error::io(&transaction, error))?;
4258 }
4259 for path in [
4260 root.join(TRANSACTION_STATE_PATH),
4261 root.join(".intentional/.takeover-state-tmp"),
4262 ] {
4263 if path.is_file() {
4264 std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
4265 }
4266 }
4267 Ok(())
4268}
4269
4270fn annotate_choice_lines(yaml: &str) -> String {
4271 let comments = BTreeMap::from([
4272 ("suspended", "configured, but releases are blocked"),
4273 ("excluded", "outside Intentional's release-unit inventory"),
4274 ("managed", "configured and eligible for release"),
4275 (
4276 "removed",
4277 "reference removed or replaced and verified from the file",
4278 ),
4279 (
4280 "external",
4281 "publication sequencing remains with the external executor",
4282 ),
4283 ("intentional", "Intentional's contract owns this behavior"),
4284 ]);
4285 let mut output = String::new();
4286 let mut choices_indent = None;
4287 for line in yaml.lines() {
4288 let indent = line.len() - line.trim_start().len();
4289 if line.trim() == "choices:" {
4290 choices_indent = Some(indent);
4291 output.push_str(line);
4292 output.push('\n');
4293 continue;
4294 }
4295 if let Some(base) = choices_indent {
4296 if let Some(value) = line.trim().strip_prefix("- ") {
4297 if let Some(comment) = comments.get(value) {
4298 output.push_str(line);
4299 output.push_str(" # ");
4300 output.push_str(comment);
4301 output.push('\n');
4302 continue;
4303 }
4304 } else if indent <= base {
4305 choices_indent = None;
4306 }
4307 }
4308 output.push_str(line);
4309 output.push('\n');
4310 }
4311 output
4312}
4313
4314#[cfg(test)]
4315mod tests {
4316 use super::*;
4317
4318 const DIGEST: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
4319
4320 fn candidate(path: &str, resolution: Option<CandidateResolution>) -> DiscoveryCandidate {
4321 let path = PathBuf::from(path);
4322 let evidence = SourceEvidence {
4323 path: path.clone(),
4324 digest: DIGEST.to_owned(),
4325 lines: vec![1],
4326 };
4327 DiscoveryCandidate {
4328 id: DiscoveryCandidate::stable_id("sample-detector", &path).expect("stable id"),
4329 detector: "sample-detector".to_owned(),
4330 path: path.clone(),
4331 evidence: vec![evidence.clone()],
4332 native_identity: Some("sample-native-id".to_owned()),
4333 raw_version: Some(RawVersionEvidence {
4334 value: "raw-version-text".to_owned(),
4335 evidence: vec![evidence.clone()],
4336 }),
4337 projection: Some(CandidateProjectionSuggestion {
4338 adapter: Adapter::Npm,
4339 path,
4340 mode: ProjectionMode::Committed,
4341 pointer: None,
4342 }),
4343 tag: Some(CandidateTagSuggestion {
4344 id: "primary".to_owned(),
4345 role: TagRole::Primary,
4346 template: "{id}@{version}".to_owned(),
4347 }),
4348 diagnostics: vec![ExtractionDiagnostic {
4349 id: "sample-diagnostic".to_owned(),
4350 code: "sample-extraction".to_owned(),
4351 message: "Sample extraction evidence is incomplete.".to_owned(),
4352 evidence: vec![evidence],
4353 }],
4354 resolution,
4355 }
4356 }
4357
4358 fn candidate_plan(candidates: Vec<DiscoveryCandidate>) -> InitPlan {
4359 let config = Config::from_yaml(
4360 "contract: contract-1\nrelease-units:\n configured:\n path: configured\n tags:\n primary: { role: primary, template: '{id}@{version}' }\n",
4361 )
4362 .expect("candidate test config");
4363 InitPlan {
4364 schema: INIT_PLAN_SCHEMA.to_owned(),
4365 state: InitState::NeedsInput,
4366 source_kind: "changesets".to_owned(),
4367 source_fingerprint: DIGEST.to_owned(),
4368 inferred_config: config,
4369 discovery_candidates: candidates,
4370 diagnostics: Vec::new(),
4371 converted_intents: Vec::new(),
4372 parity: ParityResult {
4373 status: "equivalent".to_owned(),
4374 release_units: Vec::new(),
4375 },
4376 planned_operations: Vec::new(),
4377 post_commit_action: "intentional tag --baseline".to_owned(),
4378 }
4379 }
4380
4381 #[test]
4382 fn candidate_resolution_uses_only_documented_kebab_case_fields() {
4383 let independent: CandidateResolution =
4384 serde_yaml::from_str("kind: independent\nrelease-unit: sample-library\n")
4385 .expect("documented independent resolution");
4386 assert_eq!(
4387 independent,
4388 CandidateResolution::Independent {
4389 release_unit: "sample-library".to_owned(),
4390 }
4391 );
4392
4393 let projection: CandidateResolution = serde_yaml::from_str(
4394 "kind: projection\nrelease-unit: sample-library\ntarget-candidate: candidate:1234\n",
4395 )
4396 .expect("documented projection resolution");
4397 assert_eq!(
4398 projection,
4399 CandidateResolution::Projection {
4400 release_unit: "sample-library".to_owned(),
4401 target_candidate: Some("candidate:1234".to_owned()),
4402 }
4403 );
4404 let rendered = serde_yaml::to_string(&projection).expect("canonical resolution YAML");
4405 assert!(rendered.contains("release-unit: sample-library"));
4406 assert!(rendered.contains("target-candidate: candidate:1234"));
4407 assert!(!rendered.contains("release_unit"));
4408 assert!(!rendered.contains("target_candidate"));
4409
4410 for undocumented in [
4411 "kind: independent\nrelease_unit: sample-library\n",
4412 "kind: projection\nrelease-unit: sample-library\ntarget_candidate: candidate:1234\n",
4413 ] {
4414 serde_yaml::from_str::<CandidateResolution>(undocumented)
4415 .expect_err("undocumented snake-case field");
4416 }
4417 }
4418
4419 #[test]
4420 fn recognizes_supported_manifests() {
4421 assert_eq!(adapter_for(Path::new("package.json")), Some(Adapter::Npm));
4422 assert_eq!(
4423 adapter_for(Path::new("module.csproj")),
4424 Some(Adapter::Msbuild)
4425 );
4426 assert_eq!(adapter_for(Path::new("README.md")), None);
4427 }
4428
4429 #[test]
4430 fn choice_rendering_is_compact_and_explained() {
4431 let yaml = "choices:\n- suspended\n- excluded\nresolution: null\n";
4432 let rendered = annotate_choice_lines(yaml);
4433 assert!(rendered.contains("- suspended # configured, but releases are blocked"));
4434 assert!(rendered.contains("- excluded # outside Intentional's release-unit inventory"));
4435 }
4436
4437 #[test]
4438 fn proxy_reference_detection_ignores_prose_and_comments() {
4439 let path = "examples/sample.json";
4440 assert!(!structural_line_references_path(
4441 r#""description": "See examples/sample.json""#,
4442 path
4443 ));
4444 assert!(!structural_line_references_path(
4445 "note: examples/sample.json",
4446 path
4447 ));
4448 assert!(!structural_line_references_path(
4449 "// load examples/sample.json",
4450 path
4451 ));
4452 assert!(!structural_line_references_path(
4453 "const enabled = true; // load examples/sample.json",
4454 path
4455 ));
4456 assert!(!structural_line_references_path(
4457 "/* load examples/sample.json */",
4458 path
4459 ));
4460 assert!(structural_line_references_path(
4461 "run: curl https://registry.invalid && use examples/sample.json",
4462 path
4463 ));
4464 assert!(structural_line_references_path(
4465 r##"run: "curl https://registry.invalid/#fragment && use examples/sample.json""##,
4466 path
4467 ));
4468 assert!(structural_line_references_path(
4469 r#"const manifest = "examples/sample.json";"#,
4470 path
4471 ));
4472 }
4473
4474 #[test]
4475 fn npm_dependency_evidence_follows_the_materialized_projection_path() {
4476 struct TestDirectory(PathBuf);
4477 impl Drop for TestDirectory {
4478 fn drop(&mut self) {
4479 let _ = std::fs::remove_dir_all(&self.0);
4480 }
4481 }
4482
4483 let root = TestDirectory(std::env::temp_dir().join(format!(
4484 "intentional-init-{}-{}",
4485 std::process::id(),
4486 std::time::SystemTime::now()
4487 .duration_since(std::time::UNIX_EPOCH)
4488 .expect("system time")
4489 .as_nanos()
4490 )));
4491 for directory in ["base", "duplicates/alpha", "duplicates/beta"] {
4492 std::fs::create_dir_all(root.0.join(directory)).expect("create fixture directory");
4493 }
4494 std::fs::write(
4495 root.0.join("base/package.json"),
4496 r#"{"name":"sample-base","version":"1.0.0"}"#,
4497 )
4498 .expect("write base manifest");
4499 std::fs::write(
4500 root.0.join("duplicates/alpha/package.json"),
4501 r#"{"name":"sample-duplicate","version":"1.0.0"}"#,
4502 )
4503 .expect("write first duplicate");
4504 std::fs::write(
4505 root.0.join("duplicates/beta/package.json"),
4506 r#"{"name":"sample-duplicate","version":"1.0.0","dependencies":{"sample-base":"^1.0.0"}}"#,
4507 )
4508 .expect("write second duplicate");
4509 let mut config = Config::from_yaml(
4510 r#"contract: contract-1
4511release-units:
4512 sample-base:
4513 path: base
4514 projections:
4515 - { adapter: npm, file: package.json, mode: committed }
4516 tags:
4517 primary: { role: primary, template: '{id}@{version}' }
4518 sample-duplicate:
4519 path: .
4520 projections:
4521 - { adapter: npm, file: duplicates/alpha/package.json, mode: committed }
4522 - { adapter: npm, file: duplicates/beta/package.json, mode: committed }
4523 tags:
4524 primary: { role: primary, template: '{id}@{version}' }
4525"#,
4526 )
4527 .expect("fixture config");
4528
4529 let mut edges = derive_npm_dependencies(&root.0, &mut config).expect("dependency evidence");
4530 assert_eq!(edges.len(), 1);
4531 assert_eq!(edges[0].manifest, Path::new("duplicates/beta/package.json"));
4532 assert_eq!(
4533 config.release_units["sample-duplicate"].depends_on,
4534 vec!["sample-base"]
4535 );
4536
4537 config
4538 .release_units
4539 .get_mut("sample-duplicate")
4540 .expect("duplicate unit")
4541 .projections
4542 .retain(|projection| projection.file != "duplicates/beta/package.json");
4543 retain_materialized_npm_dependencies(&mut config, &mut edges);
4544 assert!(edges.is_empty());
4545 assert!(config.release_units["sample-duplicate"]
4546 .depends_on
4547 .is_empty());
4548
4549 let duplicate = config
4550 .release_units
4551 .get_mut("sample-duplicate")
4552 .expect("duplicate unit");
4553 duplicate.projections.clear();
4554 duplicate.depends_on = vec!["sample-base".to_owned()];
4555 retain_materialized_npm_dependencies(&mut config, &mut edges);
4556 assert!(config.release_units["sample-duplicate"]
4557 .depends_on
4558 .is_empty());
4559 }
4560
4561 #[test]
4562 fn candidate_identity_and_detector_results_are_stable() {
4563 let first = candidate(
4564 "examples/first.json",
4565 Some(CandidateResolution::Independent {
4566 release_unit: "sample-unit".to_owned(),
4567 }),
4568 );
4569 let second = candidate("examples/second.json", Some(CandidateResolution::Excluded));
4570 assert_eq!(
4571 first.id,
4572 DiscoveryCandidate::stable_id("sample-detector", Path::new("examples/first.json"))
4573 .expect("same identity")
4574 );
4575 assert_ne!(first.id, second.id);
4576 assert_ne!(
4577 first.id,
4578 DiscoveryCandidate::stable_id("other-detector", Path::new("examples/first.json"))
4579 .expect("different detector")
4580 );
4581 assert!(DiscoveryCandidate::stable_id(
4582 "-invalid-detector",
4583 Path::new("examples/first.json")
4584 )
4585 .is_err());
4586
4587 let result = DetectorResult {
4588 detector: "sample-detector".to_owned(),
4589 candidates: vec![second.clone(), first.clone()],
4590 };
4591 let ordered = result.into_candidates().expect("detector result valid");
4592 assert!(ordered[0].path < ordered[1].path);
4593
4594 let mut tampered = first.clone();
4595 tampered.id = format!("candidate:{}", "0".repeat(64));
4596 assert!(candidate_plan(vec![tampered])
4597 .validate()
4598 .expect_err("tampered candidate id rejected")
4599 .to_string()
4600 .contains("does not match detector"));
4601
4602 let foreign = DetectorResult {
4603 detector: "other-detector".to_owned(),
4604 candidates: vec![first.clone()],
4605 };
4606 assert!(foreign
4607 .validate()
4608 .expect_err("foreign detector candidate rejected")
4609 .to_string()
4610 .contains("returned candidate owned by"));
4611
4612 let yaml = candidate_plan(vec![first, second])
4613 .to_yaml()
4614 .expect("candidate plan serializes");
4615 assert!(yaml.contains("kind: independent"));
4616 assert!(yaml.contains("kind: excluded"));
4617 assert!(yaml.contains("raw-version:"));
4618 assert!(yaml.contains("diagnostics:"));
4619 }
4620
4621 #[test]
4622 fn changesets_projection_dedup_rejects_adapter_or_mode_conflicts() {
4623 for (adapter, mode) in [
4624 (Adapter::Cargo, ProjectionMode::Committed),
4625 (Adapter::Npm, ProjectionMode::Injected),
4626 ] {
4627 let mut config = candidate_plan(Vec::new()).inferred_config;
4628 let release_unit = config
4629 .release_units
4630 .get_mut("configured")
4631 .expect("configured release unit");
4632 release_unit.path = PathBuf::from("examples");
4633 release_unit.projections = vec![Projection {
4634 adapter: Adapter::Npm,
4635 file: ProjectionFile::unit_relative(Path::new("sample.json")),
4636 mode: ProjectionMode::Committed,
4637 pointer: None,
4638 }];
4639 let mut candidate = candidate(
4640 "examples/sample.json",
4641 Some(CandidateResolution::Projection {
4642 release_unit: "configured".to_owned(),
4643 target_candidate: None,
4644 }),
4645 );
4646 let suggestion = candidate
4647 .projection
4648 .as_mut()
4649 .expect("projection suggestion");
4650 suggestion.adapter = adapter;
4651 suggestion.mode = mode;
4652 let mut discovery = Discovery {
4653 config,
4654 ..Discovery::default()
4655 };
4656
4657 let error = apply_changesets_candidate_resolutions(&mut discovery, &[candidate])
4658 .expect_err("same target with different semantics rejected");
4659 assert!(error
4660 .to_string()
4661 .contains("conflicts with existing projection"));
4662 }
4663 }
4664
4665 #[test]
4666 fn resolved_candidates_reject_genuine_version_conflicts() {
4667 let mut first = candidate(
4668 "examples/first.json",
4669 Some(CandidateResolution::Projection {
4670 release_unit: "configured".to_owned(),
4671 target_candidate: None,
4672 }),
4673 );
4674 first.raw_version.as_mut().expect("first version").value = "1.0.0".to_owned();
4675 let mut second = candidate(
4676 "examples/second.json",
4677 Some(CandidateResolution::Projection {
4678 release_unit: "configured".to_owned(),
4679 target_candidate: None,
4680 }),
4681 );
4682 second.raw_version.as_mut().expect("second version").value = "2.0.0".to_owned();
4683 let mut discovery = Discovery {
4684 config: candidate_plan(Vec::new()).inferred_config,
4685 ..Discovery::default()
4686 };
4687
4688 let error = recompute_resolved_versions(&mut discovery, &[first, second])
4689 .expect_err("resolved version conflict");
4690 assert!(error
4691 .to_string()
4692 .contains("disagree on current version: 1.0.0, 2.0.0"));
4693 }
4694
4695 #[test]
4696 fn changesets_projection_rejects_an_unrelated_existing_owner() {
4697 let config = Config::from_yaml(
4698 r#"contract: contract-1
4699release-units:
4700 alpha:
4701 path: examples
4702 tags:
4703 primary: { role: primary, template: '{id}@{version}' }
4704 gamma:
4705 path: examples
4706 projections:
4707 - adapter: json
4708 file: shared.json
4709 mode: committed
4710 pointer: /version
4711 tags:
4712 primary: { role: primary, template: 'gamma@{version}' }
4713"#,
4714 )
4715 .expect("unrelated-owner config");
4716 let mut candidate = candidate(
4717 "examples/shared.json",
4718 Some(CandidateResolution::Projection {
4719 release_unit: "alpha".to_owned(),
4720 target_candidate: None,
4721 }),
4722 );
4723 candidate.projection = Some(CandidateProjectionSuggestion {
4724 adapter: Adapter::Json,
4725 path: candidate.path.clone(),
4726 mode: ProjectionMode::Committed,
4727 pointer: Some("/version".to_owned()),
4728 });
4729 let candidate_id = candidate.id.clone();
4730 let mut discovery = Discovery {
4731 config,
4732 ..Discovery::default()
4733 };
4734
4735 let error = apply_changesets_candidate_resolutions(&mut discovery, &[candidate])
4736 .expect_err("unrelated projection ownership rejected");
4737 assert_eq!(
4738 error.to_string(),
4739 format!(
4740 "validation failed: Changesets discovery candidate {candidate_id} projection is already owned by release unit gamma, not alpha"
4741 )
4742 );
4743 }
4744
4745 #[test]
4746 fn rejects_plan_without_required_discovery_candidates() {
4747 let yaml = candidate_plan(Vec::new())
4748 .to_yaml()
4749 .expect("complete plan serializes");
4750 assert!(yaml.contains("discovery-candidates: []"));
4751 let incomplete = yaml.replace("discovery-candidates: []\n", "");
4752 let error = serde_yaml::from_str::<InitPlan>(&incomplete)
4753 .expect_err("missing discovery candidate collection rejected");
4754 assert!(error
4755 .to_string()
4756 .contains("missing field `discovery-candidates`"));
4757 }
4758
4759 #[test]
4760 fn empty_inferred_config_obeys_its_published_schema_shape() {
4761 let mut plan = candidate_plan(vec![candidate("examples/sample.json", None)]);
4762 plan.inferred_config = Config::default();
4763 plan.validate().expect("schema-compatible empty config");
4764
4765 plan.inferred_config
4766 .discovery
4767 .excluded_paths
4768 .push(ExcludedPathReceipt {
4769 detector: "sample-detector".to_owned(),
4770 path: PathBuf::from("examples/sample.json"),
4771 evidence_digest: DIGEST.to_owned(),
4772 });
4773 assert!(plan
4774 .validate()
4775 .expect_err("discovery is forbidden on an empty inferred config")
4776 .to_string()
4777 .contains("may only contain schema, contract, settings, and release-units"));
4778
4779 plan.inferred_config.discovery.excluded_paths.clear();
4780 plan.inferred_config.schema = Some("https://example.invalid/config.yml".to_owned());
4781 assert!(plan
4782 .validate()
4783 .expect_err("foreign schema is forbidden on an empty inferred config")
4784 .to_string()
4785 .contains("schema must be"));
4786 }
4787
4788 #[test]
4789 fn candidate_tag_templates_use_the_shared_contract() {
4790 for template in ["v{version}", "{version}-{version}", "{id}-{id}@{version}"] {
4791 let mut invalid = candidate("examples/template.json", None);
4792 invalid.tag.as_mut().expect("tag suggestion").template = template.to_owned();
4793 assert!(candidate_plan(vec![invalid])
4794 .validate()
4795 .expect_err("invalid candidate tag template rejected")
4796 .to_string()
4797 .contains("tag template"));
4798 }
4799 }
4800
4801 #[test]
4802 fn published_init_schema_carries_candidate_resolution_variants() {
4803 let schema: serde_yaml::Value =
4804 serde_yaml::from_str(include_str!("../../../schemas/init-plan.yml"))
4805 .expect("initialization-plan schema parses");
4806 assert!(schema["required"]
4807 .as_sequence()
4808 .expect("required plan fields")
4809 .iter()
4810 .any(|field| field.as_str() == Some("discovery-candidates")));
4811 let variants = schema["$defs"]["candidate-resolution"]["oneOf"]
4812 .as_sequence()
4813 .expect("candidate resolution variants");
4814 assert_eq!(variants.len(), 3);
4815 let kinds = variants
4816 .iter()
4817 .map(|variant| {
4818 variant["properties"]["kind"]["const"]
4819 .as_str()
4820 .expect("resolution kind")
4821 })
4822 .collect::<BTreeSet<_>>();
4823 assert_eq!(
4824 kinds,
4825 BTreeSet::from(["excluded", "independent", "projection"])
4826 );
4827 assert_eq!(
4828 schema["$defs"]["discovery-candidate"]["properties"]["tag"]["properties"]["template"]
4829 ["pattern"]
4830 .as_str(),
4831 Some(
4832 r"^(?![\s\S]*v\{version\})(?![\s\S]*\{version\}[\s\S]*\{version\})(?![\s\S]*\{id\}[\s\S]*\{id\})(?=[\s\S]*\{version\})[\s\S]*$"
4833 )
4834 );
4835 }
4836
4837 #[test]
4838 fn accepts_configured_and_same_plan_projection_targets() {
4839 let configured = candidate(
4840 "examples/configured.json",
4841 Some(CandidateResolution::Projection {
4842 release_unit: "configured".to_owned(),
4843 target_candidate: None,
4844 }),
4845 );
4846 let independent = candidate(
4847 "examples/independent.json",
4848 Some(CandidateResolution::Independent {
4849 release_unit: "planned".to_owned(),
4850 }),
4851 );
4852 let projection = candidate(
4853 "examples/projection.json",
4854 Some(CandidateResolution::Projection {
4855 release_unit: "planned".to_owned(),
4856 target_candidate: Some(independent.id.clone()),
4857 }),
4858 );
4859 candidate_plan(vec![configured, independent, projection])
4860 .validate()
4861 .expect("both projection target kinds accepted");
4862 }
4863
4864 #[test]
4865 fn changesets_materializes_any_unrepresented_candidate_projection_once() {
4866 let config = candidate_plan(Vec::new()).inferred_config;
4867 let mut generic = candidate(
4868 "configured/metadata.json",
4869 Some(CandidateResolution::Projection {
4870 release_unit: "configured".to_owned(),
4871 target_candidate: None,
4872 }),
4873 );
4874 generic.projection = Some(CandidateProjectionSuggestion {
4875 adapter: Adapter::Json,
4876 path: generic.path.clone(),
4877 mode: ProjectionMode::Committed,
4878 pointer: Some("/version".to_owned()),
4879 });
4880
4881 let mut discovery = Discovery {
4882 config,
4883 ..Discovery::default()
4884 };
4885 apply_changesets_candidate_resolutions(&mut discovery, std::slice::from_ref(&generic))
4886 .expect("generic projection materialized");
4887 assert_eq!(
4888 discovery.config.release_units["configured"]
4889 .projections
4890 .len(),
4891 1
4892 );
4893 assert_eq!(
4894 discovery.config.release_units["configured"].projections[0]
4895 .pointer
4896 .as_deref(),
4897 Some("/version")
4898 );
4899
4900 apply_changesets_candidate_resolutions(&mut discovery, std::slice::from_ref(&generic))
4901 .expect("represented projection not duplicated");
4902 assert_eq!(
4903 discovery.config.release_units["configured"]
4904 .projections
4905 .len(),
4906 1
4907 );
4908 }
4909
4910 #[test]
4911 fn rejects_duplicate_creators_and_absent_projection_targets() {
4912 let first = candidate(
4913 "examples/first.json",
4914 Some(CandidateResolution::Independent {
4915 release_unit: "duplicate".to_owned(),
4916 }),
4917 );
4918 let second = candidate(
4919 "examples/second.json",
4920 Some(CandidateResolution::Independent {
4921 release_unit: "duplicate".to_owned(),
4922 }),
4923 );
4924 assert!(candidate_plan(vec![first, second])
4925 .validate()
4926 .expect_err("duplicate creators rejected")
4927 .to_string()
4928 .contains("duplicate creator"));
4929
4930 let absent = candidate(
4931 "examples/absent.json",
4932 Some(CandidateResolution::Projection {
4933 release_unit: "missing".to_owned(),
4934 target_candidate: None,
4935 }),
4936 );
4937 assert!(candidate_plan(vec![absent])
4938 .validate()
4939 .expect_err("absent configured target rejected")
4940 .to_string()
4941 .contains("absent configured release unit"));
4942 }
4943
4944 #[test]
4945 fn rejects_projection_cycles_and_release_unit_mismatches() {
4946 let mut first = candidate("examples/first.json", None);
4947 let mut second = candidate("examples/second.json", None);
4948 first.resolution = Some(CandidateResolution::Projection {
4949 release_unit: "planned".to_owned(),
4950 target_candidate: Some(second.id.clone()),
4951 });
4952 second.resolution = Some(CandidateResolution::Projection {
4953 release_unit: "planned".to_owned(),
4954 target_candidate: Some(first.id.clone()),
4955 });
4956 assert!(candidate_plan(vec![first, second])
4957 .validate()
4958 .expect_err("candidate cycle rejected")
4959 .to_string()
4960 .contains("projection cycle"));
4961
4962 let independent = candidate(
4963 "examples/independent.json",
4964 Some(CandidateResolution::Independent {
4965 release_unit: "planned".to_owned(),
4966 }),
4967 );
4968 let mismatched = candidate(
4969 "examples/mismatched.json",
4970 Some(CandidateResolution::Projection {
4971 release_unit: "other".to_owned(),
4972 target_candidate: Some(independent.id.clone()),
4973 }),
4974 );
4975 assert!(candidate_plan(vec![independent, mismatched])
4976 .validate()
4977 .expect_err("mismatched target rejected")
4978 .to_string()
4979 .contains("target"));
4980
4981 let independent = candidate(
4982 "examples/independent.json",
4983 Some(CandidateResolution::Independent {
4984 release_unit: "planned".to_owned(),
4985 }),
4986 );
4987 let intermediate = candidate(
4988 "examples/intermediate.json",
4989 Some(CandidateResolution::Projection {
4990 release_unit: "planned".to_owned(),
4991 target_candidate: Some(independent.id.clone()),
4992 }),
4993 );
4994 let chained = candidate(
4995 "examples/chained.json",
4996 Some(CandidateResolution::Projection {
4997 release_unit: "planned".to_owned(),
4998 target_candidate: Some(intermediate.id.clone()),
4999 }),
5000 );
5001 assert!(candidate_plan(vec![independent, intermediate, chained])
5002 .validate()
5003 .expect_err("projection chain rejected")
5004 .to_string()
5005 .contains("not an independent creator"));
5006 }
5007}