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