Skip to main content

intentional_core/
init.rs

1// ---
2// relationships:
3//   implements: intent-driven-polyglot-release
4// ---
5
6//! Workspace-aware initialization and explicit Changesets takeover.
7
8use 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
38/// Transient initialization-plan location.
39pub const INIT_PLAN_PATH: &str = ".intentional/init-plan.yml";
40
41/// Published initialization-plan schema identifier.
42pub 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/// Process outcome for initialization.
51#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
52#[serde(rename_all = "kebab-case")]
53pub enum InitState {
54    /// Canonical configuration was produced, or takeover completed.
55    Success,
56    /// A deterministic plan exists but needs finite resolutions or repository edits.
57    NeedsInput,
58    /// A Changesets plan is complete and awaits explicit takeover.
59    Ready,
60}
61
62/// One source artifact used as initialization evidence.
63#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
64#[serde(rename_all = "kebab-case", deny_unknown_fields)]
65pub struct SourceEvidence {
66    /// Workspace-relative source path.
67    pub path: PathBuf,
68    /// SHA-256 of the complete file contents.
69    pub digest: String,
70    /// Relevant one-based source lines.
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub lines: Vec<usize>,
73}
74
75/// Optional raw version text together with the evidence from which it was extracted.
76#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
77#[serde(rename_all = "kebab-case", deny_unknown_fields)]
78pub struct RawVersionEvidence {
79    /// Uninterpreted version text supplied by the artifact.
80    pub value: String,
81    /// Exact evidence supporting the extracted text.
82    pub evidence: Vec<SourceEvidence>,
83}
84
85/// A projection a detector can suggest when its required fields were extracted.
86#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
87#[serde(rename_all = "kebab-case", deny_unknown_fields)]
88pub struct CandidateProjectionSuggestion {
89    /// Adapter specialization.
90    pub adapter: Adapter,
91    /// Exact workspace-relative projection path.
92    pub path: PathBuf,
93    /// Projection materialization mode.
94    pub mode: ProjectionMode,
95    /// JSON Pointer or dotted TOML/YAML key path for generic formats.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub pointer: Option<String>,
98}
99
100/// A tag a detector can suggest without granting it version authority.
101#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
102#[serde(rename_all = "kebab-case", deny_unknown_fields)]
103pub struct CandidateTagSuggestion {
104    /// Suggested tag id within the resolved release unit.
105    pub id: String,
106    /// Suggested primary or projection role.
107    pub role: TagRole,
108    /// Suggested template containing exactly one `{version}` placeholder.
109    pub template: String,
110}
111
112/// Evidence-backed extraction problem that does not assert artifact invalidity.
113#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
114#[serde(rename_all = "kebab-case", deny_unknown_fields)]
115pub struct ExtractionDiagnostic {
116    /// Stable diagnostic identity within the candidate.
117    pub id: String,
118    /// Stable extraction category.
119    pub code: String,
120    /// Human-readable extraction problem.
121    pub message: String,
122    /// Exact evidence encountered by the detector.
123    pub evidence: Vec<SourceEvidence>,
124}
125
126/// Explicit user or agent resolution for one discovery candidate.
127#[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    /// Create one independently versioned release unit from this candidate.
136    Independent {
137        /// Stable id for the new release unit.
138        release_unit: String,
139    },
140    /// Add this candidate as a projection of a release unit.
141    Projection {
142        /// Final configured or planned release-unit id.
143        release_unit: String,
144        /// Planned candidate to follow; omitted when the release unit is already configured.
145        #[serde(default, skip_serializing_if = "Option::is_none")]
146        target_candidate: Option<String>,
147    },
148    /// Keep this exact detector/path evidence outside the release-unit inventory.
149    Excluded,
150}
151
152/// Artifact-neutral evidence emitted by any release-unit detector.
153#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
154#[serde(rename_all = "kebab-case", deny_unknown_fields)]
155pub struct DiscoveryCandidate {
156    /// Stable hash of detector and exact path identity.
157    pub id: String,
158    /// Stable detector id, independent from adapter or ecosystem names.
159    pub detector: String,
160    /// Exact workspace-relative artifact path.
161    pub path: PathBuf,
162    /// All evidence used to create this candidate.
163    pub evidence: Vec<SourceEvidence>,
164    /// Manifest-native identity, when extraction succeeds.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub native_identity: Option<String>,
167    /// Uninterpreted version evidence, when present and readable.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub raw_version: Option<RawVersionEvidence>,
170    /// Projection suggestion, only when the detector has enough evidence.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub projection: Option<CandidateProjectionSuggestion>,
173    /// Tag suggestion, only when the detector has enough evidence.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub tag: Option<CandidateTagSuggestion>,
176    /// Narrow extraction diagnostics; these do not validate publication or artifact completeness.
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub diagnostics: Vec<ExtractionDiagnostic>,
179    /// Editable independent, projection, or excluded choice.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub resolution: Option<CandidateResolution>,
182}
183
184impl DiscoveryCandidate {
185    /// Derive the stable candidate id from detector and exact path identity.
186    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/// One detector invocation before its candidates are flattened into an initialization plan.
341#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
342#[serde(rename_all = "kebab-case", deny_unknown_fields)]
343pub struct DetectorResult {
344    /// Stable detector id.
345    pub detector: String,
346    /// Candidates emitted in stable path order.
347    pub candidates: Vec<DiscoveryCandidate>,
348}
349
350impl DetectorResult {
351    /// Validate detector identity and candidate ownership.
352    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    /// Return candidates in deterministic path order for plan serialization.
375    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/// One finite initialization decision or warning.
427#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
428#[serde(rename_all = "kebab-case", deny_unknown_fields)]
429pub struct InitDiagnostic {
430    /// Stable diagnostic identity.
431    pub id: String,
432    /// Stable diagnostic category.
433    pub code: String,
434    /// Actionable human-readable explanation.
435    pub message: String,
436    /// Exact evidence supporting the diagnostic.
437    pub evidence: Vec<SourceEvidence>,
438    /// Finite resolution values. Empty means informational.
439    #[serde(default, skip_serializing_if = "Vec::is_empty")]
440    pub choices: Vec<String>,
441    /// Parity-preserving or boundary-preserving recommendation.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub recommended: Option<String>,
444    /// Editable user/agent resolution.
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub resolution: Option<String>,
447    /// Whether repository state proves the selected resolution.
448    #[serde(default)]
449    pub verified: bool,
450    /// Whether a prior resolution was discarded because its evidence became stale.
451    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
452    pub invalidated_resolution: bool,
453    /// Evidence supporting a best-effort recommendation.
454    #[serde(default, skip_serializing_if = "Vec::is_empty")]
455    pub supporting_evidence: Vec<SourceEvidence>,
456    /// Evidence that weakens or contradicts a best-effort recommendation.
457    #[serde(default, skip_serializing_if = "Vec::is_empty")]
458    pub contradictory_evidence: Vec<SourceEvidence>,
459    /// Explicit uncertainty boundary for a best-effort recommendation.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub uncertainty: Option<String>,
462}
463
464/// One side of a Changesets takeover parity comparison.
465#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
466#[serde(rename_all = "kebab-case", deny_unknown_fields)]
467pub struct ParityRelease {
468    /// Aggregate requested bump after dependency and group propagation.
469    pub requested_bump: Bump,
470    /// Resulting version under that authority's contract.
471    pub next_version: String,
472}
473
474/// Changesets and proposed release results used for takeover parity.
475#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
476#[serde(rename_all = "kebab-case", deny_unknown_fields)]
477pub struct ParityReleaseUnit {
478    /// Release-unit id.
479    pub release_unit: String,
480    /// Version in source projections.
481    pub current_version: String,
482    /// Independently computed Changesets result, when the source releases this release unit.
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub source: Option<ParityRelease>,
485    /// Proposed Intentional result, when the proposed contract releases this release unit.
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub proposed: Option<ParityRelease>,
488    /// The results differ only because `bump-translation` is `significance`.
489    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
490    pub expected_difference: bool,
491}
492
493/// Deterministic parity comparison.
494#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
495#[serde(rename_all = "kebab-case", deny_unknown_fields)]
496pub struct ParityResult {
497    /// `equivalent` when all logical releases agree, `expected-difference`
498    /// when they differ only because of the chosen bump translation, and
499    /// otherwise `blocked`.
500    pub status: String,
501    /// Per-release-unit results.
502    pub release_units: Vec<ParityReleaseUnit>,
503}
504
505/// One pending Changesets file converted losslessly at takeover.
506#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
507#[serde(rename_all = "kebab-case", deny_unknown_fields)]
508pub struct ConvertedIntent {
509    /// Changeset filename stem retained as the Intentional intent id.
510    pub id: String,
511    /// Release-unit bumps after bump translation.
512    pub release_units: BTreeMap<String, Bump>,
513    /// Release units the translated intent graduates to `1.0.0`.
514    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
515    pub graduates: BTreeSet<String>,
516    /// Markdown body, excluding frontmatter delimiters.
517    pub message: String,
518    /// Original file.
519    pub source: PathBuf,
520    /// Intentional target file.
521    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/// Durable, editable handoff produced while Changesets still owns authority.
531#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
532#[serde(rename_all = "kebab-case", deny_unknown_fields)]
533pub struct InitPlan {
534    /// Published schema location.
535    #[serde(rename = "$schema")]
536    pub schema: String,
537    /// Computed state. Editing this field never bypasses validation.
538    pub state: InitState,
539    /// Existing authority kind.
540    pub source_kind: String,
541    /// Digest over all current source evidence.
542    pub source_fingerprint: String,
543    /// Complete confidently inferred canonical configuration.
544    pub inferred_config: Config,
545    /// Artifact-neutral candidates awaiting or carrying explicit resolutions.
546    pub discovery_candidates: Vec<DiscoveryCandidate>,
547    /// Finite choices, warnings, and verified repository integration outcomes.
548    pub diagnostics: Vec<InitDiagnostic>,
549    /// How pending bumps written under component semantics translate. Present
550    /// for Changesets adoption and for a `contract-1` component upgrade.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub bump_translation: Option<BumpTranslation>,
553    /// Pending source intents and their targets.
554    pub converted_intents: Vec<ConvertedIntent>,
555    /// Independent source/proposed release comparison.
556    pub parity: ParityResult,
557    /// Exact operations an explicit takeover performs.
558    pub planned_operations: Vec<String>,
559    /// Required command after the takeover changes are committed.
560    pub post_commit_action: String,
561}
562
563impl InitPlan {
564    /// Validate candidate evidence and its projection resolution graph.
565    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    /// Serialize deterministically with compact, explained YAML enum choices.
651    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    /// Equivalent structured JSON for agent consumers.
658    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/// Planned initialization or takeover output.
777#[derive(Debug, Clone)]
778pub struct InitResult {
779    /// Computed process outcome.
780    pub state: InitState,
781    /// Primary output path.
782    pub path: PathBuf,
783    /// Human-readable/schema-backed file contents.
784    pub contents: String,
785    /// Exact operations.
786    pub operations: Vec<String>,
787    /// Structured plan when adopting Changesets.
788    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    /// Materialize initialization unless this is a dry run.
804    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    /// Structured output matching the written configuration or initialization plan.
843    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/// Options for one `intentional init` run.
855#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
856pub struct InitOptions {
857    /// Perform the explicit takeover from a ready Changesets plan.
858    pub take_over: bool,
859    /// Set the plan's `bump-translation`. Without it, the previous plan's
860    /// value applies, else `parity`.
861    pub bump_translation: Option<BumpTranslation>,
862}
863
864/// Discover or reconcile initialization, optionally executing explicit takeover.
865pub 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
875/// Discover or reconcile initialization with explicit options.
876pub 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
896/// Compatibility wrapper for callers performing ordinary initialization.
897pub 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
949/// Source kind of an initialization plan that upgrades a `contract-1` config.
950const 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            // The applied config already carries the upgrade; keep only
1017            // the intent rewrites.
1018            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    // A config-only upgrade is safe on its own, so it does not wait for
1039    // unrelated discovery candidates.
1040    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
1120/// Load the configured inventory. A `contract-1` config is upgraded in
1121/// memory and returned with its upgrade.
1122fn 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
1134/// Planned upgrade of a `contract-1` config.
1135struct UpgradePlan {
1136    /// Upgraded config text, with comments kept where possible.
1137    config_text: String,
1138    /// Pending intents rewritten by the chosen translation.
1139    rewrites: Vec<ConvertedIntent>,
1140    /// Chosen translation, or the default while the choice is pending.
1141    translation: Option<BumpTranslation>,
1142    /// Whether the upgrade waits in the init plan: for review, or because
1143    /// the translation cannot keep the versions.
1144    pending: bool,
1145    /// Comparison of the `contract-1` result with the upgraded result.
1146    parity: ParityResult,
1147    /// Digest over the config, pending intents, and candidate evidence.
1148    fingerprint: String,
1149    /// Config and intent writes when the upgrade applies now.
1150    writes: Vec<(PathBuf, String)>,
1151    /// Operations when the upgrade applies now.
1152    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
1171/// Plan a `contract-1` upgrade.
1172///
1173/// A `compatibility` config, or one without the mapping, already reads bump
1174/// names as significance: only the config changes. A `component` config
1175/// whose pending release, including dependency-derived releases, has other
1176/// versions under `contract-2` needs a translation choice. A
1177/// `--bump-translation` flag, or an unchanged reviewed init plan, supplies
1178/// it; otherwise init records the default in the init plan for review.
1179/// `parity` applies only when the rewritten intents release every version
1180/// that `contract-1` would release; otherwise the plan is blocked.
1181fn 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    // The contract-1 component result, with its dependency propagation and
1260    // release groups.
1261    let source =
1262        resolve_versions_with(&upgrade.config, &declared, &none, &current, component_bump)?;
1263    let significance = resolve_versions(&upgrade.config, &declared, &none, &current)?;
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, &current);
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        &current,
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                // `significance` accepts every contract-2 version.
1300                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            // npm-to-npm edges are manifest-owned. Only edges that an npm
1566            // manifest cannot natively describe remain authored config.
1567            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 &current_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    // Changesets bump names name a version component. Translate them once.
2171    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    // The state is always recomputed after reading editable resolutions.
2311    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
2957/// Whether a manifest describes a versioned project. A Cargo manifest
2958/// without `[package]` (a virtual workspace) and an npm manifest without a
2959/// string `name` (it cannot be published) are not projects.
2960fn 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
3979/// Compare the Changesets result with the proposed Intentional result.
3980///
3981/// With `parity`, the proposed result must equal the Changesets result,
3982/// including dependency-derived releases. With `significance`, the proposed
3983/// result must equal the Changesets algorithm with bump names read as
3984/// significance, which proves the inventory and configuration; each
3985/// remaining difference from the Changesets result is expected.
3986fn 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    // The reference the proposed result must equal.
4067    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
4163/// Whether two sides release the same version. Bump names may differ,
4164/// because translation renames bumps on purpose.
4165fn 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
4178/// The version that each release unit's pending bump applies to under
4179/// component semantics: the highest current version of its release group,
4180/// else its own current version.
4181fn 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
4199/// Translate intents whose bump names name a version component.
4200///
4201/// With `parity`, a release unit whose base is `0.x` translates `major` to
4202/// `major` with `graduate`, and `minor` to `major`, so that the next version
4203/// does not change. With `significance`, bump names stay. A translated
4204/// intent records each renamed bump in an HTML comment, so that reviewers
4205/// see why and the rendered changelog does not.
4206fn 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
4255/// Compute the source Changesets result without invoking Intentional's resolver.
4256///
4257/// This deliberately duplicates the small release-propagation algorithm so the
4258/// takeover gate compares two computations rather than comparing a result with
4259/// itself.
4260fn 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, &current[&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| &current[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| &current[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(&current[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            // A plan written before the contract-2 upgrade keeps its
4455            // resolutions; only its inferred config is upgraded.
4456            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}