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> {
2961 if adapter == Adapter::Npm {
2962 let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2963 let value: JsonValue = serde_json::from_str(&text)
2964 .map_err(|error| Error::Validation(format!("invalid {}: {error}", path.display())))?;
2965 return Ok(value["name"].is_string());
2966 }
2967 if adapter != Adapter::Cargo {
2968 return Ok(true);
2969 }
2970 let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2971 let document = text
2972 .parse::<toml_edit::DocumentMut>()
2973 .map_err(|error| Error::Validation(format!("invalid Cargo.toml: {error}")))?;
2974 Ok(document.get("package").is_some())
2975}
2976
2977fn manifest_identity(path: &Path, adapter: Adapter) -> Result<(String, Option<String>)> {
2978 let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2979 match adapter {
2980 Adapter::Npm => {
2981 let value: JsonValue = serde_json::from_str(&text).map_err(|error| {
2982 Error::Validation(format!("invalid {}: {error}", path.display()))
2983 })?;
2984 Ok((
2985 required_string(&value["name"], path, "name")?,
2986 value["version"].as_str().map(str::to_owned),
2987 ))
2988 }
2989 Adapter::Cargo => {
2990 let document = text.parse::<toml_edit::DocumentMut>().map_err(|error| {
2991 Error::Validation(format!("invalid {}: {error}", path.display()))
2992 })?;
2993 let package = document.get("package").ok_or_else(|| {
2994 Error::Validation(format!("{} has no package table", path.display()))
2995 })?;
2996 let name = package
2997 .get("name")
2998 .and_then(toml_edit::Item::as_str)
2999 .ok_or_else(|| {
3000 Error::Validation(format!("{} has no package.name", path.display()))
3001 })?;
3002 let version = package
3003 .get("version")
3004 .and_then(toml_edit::Item::as_str)
3005 .map(str::to_owned);
3006 Ok((name.to_owned(), version))
3007 }
3008 Adapter::Pub => {
3009 let value: serde_yaml::Value = serde_yaml::from_str(&text)?;
3010 let name = value["name"]
3011 .as_str()
3012 .ok_or_else(|| Error::Validation(format!("{} has no name", path.display())))?;
3013 Ok((
3014 name.to_owned(),
3015 value["version"].as_str().map(str::to_owned),
3016 ))
3017 }
3018 Adapter::Python => {
3019 let document = text.parse::<toml_edit::DocumentMut>().map_err(|error| {
3020 Error::Validation(format!("invalid {}: {error}", path.display()))
3021 })?;
3022 let project = document.get("project").ok_or_else(|| {
3023 Error::Validation(format!("{} has no project table", path.display()))
3024 })?;
3025 let name = project
3026 .get("name")
3027 .and_then(toml_edit::Item::as_str)
3028 .ok_or_else(|| {
3029 Error::Validation(format!("{} has no project.name", path.display()))
3030 })?;
3031 let version = project
3032 .get("version")
3033 .and_then(toml_edit::Item::as_str)
3034 .map(str::to_owned);
3035 Ok((name.to_owned(), version))
3036 }
3037 Adapter::Go => {
3038 let name = text
3039 .lines()
3040 .find_map(|line| line.trim().strip_prefix("module "))
3041 .ok_or_else(|| {
3042 Error::Validation(format!("{} has no module directive", path.display()))
3043 })?;
3044 Ok((name.to_owned(), None))
3045 }
3046 Adapter::Msbuild => {
3047 let name = xml_element(&text, "PackageId")
3048 .or_else(|| xml_element(&text, "AssemblyName"))
3049 .or_else(|| {
3050 path.file_stem()
3051 .and_then(|value| value.to_str())
3052 .map(str::to_owned)
3053 })
3054 .ok_or_else(|| {
3055 Error::Validation(format!("{} has no package identity", path.display()))
3056 })?;
3057 Ok((name, xml_element(&text, "Version")))
3058 }
3059 Adapter::Json | Adapter::Toml | Adapter::Yaml => {
3060 unreachable!("generic adapters are not discovered")
3061 }
3062 }
3063}
3064
3065fn npm_manifest_is_private(path: &Path) -> Result<bool> {
3066 let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
3067 let value: JsonValue = serde_json::from_str(&text)
3068 .map_err(|error| Error::Validation(format!("invalid {}: {error}", path.display())))?;
3069 Ok(value["private"].as_bool() == Some(true))
3070}
3071
3072fn required_string(value: &JsonValue, path: &Path, field: &str) -> Result<String> {
3073 value
3074 .as_str()
3075 .map(str::to_owned)
3076 .ok_or_else(|| Error::Validation(format!("{} has no string {field}", path.display())))
3077}
3078
3079fn xml_element(text: &str, name: &str) -> Option<String> {
3080 let start = format!("<{name}>");
3081 let end = format!("</{name}>");
3082 text.split_once(&start)?
3083 .1
3084 .split_once(&end)
3085 .map(|(value, _)| value.trim().to_owned())
3086}
3087
3088fn derive_npm_dependencies(root: &Path, config: &mut Config) -> Result<Vec<NpmDependencyEdge>> {
3089 let ids = config
3090 .release_units
3091 .keys()
3092 .cloned()
3093 .collect::<BTreeSet<_>>();
3094 let mut edges = Vec::new();
3095 for (id, release_unit) in &mut config.release_units {
3096 let npm_manifests = release_unit
3097 .projections
3098 .iter()
3099 .filter(|projection| projection.adapter == Adapter::Npm)
3100 .map(|projection| projection.file.workspace_path(&release_unit.path))
3101 .collect::<Vec<_>>();
3102 if npm_manifests.is_empty() {
3103 continue;
3104 }
3105 let mut dependencies = BTreeSet::new();
3106 for manifest in npm_manifests {
3107 let path = root.join(&manifest);
3108 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
3109 let value: JsonValue = serde_json::from_str(&text).map_err(|error| {
3110 Error::Validation(format!("invalid {}: {error}", path.display()))
3111 })?;
3112 for (group, kind) in [
3113 ("dependencies", NpmDependencyKind::Dependency),
3114 ("optionalDependencies", NpmDependencyKind::Optional),
3115 ("peerDependencies", NpmDependencyKind::Peer),
3116 ("devDependencies", NpmDependencyKind::Dev),
3117 ] {
3118 if let Some(entries) = value[group].as_object() {
3119 for (dependency, range) in entries {
3120 if !ids.contains(dependency) {
3121 continue;
3122 }
3123 if kind != NpmDependencyKind::Dev {
3124 dependencies.insert(dependency.clone());
3125 }
3126 edges.push(NpmDependencyEdge {
3127 dependent: id.clone(),
3128 dependency: dependency.clone(),
3129 kind,
3130 range: range.as_str().unwrap_or_default().to_owned(),
3131 manifest: manifest.clone(),
3132 });
3133 }
3134 }
3135 }
3136 }
3137 release_unit.depends_on = dependencies.into_iter().collect();
3138 }
3139 edges.sort_by(|left, right| {
3140 (
3141 &left.dependent,
3142 &left.dependency,
3143 left.kind as u8,
3144 &left.range,
3145 &left.manifest,
3146 )
3147 .cmp(&(
3148 &right.dependent,
3149 &right.dependency,
3150 right.kind as u8,
3151 &right.range,
3152 &right.manifest,
3153 ))
3154 });
3155 Ok(edges)
3156}
3157
3158fn retain_materialized_npm_dependencies(config: &mut Config, edges: &mut Vec<NpmDependencyEdge>) {
3159 edges.retain(|edge| {
3160 config
3161 .release_units
3162 .get(&edge.dependent)
3163 .is_some_and(|unit| {
3164 unit.projections.iter().any(|projection| {
3165 projection.adapter == Adapter::Npm
3166 && projection.file.workspace_path(&unit.path) == edge.manifest
3167 })
3168 })
3169 && config.release_units.contains_key(&edge.dependency)
3170 });
3171 for (id, unit) in &mut config.release_units {
3172 unit.depends_on = edges
3173 .iter()
3174 .filter(|edge| edge.dependent == *id && edge.kind != NpmDependencyKind::Dev)
3175 .map(|edge| edge.dependency.clone())
3176 .collect::<BTreeSet<_>>()
3177 .into_iter()
3178 .collect();
3179 }
3180}
3181
3182fn load_changesets_intents(root: &Path) -> Result<Vec<ConvertedIntent>> {
3183 let directory = root.join(".changeset");
3184 let mut paths = std::fs::read_dir(&directory)
3185 .map_err(|error| Error::io(&directory, error))?
3186 .filter_map(std::result::Result::ok)
3187 .map(|entry| entry.path())
3188 .filter(|path| path.extension().is_some_and(|extension| extension == "md"))
3189 .filter(|path| path.file_name().is_none_or(|name| name != "README.md"))
3190 .collect::<Vec<_>>();
3191 paths.sort();
3192 paths
3193 .into_iter()
3194 .map(|path| {
3195 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
3196 let rest = text.strip_prefix("---\n").ok_or_else(|| {
3197 Error::Validation(format!("{} has no Changesets frontmatter", path.display()))
3198 })?;
3199 let (frontmatter, message) = rest.split_once("\n---\n").ok_or_else(|| {
3200 Error::Validation(format!(
3201 "{} has unterminated Changesets frontmatter",
3202 path.display()
3203 ))
3204 })?;
3205 let packages = serde_yaml::from_str(frontmatter)?;
3206 let id = path
3207 .file_stem()
3208 .and_then(|value| value.to_str())
3209 .ok_or_else(|| {
3210 Error::Validation(format!("invalid Changesets filename {}", path.display()))
3211 })?
3212 .to_owned();
3213 Ok(ConvertedIntent {
3214 target: PathBuf::from(crate::intent::INTENTS_PATH).join(format!("{id}.md")),
3215 source: path.strip_prefix(root).unwrap_or(&path).to_owned(),
3216 id,
3217 release_units: packages,
3218 graduates: BTreeSet::new(),
3219 message: message.trim().to_owned(),
3220 })
3221 })
3222 .collect()
3223}
3224
3225fn parse_groups(value: &JsonValue) -> Result<Vec<Vec<String>>> {
3226 value
3227 .as_array()
3228 .into_iter()
3229 .flatten()
3230 .map(|group| {
3231 group
3232 .as_array()
3233 .ok_or_else(|| {
3234 Error::Validation("Changesets release group must be an array".to_owned())
3235 })?
3236 .iter()
3237 .map(|member| {
3238 member.as_str().map(str::to_owned).ok_or_else(|| {
3239 Error::Validation("Changesets group member must be a string".to_owned())
3240 })
3241 })
3242 .collect()
3243 })
3244 .collect()
3245}
3246
3247fn collect_json_strings(value: &JsonValue, output: &mut BTreeSet<String>) {
3248 match value {
3249 JsonValue::String(value) => {
3250 output.insert(value.clone());
3251 }
3252 JsonValue::Array(values) => {
3253 for value in values {
3254 collect_json_strings(value, output);
3255 }
3256 }
3257 _ => {}
3258 }
3259}
3260
3261fn changesets_private_package_settings(value: &JsonValue) -> Option<(bool, bool)> {
3262 match value {
3263 JsonValue::Bool(enabled) => Some((*enabled, *enabled)),
3264 JsonValue::Object(settings) => Some((
3265 settings
3266 .get("version")
3267 .and_then(JsonValue::as_bool)
3268 .unwrap_or(true),
3269 settings
3270 .get("tag")
3271 .and_then(JsonValue::as_bool)
3272 .unwrap_or(true),
3273 )),
3274 _ => None,
3275 }
3276}
3277
3278fn remap_groups(groups: &mut Vec<Vec<String>>, identity_map: &BTreeMap<String, String>) {
3279 for group in groups.iter_mut() {
3280 for release_unit in group.iter_mut() {
3281 if let Some(target) = identity_map.get(release_unit) {
3282 *release_unit = target.clone();
3283 }
3284 }
3285 group.sort();
3286 group.dedup();
3287 }
3288 groups.retain(|group| group.len() > 1);
3289}
3290
3291fn remap_converted_intents(
3292 intents: &mut [ConvertedIntent],
3293 identity_map: &BTreeMap<String, String>,
3294 config: &Config,
3295) -> Result<()> {
3296 for intent in intents {
3297 let mut release_units = BTreeMap::<String, Bump>::new();
3298 for (id, bump) in std::mem::take(&mut intent.release_units) {
3299 let logical_id = identity_map.get(&id).cloned().unwrap_or(id);
3300 if !config.release_units.contains_key(&logical_id) {
3301 return Err(Error::Validation(format!(
3302 "Changesets intent {} references release unit {logical_id}, which has no Intentional release-unit identity",
3303 intent.id
3304 )));
3305 }
3306 release_units
3307 .entry(logical_id)
3308 .and_modify(|existing| *existing = (*existing).max(bump))
3309 .or_insert(bump);
3310 }
3311 intent.release_units = release_units;
3312 }
3313 Ok(())
3314}
3315
3316fn scan_changesets_integrations(root: &Path) -> Result<Vec<SourceEvidence>> {
3317 let mut findings = Vec::new();
3318 for entry in WalkDir::new(root)
3319 .into_iter()
3320 .filter_entry(|entry| !hard_excluded(root, entry.path()))
3321 .filter_map(std::result::Result::ok)
3322 .filter(|entry| entry.file_type().is_file())
3323 {
3324 let path = entry.path();
3325 let relative = path.strip_prefix(root).unwrap_or(path);
3326 if relative.starts_with(".changeset") || relative.starts_with(".intentional") {
3327 continue;
3328 }
3329 let relevant = relative == Path::new("package.json")
3330 || relative == Path::new("pnpm-lock.yaml")
3331 || relative.starts_with(".github")
3332 || relative.starts_with("scripts")
3333 || relative
3334 .components()
3335 .any(|component| component.as_os_str().to_string_lossy().contains("test"));
3336 if !relevant {
3337 continue;
3338 }
3339 let Ok(text) = std::fs::read_to_string(path) else {
3340 continue;
3341 };
3342 let lines = text
3343 .lines()
3344 .enumerate()
3345 .filter(|(_, line)| line.to_ascii_lowercase().contains("changeset"))
3346 .map(|(index, _)| index + 1)
3347 .collect::<Vec<_>>();
3348 if !lines.is_empty() {
3349 findings.push(evidence(root, relative, lines)?);
3350 }
3351 }
3352 findings.sort();
3353 Ok(findings)
3354}
3355
3356fn scan_external_release_evidence(root: &Path) -> Result<Vec<SourceEvidence>> {
3357 let mut findings = Vec::new();
3358 for entry in WalkDir::new(root)
3359 .into_iter()
3360 .filter_entry(|entry| !hard_excluded(root, entry.path()))
3361 .filter_map(std::result::Result::ok)
3362 .filter(|entry| entry.file_type().is_file())
3363 {
3364 let path = entry.path();
3365 let relative = path.strip_prefix(root).unwrap_or(path);
3366 let is_release_file = relative.file_name().is_some_and(|name| {
3367 name.to_string_lossy()
3368 .to_ascii_lowercase()
3369 .contains("release")
3370 });
3371 if (relative.starts_with("scripts") || relative.starts_with(".github")) && is_release_file {
3372 findings.push(evidence(root, relative, Vec::new())?);
3373 }
3374 }
3375 findings.sort();
3376 Ok(findings)
3377}
3378
3379fn release_tool_proxy_diagnostics(
3380 root: &Path,
3381 candidates: &[DiscoveryCandidate],
3382 referenced_names: &BTreeSet<String>,
3383) -> Result<Vec<InitDiagnostic>> {
3384 let workspace_paths = workspace_manifest_paths(root)?;
3385 let mut diagnostics = Vec::new();
3386 for candidate in candidates {
3387 let Some(native_identity) = candidate.native_identity.as_deref() else {
3388 continue;
3389 };
3390 let Some(projection) = candidate.projection.as_ref() else {
3391 continue;
3392 };
3393 if projection.adapter != Adapter::Npm {
3394 continue;
3395 }
3396 let workspace_member = workspace_paths.contains(&root.join(&candidate.path));
3397 if !referenced_names.contains(native_identity) && !workspace_member {
3398 continue;
3399 }
3400 let Some(CandidateResolution::Projection { release_unit, .. }) =
3401 candidate.resolution.as_ref()
3402 else {
3403 continue;
3404 };
3405 if release_unit == native_identity {
3406 continue;
3407 }
3408 let target_candidates = candidates
3409 .iter()
3410 .filter(|target| target.id != candidate.id)
3411 .filter(|target| {
3412 target.projection.as_ref().is_some_and(|projection| {
3413 projection.adapter != Adapter::Npm
3414 && matches!(
3415 target.resolution.as_ref(),
3416 Some(CandidateResolution::Projection {
3417 release_unit: target_release_unit,
3418 ..
3419 }) if target_release_unit == release_unit
3420 )
3421 })
3422 })
3423 .collect::<Vec<_>>();
3424 if target_candidates.is_empty() {
3425 continue;
3426 }
3427
3428 let mut source_references = changesets_identity_evidence(root, native_identity)?;
3429 if source_references.is_empty() && workspace_member {
3430 source_references.push(evidence(root, Path::new(CHANGESETS_CONFIG), Vec::new())?);
3431 source_references.extend(workspace_declaration_evidence(root)?);
3432 source_references.sort();
3433 source_references.dedup();
3434 }
3435 if source_references.is_empty() {
3436 continue;
3437 }
3438 let (private, responsibilities) = npm_manifest_responsibilities(root, &candidate.path)?;
3439 let mut supporting_evidence = candidate.evidence.clone();
3440 supporting_evidence.extend(source_references);
3441 for target in &target_candidates {
3442 supporting_evidence.extend(target.evidence.clone());
3443 }
3444 supporting_evidence.sort();
3445 supporting_evidence.dedup();
3446
3447 let mut contradictory_evidence = Vec::new();
3448 if !private || !responsibilities.is_empty() {
3449 contradictory_evidence.extend(candidate.evidence.clone());
3450 }
3451 contradictory_evidence.extend(non_displaced_manifest_references(
3452 root,
3453 &candidate.path,
3454 native_identity,
3455 )?);
3456 contradictory_evidence.sort();
3457 contradictory_evidence.dedup();
3458
3459 let recommended =
3460 (private && responsibilities.is_empty() && contradictory_evidence.is_empty())
3461 .then(|| "remove".to_owned());
3462 let mut evidence = supporting_evidence.clone();
3463 evidence.extend(contradictory_evidence.clone());
3464 evidence.sort();
3465 evidence.dedup();
3466 let responsibility_message = if responsibilities.is_empty() {
3467 "no independent npm responsibility keys were observed".to_owned()
3468 } else {
3469 format!(
3470 "npm responsibility keys were observed: {}",
3471 responsibilities.join(", ")
3472 )
3473 };
3474 diagnostics.push(InitDiagnostic {
3475 id: format!("release-tool-proxy-disposition:{}", candidate.id),
3476 code: "release-tool-proxy-disposition".to_owned(),
3477 message: format!(
3478 "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}.",
3479 candidate.path.display()
3480 ),
3481 evidence,
3482 choices: vec!["retain".to_owned(), "remove".to_owned()],
3483 recommended,
3484 resolution: None,
3485 verified: false,
3486 invalidated_resolution: false,
3487 supporting_evidence,
3488 contradictory_evidence,
3489 uncertainty: Some(
3490 "This is a best-effort structural assessment, not proof that the manifest is disposable. Descriptions, comments, prose, and semantic keyword matches are not evidence."
3491 .to_owned(),
3492 ),
3493 });
3494 }
3495 diagnostics.sort_by(|left, right| left.id.cmp(&right.id));
3496 Ok(diagnostics)
3497}
3498
3499fn npm_manifest_responsibilities(root: &Path, relative: &Path) -> Result<(bool, Vec<String>)> {
3500 let path = root.join(relative);
3501 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
3502 let value: JsonValue = serde_json::from_str(&text)
3503 .map_err(|error| Error::Validation(format!("invalid package.json: {error}")))?;
3504 let object = value.as_object().ok_or_else(|| {
3505 Error::Validation(format!("{} must contain a JSON object", relative.display()))
3506 })?;
3507 let private = object
3508 .get("private")
3509 .and_then(JsonValue::as_bool)
3510 .unwrap_or(false);
3511 let responsibility_keys = [
3512 "bin",
3513 "browser",
3514 "bundledDependencies",
3515 "cpu",
3516 "dependencies",
3517 "devDependencies",
3518 "engines",
3519 "exports",
3520 "files",
3521 "main",
3522 "module",
3523 "optionalDependencies",
3524 "os",
3525 "peerDependencies",
3526 "publishConfig",
3527 "repository",
3528 "scripts",
3529 "types",
3530 "typings",
3531 "workspaces",
3532 ];
3533 let responsibilities = responsibility_keys
3534 .into_iter()
3535 .filter(|key| object.get(*key).is_some_and(json_value_has_content))
3536 .map(str::to_owned)
3537 .collect();
3538 Ok((private, responsibilities))
3539}
3540
3541fn json_value_has_content(value: &JsonValue) -> bool {
3542 match value {
3543 JsonValue::Null => false,
3544 JsonValue::Bool(value) => *value,
3545 JsonValue::Number(_) => true,
3546 JsonValue::String(value) => !value.is_empty(),
3547 JsonValue::Array(values) => !values.is_empty(),
3548 JsonValue::Object(values) => !values.is_empty(),
3549 }
3550}
3551
3552fn changesets_identity_evidence(root: &Path, identity: &str) -> Result<Vec<SourceEvidence>> {
3553 let mut findings = Vec::new();
3554 let config_path = PathBuf::from(CHANGESETS_CONFIG);
3555 let config_text = std::fs::read_to_string(root.join(&config_path))
3556 .map_err(|error| Error::io(root.join(&config_path), error))?;
3557 let config: JsonValue = serde_json::from_str(&config_text)
3558 .map_err(|error| Error::Validation(format!("invalid Changesets config: {error}")))?;
3559 if json_contains_exact_string(&config, identity) {
3560 findings.push(evidence(root, &config_path, Vec::new())?);
3561 }
3562 for entry in WalkDir::new(root.join(".changeset"))
3563 .min_depth(1)
3564 .max_depth(1)
3565 .into_iter()
3566 .filter_map(std::result::Result::ok)
3567 .filter(|entry| entry.file_type().is_file())
3568 .filter(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("md"))
3569 .filter(|entry| entry.file_name() != "README.md")
3570 {
3571 let text = std::fs::read_to_string(entry.path())
3572 .map_err(|error| Error::io(entry.path(), error))?;
3573 let Some(frontmatter) = text
3574 .strip_prefix("---\n")
3575 .and_then(|text| text.split_once("\n---"))
3576 .map(|(frontmatter, _)| frontmatter)
3577 else {
3578 continue;
3579 };
3580 let values: BTreeMap<String, Bump> = serde_yaml::from_str(frontmatter)?;
3581 if values.contains_key(identity) {
3582 let relative = entry
3583 .path()
3584 .strip_prefix(root)
3585 .unwrap_or(entry.path())
3586 .to_owned();
3587 findings.push(evidence(root, &relative, Vec::new())?);
3588 }
3589 }
3590 findings.sort();
3591 findings.dedup();
3592 Ok(findings)
3593}
3594
3595fn json_contains_exact_string(value: &JsonValue, expected: &str) -> bool {
3596 match value {
3597 JsonValue::String(value) => value == expected,
3598 JsonValue::Array(values) => values
3599 .iter()
3600 .any(|value| json_contains_exact_string(value, expected)),
3601 JsonValue::Object(values) => values
3602 .iter()
3603 .any(|(key, value)| key == expected || json_contains_exact_string(value, expected)),
3604 JsonValue::Null | JsonValue::Bool(_) | JsonValue::Number(_) => false,
3605 }
3606}
3607
3608fn workspace_declaration_evidence(root: &Path) -> Result<Vec<SourceEvidence>> {
3609 ["pnpm-workspace.yaml", "package.json", "Cargo.toml"]
3610 .into_iter()
3611 .map(PathBuf::from)
3612 .filter(|path| root.join(path).is_file())
3613 .map(|path| evidence(root, &path, Vec::new()))
3614 .collect()
3615}
3616
3617fn non_displaced_manifest_references(
3618 root: &Path,
3619 candidate_path: &Path,
3620 native_identity: &str,
3621) -> Result<Vec<SourceEvidence>> {
3622 let path_text = candidate_path.to_string_lossy().replace('\\', "/");
3623 let mut findings = Vec::new();
3624 let mut paths = WalkDir::new(root)
3625 .into_iter()
3626 .filter_entry(|entry| !hard_excluded(root, entry.path()))
3627 .filter_map(std::result::Result::ok)
3628 .filter(|entry| entry.file_type().is_file())
3629 .map(|entry| entry.into_path())
3630 .collect::<BTreeSet<_>>();
3631 remove_git_ignored(root, &mut paths)?;
3632 for path in paths {
3633 let relative = path.strip_prefix(root).unwrap_or(&path);
3634 if relative == candidate_path
3635 || relative.starts_with(".changeset")
3636 || relative.starts_with(".intentional")
3637 || is_prose_path(relative)
3638 {
3639 continue;
3640 }
3641 let Ok(text) = std::fs::read_to_string(&path) else {
3642 continue;
3643 };
3644 let lines = text
3645 .lines()
3646 .enumerate()
3647 .filter(|(_, line)| structural_line_references_path(line, &path_text))
3648 .map(|(index, _)| index + 1)
3649 .collect::<Vec<_>>();
3650 let has_dependency_reference = relative.file_name().and_then(|name| name.to_str())
3651 == Some("package.json")
3652 && serde_json::from_str::<JsonValue>(&text).is_ok_and(|value| {
3653 [
3654 "dependencies",
3655 "devDependencies",
3656 "optionalDependencies",
3657 "peerDependencies",
3658 ]
3659 .iter()
3660 .any(|key| {
3661 value[*key]
3662 .as_object()
3663 .is_some_and(|dependencies| dependencies.contains_key(native_identity))
3664 })
3665 });
3666 if !lines.is_empty() || has_dependency_reference {
3667 findings.push(evidence(root, relative, lines)?);
3668 }
3669 }
3670 findings.sort();
3671 findings.dedup();
3672 Ok(findings)
3673}
3674
3675fn structural_line_references_path(line: &str, path: &str) -> bool {
3676 let normalized = line.replace('\\', "/");
3677 let Some(reference_offset) = normalized.find(path) else {
3678 return false;
3679 };
3680 let trimmed = normalized.trim_start();
3681 if trimmed.starts_with("//")
3682 || trimmed.starts_with('#')
3683 || trimmed.starts_with("/*")
3684 || trimmed.starts_with("<!--")
3685 || trimmed == "*"
3686 || trimmed.starts_with("* ")
3687 {
3688 return false;
3689 }
3690 let before_reference = &normalized[..reference_offset];
3691 if comment_starts_before_reference(before_reference) {
3692 return false;
3693 }
3694 let Some((field, _)) = trimmed.split_once(':') else {
3695 return true;
3696 };
3697 let field = field
3698 .trim()
3699 .trim_matches(|character| matches!(character, '"' | '\''))
3700 .to_ascii_lowercase();
3701 !matches!(
3702 field.as_str(),
3703 "$comment"
3704 | "comment"
3705 | "comments"
3706 | "description"
3707 | "message"
3708 | "note"
3709 | "notes"
3710 | "summary"
3711 | "title"
3712 )
3713}
3714
3715fn comment_starts_before_reference(line: &str) -> bool {
3716 let bytes = line.as_bytes();
3717 let mut quote = None;
3718 let mut escaped = false;
3719 let mut index = 0;
3720 while index < bytes.len() {
3721 let byte = bytes[index];
3722 if let Some(expected) = quote {
3723 if escaped {
3724 escaped = false;
3725 } else if byte == b'\\' {
3726 escaped = true;
3727 } else if byte == expected {
3728 quote = None;
3729 }
3730 index += 1;
3731 continue;
3732 }
3733 if matches!(byte, b'\'' | b'"') {
3734 quote = Some(byte);
3735 index += 1;
3736 continue;
3737 }
3738 let previous_is_boundary =
3739 index == 0 || bytes[index.saturating_sub(1)].is_ascii_whitespace();
3740 if byte == b'#' && previous_is_boundary {
3741 return true;
3742 }
3743 if bytes[index..].starts_with(b"<!--") {
3744 return true;
3745 }
3746 if bytes[index..].starts_with(b"/*")
3747 && bytes
3748 .get(index + 2)
3749 .is_none_or(|next| next.is_ascii_whitespace())
3750 {
3751 return true;
3752 }
3753 if bytes[index..].starts_with(b"//")
3754 && bytes
3755 .get(index + 2)
3756 .is_none_or(|next| next.is_ascii_whitespace())
3757 {
3758 return true;
3759 }
3760 index += 1;
3761 }
3762 false
3763}
3764
3765fn is_prose_path(path: &Path) -> bool {
3766 matches!(
3767 path.extension().and_then(|extension| extension.to_str()),
3768 Some("md" | "mdx" | "rst" | "txt")
3769 )
3770}
3771
3772fn reconcile_diagnostics(
3773 root: &Path,
3774 current: &mut Vec<InitDiagnostic>,
3775 previous: Option<&InitPlan>,
3776 candidates: &[DiscoveryCandidate],
3777) -> Result<()> {
3778 let Some(previous) = previous else {
3779 return Ok(());
3780 };
3781 let previous_by_id = previous
3782 .diagnostics
3783 .iter()
3784 .map(|diagnostic| (diagnostic.id.as_str(), diagnostic))
3785 .collect::<BTreeMap<_, _>>();
3786 for diagnostic in current.iter_mut() {
3787 let Some(old) = previous_by_id.get(diagnostic.id.as_str()) else {
3788 continue;
3789 };
3790 if old.evidence == diagnostic.evidence {
3791 diagnostic.resolution = old.resolution.clone();
3792 if !diagnostic.choices.is_empty() {
3793 diagnostic.verified = match diagnostic.code.as_str() {
3794 "repository-integration" => false,
3795 "release-tool-proxy-disposition" => {
3796 let candidate = candidates.iter().find(|candidate| {
3797 diagnostic.id
3798 == format!("release-tool-proxy-disposition:{}", candidate.id)
3799 });
3800 match (diagnostic.resolution.as_deref(), candidate) {
3801 (Some("retain"), Some(_)) => true,
3802 (Some("remove"), Some(candidate)) => non_displaced_manifest_references(
3803 root,
3804 &candidate.path,
3805 candidate.native_identity.as_deref().unwrap_or_default(),
3806 )?
3807 .is_empty(),
3808 _ => false,
3809 }
3810 }
3811 _ => diagnostic.resolution.is_some(),
3812 };
3813 }
3814 } else if old.resolution.is_some() {
3815 diagnostic.invalidated_resolution = true;
3816 }
3817 }
3818 let current_ids = current
3819 .iter()
3820 .map(|diagnostic| diagnostic.id.clone())
3821 .collect::<BTreeSet<_>>();
3822 for old in &previous.diagnostics {
3823 if old.code != "repository-integration"
3824 || current_ids.contains(&old.id)
3825 || old.resolution.as_deref() != Some("removed")
3826 {
3827 continue;
3828 }
3829 let Some(path) = old.evidence.first().map(|evidence| evidence.path.clone()) else {
3830 continue;
3831 };
3832 let full = root.join(&path);
3833 if !full.is_file() {
3834 continue;
3835 }
3836 let text = std::fs::read_to_string(&full).map_err(|error| Error::io(&full, error))?;
3837 if text.to_ascii_lowercase().contains("changeset") {
3838 continue;
3839 }
3840 current.push(InitDiagnostic {
3841 id: old.id.clone(),
3842 code: old.code.clone(),
3843 message: old.message.clone(),
3844 evidence: vec![evidence(root, &path, Vec::new())?],
3845 choices: old.choices.clone(),
3846 recommended: old.recommended.clone(),
3847 resolution: old.resolution.clone(),
3848 verified: true,
3849 invalidated_resolution: false,
3850 supporting_evidence: old.supporting_evidence.clone(),
3851 contradictory_evidence: old.contradictory_evidence.clone(),
3852 uncertainty: old.uncertainty.clone(),
3853 });
3854 }
3855 current.sort_by(|left, right| left.id.cmp(&right.id));
3856 Ok(())
3857}
3858
3859fn apply_disposition_resolutions(
3860 config: &mut Config,
3861 diagnostics: &[InitDiagnostic],
3862) -> Result<()> {
3863 let mut excluded = BTreeSet::new();
3864 for diagnostic in diagnostics.iter().filter(|diagnostic| {
3865 diagnostic.code == "ignored-release-unit-disposition"
3866 || diagnostic.code == "unmapped-release-unit-disposition"
3867 }) {
3868 let release_unit = diagnostic
3869 .id
3870 .split_once(':')
3871 .map(|(_, release_unit)| release_unit)
3872 .expect("diagnostic id prefix");
3873 match diagnostic.resolution.as_deref() {
3874 Some("suspended") => {
3875 if let Some(config) = config.release_units.get_mut(release_unit) {
3876 config.disposition = ReleaseUnitDisposition::Suspended;
3877 }
3878 }
3879 Some("excluded") => {
3880 config.release_units.remove(release_unit);
3881 excluded.insert(release_unit.to_owned());
3882 }
3883 Some("managed") | None => {}
3884 Some(value) => {
3885 return Err(Error::Validation(format!(
3886 "invalid resolution {value} for {}",
3887 diagnostic.id
3888 )))
3889 }
3890 }
3891 }
3892 exclude_release_units(config, &excluded);
3893 Ok(())
3894}
3895
3896fn apply_proxy_manifest_resolutions(
3897 config: &mut Config,
3898 diagnostics: &[InitDiagnostic],
3899 candidates: &[DiscoveryCandidate],
3900) -> Result<Vec<ProxyRemoval>> {
3901 let mut removals = Vec::new();
3902 for diagnostic in diagnostics
3903 .iter()
3904 .filter(|diagnostic| diagnostic.code == "release-tool-proxy-disposition")
3905 {
3906 let candidate = candidates
3907 .iter()
3908 .find(|candidate| {
3909 diagnostic.id == format!("release-tool-proxy-disposition:{}", candidate.id)
3910 })
3911 .ok_or_else(|| {
3912 Error::Validation(format!(
3913 "{} does not identify a current discovery candidate",
3914 diagnostic.id
3915 ))
3916 })?;
3917 match diagnostic.resolution.as_deref() {
3918 None | Some("retain") => {}
3919 Some("remove") => {
3920 let Some(CandidateResolution::Projection { release_unit, .. }) =
3921 candidate.resolution.as_ref()
3922 else {
3923 return Err(Error::Validation(format!(
3924 "{} can remove only a candidate resolved as a projection",
3925 diagnostic.id
3926 )));
3927 };
3928 let unit = config.release_units.get_mut(release_unit).ok_or_else(|| {
3929 Error::Validation(format!(
3930 "{} targets absent release unit {release_unit}",
3931 diagnostic.id
3932 ))
3933 })?;
3934 let unit_path = unit.path.clone();
3935 unit.projections.retain(|projection| {
3936 projection.file.workspace_path(&unit_path) != candidate.path
3937 });
3938 config.discovery.managed_paths.retain(|receipt| {
3939 receipt.detector != candidate.detector || receipt.path != candidate.path
3940 });
3941 removals.push(ProxyRemoval {
3942 path: candidate.path.clone(),
3943 native_identity: candidate
3944 .native_identity
3945 .clone()
3946 .expect("proxy diagnostic requires native identity"),
3947 });
3948 }
3949 Some(value) => {
3950 return Err(Error::Validation(format!(
3951 "invalid resolution {value} for {}",
3952 diagnostic.id
3953 )));
3954 }
3955 }
3956 }
3957 removals.sort_by(|left, right| left.path.cmp(&right.path));
3958 Ok(removals)
3959}
3960
3961fn exclude_release_units(config: &mut Config, excluded: &BTreeSet<String>) {
3962 for id in excluded {
3963 config.release_units.remove(id);
3964 }
3965 if excluded.is_empty() {
3966 return;
3967 }
3968 for release_unit in config.release_units.values_mut() {
3969 release_unit.depends_on.retain(|id| !excluded.contains(id));
3970 }
3971 for groups in [&mut config.fixed, &mut config.linked] {
3972 for group in groups.iter_mut() {
3973 group.retain(|id| !excluded.contains(id));
3974 }
3975 groups.retain(|group| group.len() >= 2);
3976 }
3977}
3978
3979fn parity_result(
3987 source_config: &Config,
3988 proposed_config: &Config,
3989 current: &BTreeMap<String, Version>,
3990 source_declared: &BTreeMap<String, Bump>,
3991 chosen_intents: &[ConvertedIntent],
3992 translation: BumpTranslation,
3993 source_semantics: &ChangesetsSourceSemantics<'_>,
3994) -> Result<ParityComputation> {
3995 let declared = &aggregate_converted(chosen_intents);
3996 let graduating = aggregate_converted_graduations(chosen_intents);
3997 let mut source_config = source_config.clone();
3998 let mut proposed_config = proposed_config.clone();
3999 let effective = effective_bumps(&proposed_config, declared);
4000 let non_releasing_without_versions = proposed_config
4001 .release_units
4002 .keys()
4003 .filter(|id| !current.contains_key(*id) && effective[*id] == Bump::None)
4004 .cloned()
4005 .collect::<BTreeSet<_>>();
4006 exclude_release_units(&mut source_config, &non_releasing_without_versions);
4007 exclude_release_units(&mut proposed_config, &non_releasing_without_versions);
4008 let excluded_pending = declared
4009 .keys()
4010 .filter(|id| !proposed_config.release_units.contains_key(*id))
4011 .cloned()
4012 .collect::<Vec<_>>();
4013 let proposed_preflight_error = (!excluded_pending.is_empty()).then(|| {
4014 format!(
4015 "converted intents reference release units excluded from the proposed inventory: {}",
4016 excluded_pending.join(", ")
4017 )
4018 });
4019 if source_semantics.preflight_error.is_some() || proposed_preflight_error.is_some() {
4020 return Ok(ParityComputation {
4021 result: ParityResult {
4022 status: "blocked".to_owned(),
4023 release_units: Vec::new(),
4024 },
4025 source_error: source_semantics.preflight_error.clone(),
4026 proposed_error: proposed_preflight_error,
4027 });
4028 }
4029 let missing = source_config
4030 .release_units
4031 .keys()
4032 .chain(proposed_config.release_units.keys())
4033 .filter(|id| !current.contains_key(*id))
4034 .cloned()
4035 .collect::<BTreeSet<_>>();
4036 if !missing.is_empty() {
4037 return Ok(ParityComputation {
4038 result: ParityResult {
4039 status: "blocked".to_owned(),
4040 release_units: Vec::new(),
4041 },
4042 source_error: None,
4043 proposed_error: Some(format!(
4044 "missing current versions for release units: {}",
4045 missing.into_iter().collect::<Vec<_>>().join(", ")
4046 )),
4047 });
4048 }
4049 let (source, source_error) = match resolve_changesets_source(
4050 &source_config,
4051 source_declared,
4052 current,
4053 source_semantics,
4054 component_bump,
4055 ) {
4056 Ok(resolved) => (resolved, None),
4057 Err(Error::Validation(message)) => (BTreeMap::new(), Some(message)),
4058 Err(error) => return Err(error),
4059 };
4060 let (proposed, proposed_error) =
4061 match resolve_versions(&proposed_config, declared, &graduating, current) {
4062 Ok(resolved) => (resolved, None),
4063 Err(Error::Validation(message)) => (BTreeMap::new(), Some(message)),
4064 Err(error) => return Err(error),
4065 };
4066 let reference = match translation {
4068 BumpTranslation::Parity => source.clone(),
4069 BumpTranslation::Significance => resolve_changesets_source(
4070 &source_config,
4071 source_declared,
4072 current,
4073 source_semantics,
4074 bump_version,
4075 )
4076 .unwrap_or_default(),
4077 };
4078 let release_ids = source
4079 .iter()
4080 .chain(&proposed)
4081 .filter(|(_, versions)| versions.bump != Bump::None)
4082 .map(|(id, _)| id.clone())
4083 .collect::<BTreeSet<_>>();
4084 let release_units = release_ids
4085 .into_iter()
4086 .map(|release_unit| {
4087 let source = parity_release(source.get(&release_unit));
4088 let proposed = parity_release(proposed.get(&release_unit));
4089 let reference = parity_release(reference.get(&release_unit));
4090 ParityReleaseUnit {
4091 current_version: current[&release_unit].to_string(),
4092 expected_difference: !same_release(&source, &proposed)
4093 && same_release(&reference, &proposed),
4094 source,
4095 proposed,
4096 release_unit,
4097 }
4098 })
4099 .collect::<Vec<_>>();
4100 let consistent = source_error.is_none()
4101 && proposed_error.is_none()
4102 && release_units.iter().all(|release_unit| {
4103 release_unit.expected_difference
4104 || same_release(&release_unit.source, &release_unit.proposed)
4105 });
4106 let status = if !consistent {
4107 "blocked"
4108 } else if release_units
4109 .iter()
4110 .any(|release_unit| release_unit.expected_difference)
4111 {
4112 "expected-difference"
4113 } else {
4114 "equivalent"
4115 };
4116 Ok(ParityComputation {
4117 result: ParityResult {
4118 status: status.to_owned(),
4119 release_units,
4120 },
4121 source_error,
4122 proposed_error,
4123 })
4124}
4125
4126fn mixed_skipped_changeset(
4127 intents: &[ConvertedIntent],
4128 skipped_release_units: &BTreeSet<String>,
4129) -> Option<String> {
4130 intents.iter().find_map(|intent| {
4131 let has_skipped = intent
4132 .release_units
4133 .keys()
4134 .any(|id| skipped_release_units.contains(id));
4135 let has_managed = intent
4136 .release_units
4137 .keys()
4138 .any(|id| !skipped_release_units.contains(id));
4139 (has_skipped && has_managed).then(|| {
4140 format!(
4141 "Changesets intent {} mixes skipped and managed release units; split or revise the source changeset before takeover",
4142 intent.id
4143 )
4144 })
4145 })
4146}
4147
4148struct ParityComputation {
4149 result: ParityResult,
4150 source_error: Option<String>,
4151 proposed_error: Option<String>,
4152}
4153
4154fn parity_release(version: Option<&ReleaseUnitVersion>) -> Option<ParityRelease> {
4155 version
4156 .filter(|version| version.bump != Bump::None)
4157 .map(|version| ParityRelease {
4158 requested_bump: version.bump,
4159 next_version: version.next.to_string(),
4160 })
4161}
4162
4163fn same_release(left: &Option<ParityRelease>, right: &Option<ParityRelease>) -> bool {
4166 left.as_ref().map(|release| &release.next_version)
4167 == right.as_ref().map(|release| &release.next_version)
4168}
4169
4170fn aggregate_converted(intents: &[ConvertedIntent]) -> BTreeMap<String, Bump> {
4171 crate::version::aggregate_bumps(intents.iter().map(|intent| &intent.release_units))
4172}
4173
4174fn aggregate_converted_graduations(intents: &[ConvertedIntent]) -> BTreeSet<String> {
4175 crate::version::aggregate_graduations(intents.iter().map(|intent| &intent.graduates))
4176}
4177
4178fn component_bases(
4182 config: &Config,
4183 current: &BTreeMap<String, Version>,
4184) -> BTreeMap<String, Version> {
4185 let mut bases = current.clone();
4186 for group in config.fixed.iter().chain(&config.linked) {
4187 let Some(highest) = group.iter().filter_map(|id| current.get(id)).max().cloned() else {
4188 continue;
4189 };
4190 for id in group {
4191 if current.contains_key(id) {
4192 bases.insert(id.clone(), highest.clone());
4193 }
4194 }
4195 }
4196 bases
4197}
4198
4199fn translate_converted_intents(
4207 intents: &[ConvertedIntent],
4208 bases: &BTreeMap<String, Version>,
4209 translation: BumpTranslation,
4210 source_name: &str,
4211) -> Vec<ConvertedIntent> {
4212 intents
4213 .iter()
4214 .map(|intent| {
4215 let mut translated = intent.clone();
4216 if translation == BumpTranslation::Significance {
4217 return translated;
4218 }
4219 let mut notes = Vec::new();
4220 for (id, bump) in &intent.release_units {
4221 let Some(base) = bases.get(id).filter(|base| base.major == 0) else {
4222 continue;
4223 };
4224 let (renamed, graduates) = match bump {
4225 Bump::Major => (Bump::Major, true),
4226 Bump::Minor => (Bump::Major, false),
4227 Bump::Patch | Bump::None => continue,
4228 };
4229 translated.release_units.insert(id.clone(), renamed);
4230 if graduates {
4231 translated.graduates.insert(id.clone());
4232 }
4233 let target = if graduates {
4234 "`major` with `graduate`".to_owned()
4235 } else {
4236 format!("`{renamed}`")
4237 };
4238 notes.push(format!(
4239 "{source_name} `{bump}` for `{id}` at {base} is {target} here, so that the next version stays {}.",
4240 component_bump(base, *bump)
4241 ));
4242 }
4243 if !notes.is_empty() {
4244 translated.message = format!(
4245 "{}\n\n<!-- bump translation: {} -->",
4246 intent.message.trim(),
4247 notes.join(" ")
4248 );
4249 }
4250 translated
4251 })
4252 .collect()
4253}
4254
4255fn resolve_changesets_source(
4261 config: &Config,
4262 declared: &BTreeMap<String, Bump>,
4263 current: &BTreeMap<String, Version>,
4264 semantics: &ChangesetsSourceSemantics<'_>,
4265 bump: fn(&Version, Bump) -> Version,
4266) -> Result<BTreeMap<String, ReleaseUnitVersion>> {
4267 let mut effective = config
4268 .release_units
4269 .keys()
4270 .map(|id| {
4271 let suppressed =
4272 semantics.suppress_private_versions && semantics.private_packages.contains(id);
4273 (
4274 id.clone(),
4275 if suppressed {
4276 Bump::None
4277 } else {
4278 declared.get(id).copied().unwrap_or(Bump::None)
4279 },
4280 )
4281 })
4282 .collect::<BTreeMap<_, _>>();
4283 loop {
4284 let before = effective.clone();
4285 for edge in semantics.npm_dependencies {
4286 if !config.release_units.contains_key(&edge.dependent)
4287 || !config.release_units.contains_key(&edge.dependency)
4288 || (semantics.suppress_private_versions
4289 && semantics.private_packages.contains(&edge.dependent))
4290 {
4291 continue;
4292 }
4293 let dependency_bump = effective[&edge.dependency];
4294 if dependency_bump == Bump::None {
4295 continue;
4296 }
4297 let next =
4298 changesets_dependency_next(config, &edge.dependency, &effective, current, bump);
4299 let in_range = npm_range_satisfies(&edge.range, ¤t[&edge.dependency], &next)?;
4300 let required = if edge.kind == NpmDependencyKind::Peer
4301 && dependency_bump >= Bump::Minor
4302 && (!semantics.only_update_peer_dependents_when_out_of_range || !in_range)
4303 {
4304 Bump::Major
4305 } else if semantics.update_internal_dependents_always || !in_range {
4306 semantics.internal_dependency_bump
4307 } else {
4308 Bump::None
4309 };
4310 if required != Bump::None {
4311 effective
4312 .entry(edge.dependent.clone())
4313 .and_modify(|bump| *bump = (*bump).max(required));
4314 }
4315 }
4316 for group in &config.fixed {
4317 let bump = group
4318 .iter()
4319 .map(|id| effective[id])
4320 .max()
4321 .unwrap_or_default();
4322 if bump != Bump::None {
4323 for id in group {
4324 if !semantics.suppress_private_versions
4325 || !semantics.private_packages.contains(id)
4326 {
4327 effective.insert(id.clone(), bump);
4328 }
4329 }
4330 }
4331 }
4332 for group in &config.linked {
4333 let bump = group
4334 .iter()
4335 .map(|id| effective[id])
4336 .max()
4337 .unwrap_or_default();
4338 if bump != Bump::None {
4339 for id in group {
4340 if effective[id] != Bump::None {
4341 effective.insert(id.clone(), bump);
4342 }
4343 }
4344 }
4345 }
4346 if effective == before {
4347 break;
4348 }
4349 }
4350
4351 let mut resolved = config
4352 .release_units
4353 .keys()
4354 .map(|id| {
4355 let current_version = current.get(id).ok_or_else(|| {
4356 Error::Validation(format!("missing current version for release unit {id}"))
4357 })?;
4358 Ok((
4359 id.clone(),
4360 ReleaseUnitVersion {
4361 current: current_version.clone(),
4362 next: bump(current_version, effective[id]),
4363 bump: effective[id],
4364 graduates: false,
4365 },
4366 ))
4367 })
4368 .collect::<Result<BTreeMap<_, _>>>()?;
4369 for (fixed, groups) in [(true, &config.fixed), (false, &config.linked)] {
4370 for group in groups {
4371 let highest_current = group
4372 .iter()
4373 .map(|id| ¤t[id])
4374 .max()
4375 .expect("validated release group")
4376 .clone();
4377 let highest_bump = group
4378 .iter()
4379 .map(|id| effective[id])
4380 .max()
4381 .unwrap_or_default();
4382 let next = bump(&highest_current, highest_bump);
4383 for id in group {
4384 let suppressed =
4385 semantics.suppress_private_versions && semantics.private_packages.contains(id);
4386 let releases = !suppressed
4387 && highest_bump != Bump::None
4388 && (fixed || effective[id] != Bump::None);
4389 resolved.insert(
4390 id.clone(),
4391 ReleaseUnitVersion {
4392 current: current[id].clone(),
4393 next: if releases {
4394 next.clone()
4395 } else {
4396 current[id].clone()
4397 },
4398 bump: if releases { highest_bump } else { Bump::None },
4399 graduates: false,
4400 },
4401 );
4402 }
4403 }
4404 }
4405 Ok(resolved)
4406}
4407
4408fn changesets_dependency_next(
4409 config: &Config,
4410 id: &str,
4411 effective: &BTreeMap<String, Bump>,
4412 current: &BTreeMap<String, Version>,
4413 bump: fn(&Version, Bump) -> Version,
4414) -> Version {
4415 for group in config.fixed.iter().chain(&config.linked) {
4416 if group.iter().any(|member| member == id) && effective[id] != Bump::None {
4417 let highest_current = group
4418 .iter()
4419 .map(|member| ¤t[member])
4420 .max()
4421 .expect("validated group");
4422 let highest_bump = group
4423 .iter()
4424 .map(|member| effective[member])
4425 .max()
4426 .unwrap_or_default();
4427 return bump(highest_current, highest_bump);
4428 }
4429 }
4430 bump(¤t[id], effective[id])
4431}
4432
4433fn npm_range_satisfies(range: &str, current: &Version, next: &Version) -> Result<bool> {
4434 let normalized = match range.strip_prefix("workspace:") {
4435 Some("*") => current.to_string(),
4436 Some("^") => format!("^{current}"),
4437 Some("~") => format!("~{current}"),
4438 Some(range) => range.to_owned(),
4439 None => range.to_owned(),
4440 };
4441 let Ok(range) = NodeRange::parse(&normalized) else {
4442 return Ok(false);
4443 };
4444 let next = NodeVersion::parse(next.to_string())
4445 .map_err(|error| Error::Validation(format!("invalid next npm version: {error}")))?;
4446 Ok(range.satisfies(&next))
4447}
4448
4449fn load_previous_plan(root: &Path) -> Result<Option<InitPlan>> {
4450 let path = root.join(INIT_PLAN_PATH);
4451 match std::fs::read_to_string(&path) {
4452 Ok(text) => {
4453 let mut value: serde_yaml::Value = serde_yaml::from_str(&text)?;
4454 if let Some(inferred) = value.get_mut("inferred-config") {
4457 if inferred.get("contract").and_then(serde_yaml::Value::as_str)
4458 == Some(crate::config::CONTRACT_1)
4459 {
4460 upgrade_contract_1_value(inferred)?;
4461 }
4462 }
4463 let plan: InitPlan = serde_yaml::from_value(value)?;
4464 plan.validate()?;
4465 Ok(Some(plan))
4466 }
4467 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
4468 Err(error) => Err(Error::io(path, error)),
4469 }
4470}
4471
4472fn evidence(root: &Path, relative: &Path, lines: Vec<usize>) -> Result<SourceEvidence> {
4473 let path = root.join(relative);
4474 let bytes = std::fs::read(&path).map_err(|error| Error::io(&path, error))?;
4475 Ok(SourceEvidence {
4476 path: relative.to_owned(),
4477 digest: sha256_digest(&bytes),
4478 lines,
4479 })
4480}
4481
4482fn fingerprint(root: &Path, paths: &BTreeSet<PathBuf>) -> Result<String> {
4483 let mut source = BTreeMap::new();
4484 for path in paths {
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 path in ["package.json", "pnpm-workspace.yaml", "Cargo.toml"] {
4493 let path = PathBuf::from(path);
4494 if root.join(&path).is_file() {
4495 source.insert(
4496 path.to_string_lossy().to_string(),
4497 evidence(root, &path, Vec::new())?.digest,
4498 );
4499 }
4500 }
4501 for entry in WalkDir::new(root.join(".changeset"))
4502 .into_iter()
4503 .filter_map(std::result::Result::ok)
4504 .filter(|entry| entry.file_type().is_file())
4505 {
4506 let path = entry
4507 .path()
4508 .strip_prefix(root)
4509 .unwrap_or(entry.path())
4510 .to_owned();
4511 source.insert(
4512 path.to_string_lossy().to_string(),
4513 evidence(root, &path, Vec::new())?.digest,
4514 );
4515 }
4516 Ok(sha256_digest(canonical_json(&source)?.as_bytes()))
4517}
4518
4519fn verify_takeover_preconditions(
4520 root: &Path,
4521 writes: &[(PathBuf, String)],
4522 expected_fingerprint: &str,
4523 evidence_paths: &BTreeSet<PathBuf>,
4524) -> Result<()> {
4525 let actual_fingerprint = fingerprint(root, evidence_paths)?;
4526 if actual_fingerprint != expected_fingerprint {
4527 return Err(Error::Validation(
4528 "initialization plan source evidence became stale; rerun intentional init".to_owned(),
4529 ));
4530 }
4531 for (relative, _) in writes {
4532 if root.join(relative).exists() {
4533 return Err(Error::Validation(format!(
4534 "takeover target {} already exists; resolve the competing Intentional state first",
4535 relative.display()
4536 )));
4537 }
4538 }
4539 Ok(())
4540}
4541
4542fn takeover_operations(
4543 root: &Path,
4544 intents: &[ConvertedIntent],
4545 proxy_removals: &[ProxyRemoval],
4546) -> Vec<String> {
4547 let mut operations = vec![format!("write {CONFIG_PATH}")];
4548 operations.extend(intents.iter().map(|intent| {
4549 format!(
4550 "write {} from {}",
4551 intent.target.display(),
4552 intent.source.display()
4553 )
4554 }));
4555 operations.extend(
4556 takeover_deletes(root, proxy_removals)
4557 .iter()
4558 .map(|path| format!("delete {}", path.display())),
4559 );
4560 operations.push("after commit: intentional tag --baseline".to_owned());
4561 operations
4562}
4563
4564fn takeover_deletes(root: &Path, proxy_removals: &[ProxyRemoval]) -> Vec<PathBuf> {
4565 let mut paths = WalkDir::new(root.join(".changeset"))
4566 .into_iter()
4567 .filter_map(std::result::Result::ok)
4568 .filter(|entry| entry.file_type().is_file())
4569 .map(|entry| {
4570 entry
4571 .path()
4572 .strip_prefix(root)
4573 .unwrap_or(entry.path())
4574 .to_owned()
4575 })
4576 .collect::<Vec<_>>();
4577 paths.push(PathBuf::from(INIT_PLAN_PATH));
4578 paths.extend(proxy_removals.iter().map(|removal| removal.path.clone()));
4579 paths.sort();
4580 paths.dedup();
4581 paths
4582}
4583
4584fn verify_proxy_removal_preconditions(root: &Path, removals: &[ProxyRemoval]) -> Result<()> {
4585 for removal in removals {
4586 let references =
4587 non_displaced_manifest_references(root, &removal.path, &removal.native_identity)?;
4588 if !references.is_empty() {
4589 return Err(Error::Validation(format!(
4590 "cannot remove probable release-tool proxy {} while non-source-tool repository references remain in {}",
4591 removal.path.display(),
4592 references
4593 .iter()
4594 .map(|item| item.path.display().to_string())
4595 .collect::<Vec<_>>()
4596 .join(", ")
4597 )));
4598 }
4599 }
4600 Ok(())
4601}
4602
4603fn apply_takeover_transaction(
4604 root: &Path,
4605 writes: &[(PathBuf, String)],
4606 deletes: &[PathBuf],
4607) -> Result<()> {
4608 let transaction = root.join(TRANSACTION_PATH);
4609 if transaction.exists() {
4610 recover_interrupted_takeover(root)?;
4611 }
4612 let setup = (|| {
4613 std::fs::create_dir_all(transaction.join("original"))
4614 .map_err(|error| Error::io(&transaction, error))?;
4615 let affected = writes
4616 .iter()
4617 .map(|(path, _)| path.clone())
4618 .chain(deletes.iter().cloned())
4619 .collect::<BTreeSet<_>>();
4620 for relative in &affected {
4621 let source = root.join(relative);
4622 if source.is_file() {
4623 let backup = transaction.join("original").join(relative);
4624 if let Some(parent) = backup.parent() {
4625 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4626 }
4627 std::fs::copy(&source, &backup).map_err(|error| Error::io(&source, error))?;
4628 }
4629 }
4630 let manifest = serde_yaml::to_string(
4631 &affected
4632 .iter()
4633 .map(|path| path.to_string_lossy().to_string())
4634 .collect::<Vec<_>>(),
4635 )?;
4636 let manifest_path = transaction.join("manifest.yml");
4637 let manifest_temporary = transaction.join("manifest-tmp.yml");
4638 std::fs::write(&manifest_temporary, manifest)
4639 .map_err(|error| Error::io(&manifest_temporary, error))?;
4640 std::fs::rename(&manifest_temporary, &manifest_path)
4641 .map_err(|error| Error::io(&manifest_path, error))?;
4642 Ok::<(), Error>(())
4643 })();
4644 match setup {
4645 Ok(()) => {}
4646 Err(error) => {
4647 if transaction.exists() {
4648 std::fs::remove_dir_all(&transaction)
4649 .map_err(|cleanup| Error::io(&transaction, cleanup))?;
4650 }
4651 return Err(error);
4652 }
4653 }
4654 let result = (|| {
4655 for (relative, contents) in writes {
4656 let path = root.join(relative);
4657 if let Some(parent) = path.parent() {
4658 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4659 }
4660 let temporary = transaction.join("staged").join(relative);
4661 if let Some(parent) = temporary.parent() {
4662 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4663 }
4664 std::fs::write(&temporary, contents).map_err(|error| Error::io(&temporary, error))?;
4665 std::fs::rename(&temporary, &path).map_err(|error| Error::io(&path, error))?;
4666 }
4667 for relative in deletes {
4668 let path = root.join(relative);
4669 if path.is_file() {
4670 std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
4671 }
4672 }
4673 let changeset_directory = root.join(".changeset");
4674 if changeset_directory.is_dir()
4675 && std::fs::read_dir(&changeset_directory)
4676 .map_err(|error| Error::io(&changeset_directory, error))?
4677 .next()
4678 .is_none()
4679 {
4680 std::fs::remove_dir(&changeset_directory)
4681 .map_err(|error| Error::io(&changeset_directory, error))?;
4682 }
4683 Ok(())
4684 })();
4685 if result.is_err() {
4686 recover_interrupted_takeover(root)?;
4687 return result;
4688 }
4689 if let Err(error) = write_transaction_state(root, "committed") {
4690 recover_interrupted_takeover(root)?;
4691 return Err(error);
4692 }
4693 finish_transaction_cleanup(root)
4694}
4695
4696fn recover_interrupted_takeover(root: &Path) -> Result<()> {
4697 let transaction = root.join(TRANSACTION_PATH);
4698 let state_path = root.join(TRANSACTION_STATE_PATH);
4699 if state_path.is_file() {
4700 return finish_transaction_cleanup(root);
4701 }
4702 if !transaction.exists() {
4703 return Ok(());
4704 }
4705 let manifest_path = transaction.join("manifest.yml");
4706 if !manifest_path.is_file() {
4707 return std::fs::remove_dir_all(&transaction)
4708 .map_err(|error| Error::io(&transaction, error));
4709 }
4710 let manifest = std::fs::read_to_string(&manifest_path)
4711 .map_err(|error| Error::io(&manifest_path, error))?;
4712 let affected: Vec<String> = serde_yaml::from_str(&manifest)?;
4713 for relative in affected {
4714 let relative = PathBuf::from(relative);
4715 let target = root.join(&relative);
4716 let backup = transaction.join("original").join(&relative);
4717 if backup.is_file() {
4718 if let Some(parent) = target.parent() {
4719 std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4720 }
4721 std::fs::copy(&backup, &target).map_err(|error| Error::io(&target, error))?;
4722 } else if target.is_file() {
4723 std::fs::remove_file(&target).map_err(|error| Error::io(&target, error))?;
4724 }
4725 }
4726 write_transaction_state(root, "rolled-back")?;
4727 finish_transaction_cleanup(root)
4728}
4729
4730fn write_transaction_state(root: &Path, state: &str) -> Result<()> {
4731 let path = root.join(TRANSACTION_STATE_PATH);
4732 let temporary = root.join(".intentional/.takeover-state-tmp");
4733 std::fs::write(&temporary, state).map_err(|error| Error::io(&temporary, error))?;
4734 std::fs::rename(&temporary, &path).map_err(|error| Error::io(&path, error))
4735}
4736
4737fn finish_transaction_cleanup(root: &Path) -> Result<()> {
4738 let transaction = root.join(TRANSACTION_PATH);
4739 if transaction.exists() {
4740 std::fs::remove_dir_all(&transaction).map_err(|error| Error::io(&transaction, error))?;
4741 }
4742 for path in [
4743 root.join(TRANSACTION_STATE_PATH),
4744 root.join(".intentional/.takeover-state-tmp"),
4745 ] {
4746 if path.is_file() {
4747 std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
4748 }
4749 }
4750 Ok(())
4751}
4752
4753fn annotate_choice_lines(yaml: &str) -> String {
4754 let comments = BTreeMap::from([
4755 ("suspended", "configured, but releases are blocked"),
4756 ("excluded", "outside Intentional's release-unit inventory"),
4757 ("managed", "configured and eligible for release"),
4758 (
4759 "removed",
4760 "reference removed or replaced and verified from the file",
4761 ),
4762 (
4763 "external",
4764 "publication sequencing remains with the external executor",
4765 ),
4766 ("intentional", "Intentional's contract owns this behavior"),
4767 ]);
4768 let mut output = String::new();
4769 let mut choices_indent = None;
4770 for line in yaml.lines() {
4771 let indent = line.len() - line.trim_start().len();
4772 if line.trim() == "choices:" {
4773 choices_indent = Some(indent);
4774 output.push_str(line);
4775 output.push('\n');
4776 continue;
4777 }
4778 if let Some(base) = choices_indent {
4779 if let Some(value) = line.trim().strip_prefix("- ") {
4780 if let Some(comment) = comments.get(value) {
4781 output.push_str(line);
4782 output.push_str(" # ");
4783 output.push_str(comment);
4784 output.push('\n');
4785 continue;
4786 }
4787 } else if indent <= base {
4788 choices_indent = None;
4789 }
4790 }
4791 output.push_str(line);
4792 output.push('\n');
4793 }
4794 output
4795}
4796
4797#[cfg(test)]
4798mod tests {
4799 use super::*;
4800
4801 const DIGEST: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
4802
4803 fn candidate(path: &str, resolution: Option<CandidateResolution>) -> DiscoveryCandidate {
4804 let path = PathBuf::from(path);
4805 let evidence = SourceEvidence {
4806 path: path.clone(),
4807 digest: DIGEST.to_owned(),
4808 lines: vec![1],
4809 };
4810 DiscoveryCandidate {
4811 id: DiscoveryCandidate::stable_id("sample-detector", &path).expect("stable id"),
4812 detector: "sample-detector".to_owned(),
4813 path: path.clone(),
4814 evidence: vec![evidence.clone()],
4815 native_identity: Some("sample-native-id".to_owned()),
4816 raw_version: Some(RawVersionEvidence {
4817 value: "raw-version-text".to_owned(),
4818 evidence: vec![evidence.clone()],
4819 }),
4820 projection: Some(CandidateProjectionSuggestion {
4821 adapter: Adapter::Npm,
4822 path,
4823 mode: ProjectionMode::Committed,
4824 pointer: None,
4825 }),
4826 tag: Some(CandidateTagSuggestion {
4827 id: "primary".to_owned(),
4828 role: TagRole::Primary,
4829 template: "{id}@{version}".to_owned(),
4830 }),
4831 diagnostics: vec![ExtractionDiagnostic {
4832 id: "sample-diagnostic".to_owned(),
4833 code: "sample-extraction".to_owned(),
4834 message: "Sample extraction evidence is incomplete.".to_owned(),
4835 evidence: vec![evidence],
4836 }],
4837 resolution,
4838 }
4839 }
4840
4841 fn candidate_plan(candidates: Vec<DiscoveryCandidate>) -> InitPlan {
4842 let config = Config::from_yaml(
4843 "contract: contract-2\nrelease-units:\n configured:\n path: configured\n tags:\n primary: { role: primary, template: '{id}@{version}' }\n",
4844 )
4845 .expect("candidate test config");
4846 InitPlan {
4847 schema: INIT_PLAN_SCHEMA.to_owned(),
4848 state: InitState::NeedsInput,
4849 source_kind: "changesets".to_owned(),
4850 source_fingerprint: DIGEST.to_owned(),
4851 inferred_config: config,
4852 discovery_candidates: candidates,
4853 diagnostics: Vec::new(),
4854 bump_translation: None,
4855 converted_intents: Vec::new(),
4856 parity: ParityResult {
4857 status: "equivalent".to_owned(),
4858 release_units: Vec::new(),
4859 },
4860 planned_operations: Vec::new(),
4861 post_commit_action: "intentional tag --baseline".to_owned(),
4862 }
4863 }
4864
4865 #[test]
4866 fn candidate_resolution_uses_only_documented_kebab_case_fields() {
4867 let independent: CandidateResolution =
4868 serde_yaml::from_str("kind: independent\nrelease-unit: sample-library\n")
4869 .expect("documented independent resolution");
4870 assert_eq!(
4871 independent,
4872 CandidateResolution::Independent {
4873 release_unit: "sample-library".to_owned(),
4874 }
4875 );
4876
4877 let projection: CandidateResolution = serde_yaml::from_str(
4878 "kind: projection\nrelease-unit: sample-library\ntarget-candidate: candidate:1234\n",
4879 )
4880 .expect("documented projection resolution");
4881 assert_eq!(
4882 projection,
4883 CandidateResolution::Projection {
4884 release_unit: "sample-library".to_owned(),
4885 target_candidate: Some("candidate:1234".to_owned()),
4886 }
4887 );
4888 let rendered = serde_yaml::to_string(&projection).expect("canonical resolution YAML");
4889 assert!(rendered.contains("release-unit: sample-library"));
4890 assert!(rendered.contains("target-candidate: candidate:1234"));
4891 assert!(!rendered.contains("release_unit"));
4892 assert!(!rendered.contains("target_candidate"));
4893
4894 for undocumented in [
4895 "kind: independent\nrelease_unit: sample-library\n",
4896 "kind: projection\nrelease-unit: sample-library\ntarget_candidate: candidate:1234\n",
4897 ] {
4898 serde_yaml::from_str::<CandidateResolution>(undocumented)
4899 .expect_err("undocumented snake-case field");
4900 }
4901 }
4902
4903 #[test]
4904 fn recognizes_supported_manifests() {
4905 assert_eq!(adapter_for(Path::new("package.json")), Some(Adapter::Npm));
4906 assert_eq!(
4907 adapter_for(Path::new("module.csproj")),
4908 Some(Adapter::Msbuild)
4909 );
4910 assert_eq!(adapter_for(Path::new("README.md")), None);
4911 }
4912
4913 #[test]
4914 fn choice_rendering_is_compact_and_explained() {
4915 let yaml = "choices:\n- suspended\n- excluded\nresolution: null\n";
4916 let rendered = annotate_choice_lines(yaml);
4917 assert!(rendered.contains("- suspended # configured, but releases are blocked"));
4918 assert!(rendered.contains("- excluded # outside Intentional's release-unit inventory"));
4919 }
4920
4921 #[test]
4922 fn proxy_reference_detection_ignores_prose_and_comments() {
4923 let path = "examples/sample.json";
4924 assert!(!structural_line_references_path(
4925 r#""description": "See examples/sample.json""#,
4926 path
4927 ));
4928 assert!(!structural_line_references_path(
4929 "note: examples/sample.json",
4930 path
4931 ));
4932 assert!(!structural_line_references_path(
4933 "// load examples/sample.json",
4934 path
4935 ));
4936 assert!(!structural_line_references_path(
4937 "const enabled = true; // load examples/sample.json",
4938 path
4939 ));
4940 assert!(!structural_line_references_path(
4941 "/* load examples/sample.json */",
4942 path
4943 ));
4944 assert!(structural_line_references_path(
4945 "run: curl https://registry.invalid && use examples/sample.json",
4946 path
4947 ));
4948 assert!(structural_line_references_path(
4949 r##"run: "curl https://registry.invalid/#fragment && use examples/sample.json""##,
4950 path
4951 ));
4952 assert!(structural_line_references_path(
4953 r#"const manifest = "examples/sample.json";"#,
4954 path
4955 ));
4956 }
4957
4958 #[test]
4959 fn npm_dependency_evidence_follows_the_materialized_projection_path() {
4960 struct TestDirectory(PathBuf);
4961 impl Drop for TestDirectory {
4962 fn drop(&mut self) {
4963 let _ = std::fs::remove_dir_all(&self.0);
4964 }
4965 }
4966
4967 let root = TestDirectory(std::env::temp_dir().join(format!(
4968 "intentional-init-{}-{}",
4969 std::process::id(),
4970 std::time::SystemTime::now()
4971 .duration_since(std::time::UNIX_EPOCH)
4972 .expect("system time")
4973 .as_nanos()
4974 )));
4975 for directory in ["base", "duplicates/alpha", "duplicates/beta"] {
4976 std::fs::create_dir_all(root.0.join(directory)).expect("create fixture directory");
4977 }
4978 std::fs::write(
4979 root.0.join("base/package.json"),
4980 r#"{"name":"sample-base","version":"1.0.0"}"#,
4981 )
4982 .expect("write base manifest");
4983 std::fs::write(
4984 root.0.join("duplicates/alpha/package.json"),
4985 r#"{"name":"sample-duplicate","version":"1.0.0"}"#,
4986 )
4987 .expect("write first duplicate");
4988 std::fs::write(
4989 root.0.join("duplicates/beta/package.json"),
4990 r#"{"name":"sample-duplicate","version":"1.0.0","dependencies":{"sample-base":"^1.0.0"}}"#,
4991 )
4992 .expect("write second duplicate");
4993 let mut config = Config::from_yaml(
4994 r#"contract: contract-2
4995release-units:
4996 sample-base:
4997 path: base
4998 projections:
4999 - { adapter: npm, file: package.json, mode: committed }
5000 tags:
5001 primary: { role: primary, template: '{id}@{version}' }
5002 sample-duplicate:
5003 path: .
5004 projections:
5005 - { adapter: npm, file: duplicates/alpha/package.json, mode: committed }
5006 - { adapter: npm, file: duplicates/beta/package.json, mode: committed }
5007 tags:
5008 primary: { role: primary, template: '{id}@{version}' }
5009"#,
5010 )
5011 .expect("fixture config");
5012
5013 let mut edges = derive_npm_dependencies(&root.0, &mut config).expect("dependency evidence");
5014 assert_eq!(edges.len(), 1);
5015 assert_eq!(edges[0].manifest, Path::new("duplicates/beta/package.json"));
5016 assert_eq!(
5017 config.release_units["sample-duplicate"].depends_on,
5018 vec!["sample-base"]
5019 );
5020
5021 config
5022 .release_units
5023 .get_mut("sample-duplicate")
5024 .expect("duplicate unit")
5025 .projections
5026 .retain(|projection| projection.file != "duplicates/beta/package.json");
5027 retain_materialized_npm_dependencies(&mut config, &mut edges);
5028 assert!(edges.is_empty());
5029 assert!(config.release_units["sample-duplicate"]
5030 .depends_on
5031 .is_empty());
5032
5033 let duplicate = config
5034 .release_units
5035 .get_mut("sample-duplicate")
5036 .expect("duplicate unit");
5037 duplicate.projections.clear();
5038 duplicate.depends_on = vec!["sample-base".to_owned()];
5039 retain_materialized_npm_dependencies(&mut config, &mut edges);
5040 assert!(config.release_units["sample-duplicate"]
5041 .depends_on
5042 .is_empty());
5043 }
5044
5045 #[test]
5046 fn candidate_identity_and_detector_results_are_stable() {
5047 let first = candidate(
5048 "examples/first.json",
5049 Some(CandidateResolution::Independent {
5050 release_unit: "sample-unit".to_owned(),
5051 }),
5052 );
5053 let second = candidate("examples/second.json", Some(CandidateResolution::Excluded));
5054 assert_eq!(
5055 first.id,
5056 DiscoveryCandidate::stable_id("sample-detector", Path::new("examples/first.json"))
5057 .expect("same identity")
5058 );
5059 assert_ne!(first.id, second.id);
5060 assert_ne!(
5061 first.id,
5062 DiscoveryCandidate::stable_id("other-detector", Path::new("examples/first.json"))
5063 .expect("different detector")
5064 );
5065 assert!(DiscoveryCandidate::stable_id(
5066 "-invalid-detector",
5067 Path::new("examples/first.json")
5068 )
5069 .is_err());
5070
5071 let result = DetectorResult {
5072 detector: "sample-detector".to_owned(),
5073 candidates: vec![second.clone(), first.clone()],
5074 };
5075 let ordered = result.into_candidates().expect("detector result valid");
5076 assert!(ordered[0].path < ordered[1].path);
5077
5078 let mut tampered = first.clone();
5079 tampered.id = format!("candidate:{}", "0".repeat(64));
5080 assert!(candidate_plan(vec![tampered])
5081 .validate()
5082 .expect_err("tampered candidate id rejected")
5083 .to_string()
5084 .contains("does not match detector"));
5085
5086 let foreign = DetectorResult {
5087 detector: "other-detector".to_owned(),
5088 candidates: vec![first.clone()],
5089 };
5090 assert!(foreign
5091 .validate()
5092 .expect_err("foreign detector candidate rejected")
5093 .to_string()
5094 .contains("returned candidate owned by"));
5095
5096 let yaml = candidate_plan(vec![first, second])
5097 .to_yaml()
5098 .expect("candidate plan serializes");
5099 assert!(yaml.contains("kind: independent"));
5100 assert!(yaml.contains("kind: excluded"));
5101 assert!(yaml.contains("raw-version:"));
5102 assert!(yaml.contains("diagnostics:"));
5103 }
5104
5105 #[test]
5106 fn changesets_projection_dedup_rejects_adapter_or_mode_conflicts() {
5107 for (adapter, mode) in [
5108 (Adapter::Cargo, ProjectionMode::Committed),
5109 (Adapter::Npm, ProjectionMode::Injected),
5110 ] {
5111 let mut config = candidate_plan(Vec::new()).inferred_config;
5112 let release_unit = config
5113 .release_units
5114 .get_mut("configured")
5115 .expect("configured release unit");
5116 release_unit.path = PathBuf::from("examples");
5117 release_unit.projections = vec![Projection {
5118 adapter: Adapter::Npm,
5119 file: ProjectionFile::unit_relative(Path::new("sample.json")),
5120 mode: ProjectionMode::Committed,
5121 pointer: None,
5122 }];
5123 let mut candidate = candidate(
5124 "examples/sample.json",
5125 Some(CandidateResolution::Projection {
5126 release_unit: "configured".to_owned(),
5127 target_candidate: None,
5128 }),
5129 );
5130 let suggestion = candidate
5131 .projection
5132 .as_mut()
5133 .expect("projection suggestion");
5134 suggestion.adapter = adapter;
5135 suggestion.mode = mode;
5136 let mut discovery = Discovery {
5137 config,
5138 ..Discovery::default()
5139 };
5140
5141 let error = apply_changesets_candidate_resolutions(&mut discovery, &[candidate])
5142 .expect_err("same target with different semantics rejected");
5143 assert!(error
5144 .to_string()
5145 .contains("conflicts with existing projection"));
5146 }
5147 }
5148
5149 #[test]
5150 fn resolved_candidates_reject_genuine_version_conflicts() {
5151 let mut first = candidate(
5152 "examples/first.json",
5153 Some(CandidateResolution::Projection {
5154 release_unit: "configured".to_owned(),
5155 target_candidate: None,
5156 }),
5157 );
5158 first.raw_version.as_mut().expect("first version").value = "1.0.0".to_owned();
5159 let mut second = candidate(
5160 "examples/second.json",
5161 Some(CandidateResolution::Projection {
5162 release_unit: "configured".to_owned(),
5163 target_candidate: None,
5164 }),
5165 );
5166 second.raw_version.as_mut().expect("second version").value = "2.0.0".to_owned();
5167 let mut discovery = Discovery {
5168 config: candidate_plan(Vec::new()).inferred_config,
5169 ..Discovery::default()
5170 };
5171
5172 let error = recompute_resolved_versions(&mut discovery, &[first, second])
5173 .expect_err("resolved version conflict");
5174 assert!(error
5175 .to_string()
5176 .contains("disagree on current version: 1.0.0, 2.0.0"));
5177 }
5178
5179 #[test]
5180 fn changesets_projection_rejects_an_unrelated_existing_owner() {
5181 let config = Config::from_yaml(
5182 r#"contract: contract-2
5183release-units:
5184 alpha:
5185 path: examples
5186 tags:
5187 primary: { role: primary, template: '{id}@{version}' }
5188 gamma:
5189 path: examples
5190 projections:
5191 - adapter: json
5192 file: shared.json
5193 mode: committed
5194 pointer: /version
5195 tags:
5196 primary: { role: primary, template: 'gamma@{version}' }
5197"#,
5198 )
5199 .expect("unrelated-owner config");
5200 let mut candidate = candidate(
5201 "examples/shared.json",
5202 Some(CandidateResolution::Projection {
5203 release_unit: "alpha".to_owned(),
5204 target_candidate: None,
5205 }),
5206 );
5207 candidate.projection = Some(CandidateProjectionSuggestion {
5208 adapter: Adapter::Json,
5209 path: candidate.path.clone(),
5210 mode: ProjectionMode::Committed,
5211 pointer: Some("/version".to_owned()),
5212 });
5213 let candidate_id = candidate.id.clone();
5214 let mut discovery = Discovery {
5215 config,
5216 ..Discovery::default()
5217 };
5218
5219 let error = apply_changesets_candidate_resolutions(&mut discovery, &[candidate])
5220 .expect_err("unrelated projection ownership rejected");
5221 assert_eq!(
5222 error.to_string(),
5223 format!(
5224 "validation failed: Changesets discovery candidate {candidate_id} projection is already owned by release unit gamma, not alpha"
5225 )
5226 );
5227 }
5228
5229 #[test]
5230 fn rejects_plan_without_required_discovery_candidates() {
5231 let yaml = candidate_plan(Vec::new())
5232 .to_yaml()
5233 .expect("complete plan serializes");
5234 assert!(yaml.contains("discovery-candidates: []"));
5235 let incomplete = yaml.replace("discovery-candidates: []\n", "");
5236 let error = serde_yaml::from_str::<InitPlan>(&incomplete)
5237 .expect_err("missing discovery candidate collection rejected");
5238 assert!(error
5239 .to_string()
5240 .contains("missing field `discovery-candidates`"));
5241 }
5242
5243 #[test]
5244 fn empty_inferred_config_obeys_its_published_schema_shape() {
5245 let mut plan = candidate_plan(vec![candidate("examples/sample.json", None)]);
5246 plan.inferred_config = Config::default();
5247 plan.validate().expect("schema-compatible empty config");
5248
5249 plan.inferred_config
5250 .discovery
5251 .excluded_paths
5252 .push(ExcludedPathReceipt {
5253 detector: "sample-detector".to_owned(),
5254 path: PathBuf::from("examples/sample.json"),
5255 evidence_digest: DIGEST.to_owned(),
5256 });
5257 assert!(plan
5258 .validate()
5259 .expect_err("discovery is forbidden on an empty inferred config")
5260 .to_string()
5261 .contains("may only contain schema, contract, settings, and release-units"));
5262
5263 plan.inferred_config.discovery.excluded_paths.clear();
5264 plan.inferred_config.schema = Some("https://example.invalid/config.yml".to_owned());
5265 assert!(plan
5266 .validate()
5267 .expect_err("foreign schema is forbidden on an empty inferred config")
5268 .to_string()
5269 .contains("schema must be"));
5270 }
5271
5272 #[test]
5273 fn candidate_tag_templates_use_the_shared_contract() {
5274 for template in ["v{version}", "{version}-{version}", "{id}-{id}@{version}"] {
5275 let mut invalid = candidate("examples/template.json", None);
5276 invalid.tag.as_mut().expect("tag suggestion").template = template.to_owned();
5277 assert!(candidate_plan(vec![invalid])
5278 .validate()
5279 .expect_err("invalid candidate tag template rejected")
5280 .to_string()
5281 .contains("tag template"));
5282 }
5283 }
5284
5285 #[test]
5286 fn published_init_schema_carries_candidate_resolution_variants() {
5287 let schema: serde_yaml::Value =
5288 serde_yaml::from_str(include_str!("../../../schemas/init-plan.yml"))
5289 .expect("initialization-plan schema parses");
5290 assert!(schema["required"]
5291 .as_sequence()
5292 .expect("required plan fields")
5293 .iter()
5294 .any(|field| field.as_str() == Some("discovery-candidates")));
5295 let variants = schema["$defs"]["candidate-resolution"]["oneOf"]
5296 .as_sequence()
5297 .expect("candidate resolution variants");
5298 assert_eq!(variants.len(), 3);
5299 let kinds = variants
5300 .iter()
5301 .map(|variant| {
5302 variant["properties"]["kind"]["const"]
5303 .as_str()
5304 .expect("resolution kind")
5305 })
5306 .collect::<BTreeSet<_>>();
5307 assert_eq!(
5308 kinds,
5309 BTreeSet::from(["excluded", "independent", "projection"])
5310 );
5311 assert_eq!(
5312 schema["$defs"]["discovery-candidate"]["properties"]["tag"]["properties"]["template"]
5313 ["pattern"]
5314 .as_str(),
5315 Some(
5316 r"^(?![\s\S]*v\{version\})(?![\s\S]*\{version\}[\s\S]*\{version\})(?![\s\S]*\{id\}[\s\S]*\{id\})(?=[\s\S]*\{version\})[\s\S]*$"
5317 )
5318 );
5319 }
5320
5321 #[test]
5322 fn accepts_configured_and_same_plan_projection_targets() {
5323 let configured = candidate(
5324 "examples/configured.json",
5325 Some(CandidateResolution::Projection {
5326 release_unit: "configured".to_owned(),
5327 target_candidate: None,
5328 }),
5329 );
5330 let independent = candidate(
5331 "examples/independent.json",
5332 Some(CandidateResolution::Independent {
5333 release_unit: "planned".to_owned(),
5334 }),
5335 );
5336 let projection = candidate(
5337 "examples/projection.json",
5338 Some(CandidateResolution::Projection {
5339 release_unit: "planned".to_owned(),
5340 target_candidate: Some(independent.id.clone()),
5341 }),
5342 );
5343 candidate_plan(vec![configured, independent, projection])
5344 .validate()
5345 .expect("both projection target kinds accepted");
5346 }
5347
5348 #[test]
5349 fn changesets_materializes_any_unrepresented_candidate_projection_once() {
5350 let config = candidate_plan(Vec::new()).inferred_config;
5351 let mut generic = candidate(
5352 "configured/metadata.json",
5353 Some(CandidateResolution::Projection {
5354 release_unit: "configured".to_owned(),
5355 target_candidate: None,
5356 }),
5357 );
5358 generic.projection = Some(CandidateProjectionSuggestion {
5359 adapter: Adapter::Json,
5360 path: generic.path.clone(),
5361 mode: ProjectionMode::Committed,
5362 pointer: Some("/version".to_owned()),
5363 });
5364
5365 let mut discovery = Discovery {
5366 config,
5367 ..Discovery::default()
5368 };
5369 apply_changesets_candidate_resolutions(&mut discovery, std::slice::from_ref(&generic))
5370 .expect("generic projection materialized");
5371 assert_eq!(
5372 discovery.config.release_units["configured"]
5373 .projections
5374 .len(),
5375 1
5376 );
5377 assert_eq!(
5378 discovery.config.release_units["configured"].projections[0]
5379 .pointer
5380 .as_deref(),
5381 Some("/version")
5382 );
5383
5384 apply_changesets_candidate_resolutions(&mut discovery, std::slice::from_ref(&generic))
5385 .expect("represented projection not duplicated");
5386 assert_eq!(
5387 discovery.config.release_units["configured"]
5388 .projections
5389 .len(),
5390 1
5391 );
5392 }
5393
5394 #[test]
5395 fn rejects_duplicate_creators_and_absent_projection_targets() {
5396 let first = candidate(
5397 "examples/first.json",
5398 Some(CandidateResolution::Independent {
5399 release_unit: "duplicate".to_owned(),
5400 }),
5401 );
5402 let second = candidate(
5403 "examples/second.json",
5404 Some(CandidateResolution::Independent {
5405 release_unit: "duplicate".to_owned(),
5406 }),
5407 );
5408 assert!(candidate_plan(vec![first, second])
5409 .validate()
5410 .expect_err("duplicate creators rejected")
5411 .to_string()
5412 .contains("duplicate creator"));
5413
5414 let absent = candidate(
5415 "examples/absent.json",
5416 Some(CandidateResolution::Projection {
5417 release_unit: "missing".to_owned(),
5418 target_candidate: None,
5419 }),
5420 );
5421 assert!(candidate_plan(vec![absent])
5422 .validate()
5423 .expect_err("absent configured target rejected")
5424 .to_string()
5425 .contains("absent configured release unit"));
5426 }
5427
5428 #[test]
5429 fn rejects_projection_cycles_and_release_unit_mismatches() {
5430 let mut first = candidate("examples/first.json", None);
5431 let mut second = candidate("examples/second.json", None);
5432 first.resolution = Some(CandidateResolution::Projection {
5433 release_unit: "planned".to_owned(),
5434 target_candidate: Some(second.id.clone()),
5435 });
5436 second.resolution = Some(CandidateResolution::Projection {
5437 release_unit: "planned".to_owned(),
5438 target_candidate: Some(first.id.clone()),
5439 });
5440 assert!(candidate_plan(vec![first, second])
5441 .validate()
5442 .expect_err("candidate cycle rejected")
5443 .to_string()
5444 .contains("projection cycle"));
5445
5446 let independent = candidate(
5447 "examples/independent.json",
5448 Some(CandidateResolution::Independent {
5449 release_unit: "planned".to_owned(),
5450 }),
5451 );
5452 let mismatched = candidate(
5453 "examples/mismatched.json",
5454 Some(CandidateResolution::Projection {
5455 release_unit: "other".to_owned(),
5456 target_candidate: Some(independent.id.clone()),
5457 }),
5458 );
5459 assert!(candidate_plan(vec![independent, mismatched])
5460 .validate()
5461 .expect_err("mismatched target rejected")
5462 .to_string()
5463 .contains("target"));
5464
5465 let independent = candidate(
5466 "examples/independent.json",
5467 Some(CandidateResolution::Independent {
5468 release_unit: "planned".to_owned(),
5469 }),
5470 );
5471 let intermediate = candidate(
5472 "examples/intermediate.json",
5473 Some(CandidateResolution::Projection {
5474 release_unit: "planned".to_owned(),
5475 target_candidate: Some(independent.id.clone()),
5476 }),
5477 );
5478 let chained = candidate(
5479 "examples/chained.json",
5480 Some(CandidateResolution::Projection {
5481 release_unit: "planned".to_owned(),
5482 target_candidate: Some(intermediate.id.clone()),
5483 }),
5484 );
5485 assert!(candidate_plan(vec![independent, intermediate, chained])
5486 .validate()
5487 .expect_err("projection chain rejected")
5488 .to_string()
5489 .contains("not an independent creator"));
5490 }
5491}