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