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