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