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, ProjectionFile, ReleaseUnitConfig,
11    TagConfig, 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: ProjectionFile::unit_relative(&file),
1288        mode: suggestion.mode,
1289        pointer: suggestion.pointer.clone(),
1290    })
1291}
1292
1293fn apply_changesets_candidate_resolutions(
1294    discovery: &mut Discovery,
1295    candidates: &[DiscoveryCandidate],
1296) -> Result<BTreeMap<String, String>> {
1297    let mut removed_release_units = BTreeSet::new();
1298    let mut identity_map = BTreeMap::new();
1299    for candidate in candidates {
1300        match &candidate.resolution {
1301            None => {}
1302            Some(CandidateResolution::Projection { release_unit, .. }) => {
1303                let projection_owner =
1304                    candidate_projection_owner(&discovery.config, candidate).map(str::to_owned);
1305                let Some(target) = discovery.config.release_units.get(release_unit) else {
1306                    return Err(Error::Validation(format!(
1307                        "Changesets discovery candidate {} projects onto absent release unit {release_unit}",
1308                        candidate.id
1309                    )));
1310                };
1311                let target_path = target.path.clone();
1312                if candidate.projection.is_some() {
1313                    if let Some(owner) = projection_owner.as_deref() {
1314                        let native_owner = candidate.native_identity.as_deref() == Some(owner);
1315                        if owner != release_unit && !native_owner {
1316                            return Err(Error::Validation(format!(
1317                                "Changesets discovery candidate {} projection is already owned by release unit {owner}, not {release_unit}",
1318                                candidate.id
1319                            )));
1320                        }
1321                    }
1322                    let projection = candidate_projection(candidate, &target_path)?;
1323                    let target = discovery
1324                        .config
1325                        .release_units
1326                        .get_mut(release_unit)
1327                        .expect("validated target release unit");
1328                    if let Some(existing) = target.projections.iter().find(|existing| {
1329                        existing.file == projection.file && existing.pointer == projection.pointer
1330                    }) {
1331                        if existing.adapter != projection.adapter
1332                            || existing.mode != projection.mode
1333                        {
1334                            return Err(Error::Validation(format!(
1335                                "Changesets discovery candidate {} conflicts with existing projection {} on release unit {release_unit}: adapter or mode differs",
1336                                candidate.id,
1337                                projection.file
1338                            )));
1339                        }
1340                    } else {
1341                        target.projections.push(projection);
1342                    }
1343                }
1344                if let Some(native_identity) = &candidate.native_identity {
1345                    if native_identity != release_unit {
1346                        if let Some(existing) =
1347                            identity_map.insert(native_identity.clone(), release_unit.clone())
1348                        {
1349                            if existing != *release_unit {
1350                                return Err(Error::Validation(format!(
1351                                    "Changesets discovery candidates map native identity {native_identity} to both {existing} and {release_unit}"
1352                                )));
1353                            }
1354                        }
1355                        if let Some(source) =
1356                            discovery.config.release_units.get_mut(native_identity)
1357                        {
1358                            source.projections.retain(|projection| {
1359                                projection.file.workspace_path(&source.path) != candidate.path
1360                            });
1361                            if source.projections.is_empty() {
1362                                removed_release_units.insert(native_identity.clone());
1363                            }
1364                        }
1365                    }
1366                }
1367                discovery
1368                    .config
1369                    .discovery
1370                    .managed_paths
1371                    .push(ManagedPathReceipt {
1372                        detector: candidate.detector.clone(),
1373                        path: candidate.path.clone(),
1374                        release_unit: release_unit.clone(),
1375                    });
1376            }
1377            Some(CandidateResolution::Excluded) => {
1378                if let Some(native_identity) = &candidate.native_identity {
1379                    if let Some(unit) = discovery.config.release_units.get_mut(native_identity) {
1380                        let unit_path = unit.path.clone();
1381                        unit.projections.retain(|projection| {
1382                            projection.file.workspace_path(&unit_path) != candidate.path
1383                        });
1384                        if unit.projections.is_empty() {
1385                            removed_release_units.insert(native_identity.clone());
1386                        }
1387                    }
1388                }
1389                let digest = candidate
1390                    .evidence
1391                    .iter()
1392                    .find(|item| item.path == candidate.path)
1393                    .expect("candidate path evidence validated")
1394                    .digest
1395                    .clone();
1396                discovery
1397                    .config
1398                    .discovery
1399                    .excluded_paths
1400                    .push(ExcludedPathReceipt {
1401                        detector: candidate.detector.clone(),
1402                        path: candidate.path.clone(),
1403                        evidence_digest: digest,
1404                    });
1405            }
1406            Some(CandidateResolution::Independent { release_unit }) => {
1407                return Err(Error::Validation(format!(
1408                    "Changesets already establishes release unit {release_unit}; candidate {} must project onto it or be excluded",
1409                    candidate.id
1410                )));
1411            }
1412        }
1413    }
1414    for release_unit in &removed_release_units {
1415        discovery.config.release_units.remove(release_unit);
1416    }
1417    for (source, target) in &identity_map {
1418        if discovery.config.release_units.contains_key(source) {
1419            return Err(Error::Validation(format!(
1420                "Changesets discovery candidates leave native identity {source} split between release units {source} and {target}; resolve every projection for that identity consistently"
1421            )));
1422        }
1423        if discovery.workspace_packages.remove(source) {
1424            discovery.workspace_packages.insert(target.clone());
1425        }
1426        if discovery.private_packages.remove(source) {
1427            discovery.private_packages.insert(target.clone());
1428        }
1429        discovery.versions.remove(source);
1430    }
1431    for unit in discovery.config.release_units.values_mut() {
1432        for dependency in &mut unit.depends_on {
1433            if let Some(target) = identity_map.get(dependency) {
1434                *dependency = target.clone();
1435            }
1436        }
1437        unit.depends_on
1438            .retain(|dependency| !removed_release_units.contains(dependency));
1439        unit.depends_on.sort();
1440        unit.depends_on.dedup();
1441    }
1442    for edge in &mut discovery.npm_dependencies {
1443        if let Some(target) = identity_map.get(&edge.dependent) {
1444            edge.dependent = target.clone();
1445        }
1446        if let Some(target) = identity_map.get(&edge.dependency) {
1447            edge.dependency = target.clone();
1448        }
1449    }
1450    discovery.npm_dependencies.retain(|edge| {
1451        edge.dependent != edge.dependency
1452            && discovery.config.release_units.contains_key(&edge.dependent)
1453            && discovery
1454                .config
1455                .release_units
1456                .contains_key(&edge.dependency)
1457    });
1458    retain_materialized_npm_dependencies(&mut discovery.config, &mut discovery.npm_dependencies);
1459    discovery
1460        .config
1461        .discovery
1462        .managed_paths
1463        .sort_by(|left, right| (&left.detector, &left.path).cmp(&(&right.detector, &right.path)));
1464    discovery
1465        .config
1466        .discovery
1467        .excluded_paths
1468        .sort_by(|left, right| (&left.detector, &left.path).cmp(&(&right.detector, &right.path)));
1469    recompute_resolved_versions(discovery, candidates)?;
1470    Ok(identity_map)
1471}
1472
1473fn recompute_resolved_versions(
1474    discovery: &mut Discovery,
1475    candidates: &[DiscoveryCandidate],
1476) -> Result<()> {
1477    let mut versions = BTreeMap::<String, BTreeSet<Version>>::new();
1478    let mut unresolved = BTreeSet::new();
1479    for candidate in candidates {
1480        let Some(raw_version) = candidate.raw_version.as_ref() else {
1481            continue;
1482        };
1483        let Some(native_identity) = candidate.native_identity.as_ref() else {
1484            continue;
1485        };
1486        let release_unit = match candidate.resolution.as_ref() {
1487            Some(CandidateResolution::Projection { release_unit, .. })
1488            | Some(CandidateResolution::Independent { release_unit }) => release_unit,
1489            Some(CandidateResolution::Excluded) => continue,
1490            None => {
1491                unresolved.insert(native_identity.clone());
1492                native_identity
1493            }
1494        };
1495        if !discovery.config.release_units.contains_key(release_unit) {
1496            continue;
1497        }
1498        if let Ok(version) = Version::parse(&raw_version.value) {
1499            versions
1500                .entry(release_unit.clone())
1501                .or_default()
1502                .insert(version);
1503        }
1504    }
1505
1506    discovery.versions.clear();
1507    for (release_unit, observed) in versions {
1508        if observed.len() == 1 {
1509            discovery.versions.insert(
1510                release_unit,
1511                observed.into_iter().next().expect("one observed version"),
1512            );
1513        } else if !unresolved.contains(&release_unit) {
1514            return Err(Error::Validation(format!(
1515                "resolved discovery candidates for release unit {release_unit} disagree on current version: {}",
1516                observed
1517                    .into_iter()
1518                    .map(|version| version.to_string())
1519                    .collect::<Vec<_>>()
1520                    .join(", ")
1521            )));
1522        }
1523    }
1524    Ok(())
1525}
1526
1527fn candidate_projection_owner<'a>(
1528    config: &'a Config,
1529    candidate: &DiscoveryCandidate,
1530) -> Option<&'a str> {
1531    let Some(suggestion) = &candidate.projection else {
1532        return None;
1533    };
1534    config.release_units.iter().find_map(|(id, unit)| {
1535        unit.projections
1536            .iter()
1537            .any(|projection| {
1538                projection.file.workspace_path(&unit.path) == suggestion.path
1539                    && projection.pointer == suggestion.pointer
1540            })
1541            .then_some(id.as_str())
1542    })
1543}
1544
1545fn changesets_plan(root: &Path, take_over: bool) -> Result<InitResult> {
1546    let previous = load_previous_plan(root)?;
1547    let changesets_config_path = root.join(CHANGESETS_CONFIG);
1548    let changesets_text = std::fs::read_to_string(&changesets_config_path)
1549        .map_err(|error| Error::io(&changesets_config_path, error))?;
1550    let changesets: JsonValue = serde_json::from_str(&changesets_text)
1551        .map_err(|error| Error::Validation(format!("invalid Changesets config: {error}")))?;
1552    let mut converted_intents = load_changesets_intents(root)?;
1553    let mut referenced_names = converted_intents
1554        .iter()
1555        .flat_map(|intent| intent.release_units.keys().cloned())
1556        .collect::<BTreeSet<_>>();
1557    for key in ["ignore", "fixed", "linked"] {
1558        collect_json_strings(&changesets[key], &mut referenced_names);
1559    }
1560    let mut discovery = discover(root)?;
1561    reconcile_candidate_resolutions(&mut discovery.candidates, previous.as_ref());
1562    let candidates = discovery.candidates.clone();
1563    let identity_map = apply_changesets_candidate_resolutions(&mut discovery, &candidates)?;
1564    discovery.config.settings.pre_1_0_bump_mapping = Pre1BumpMapping::Component;
1565    discovery.config.settings.internal_dependency_bump = changesets["updateInternalDependencies"]
1566        .as_str()
1567        .and_then(|value| value.parse().ok())
1568        .unwrap_or(Bump::Patch);
1569    discovery.config.fixed = parse_groups(&changesets["fixed"])?;
1570    discovery.config.linked = parse_groups(&changesets["linked"])?;
1571    remap_groups(&mut discovery.config.fixed, &identity_map);
1572    remap_groups(&mut discovery.config.linked, &identity_map);
1573    remap_converted_intents(&mut converted_intents, &identity_map, &discovery.config)?;
1574
1575    let mut diagnostics = Vec::new();
1576    let config_evidence = evidence(root, Path::new(CHANGESETS_CONFIG), Vec::new())?;
1577    for ignored in changesets["ignore"].as_array().into_iter().flatten() {
1578        let Some(source_package) = ignored.as_str() else {
1579            continue;
1580        };
1581        diagnostics.push(InitDiagnostic {
1582            id: format!("ignored-release-unit-disposition:{source_package}"),
1583            code: "ignored-release-unit-disposition".to_owned(),
1584            message: format!(
1585                "Choose whether Changesets-ignored package {source_package} is suspended, excluded, or managed. Selecting managed requires removing it from Changesets ignore before takeover."
1586            ),
1587            evidence: vec![config_evidence.clone()],
1588            choices: vec!["suspended".to_owned(), "excluded".to_owned(), "managed".to_owned()],
1589            recommended: Some("suspended".to_owned()),
1590            resolution: None,
1591            verified: false,
1592            invalidated_resolution: false,
1593            supporting_evidence: Vec::new(),
1594            contradictory_evidence: Vec::new(),
1595            uncertainty: None,
1596        });
1597    }
1598    if let Some((versions_private, tags_private)) =
1599        changesets_private_package_settings(&changesets["privatePackages"])
1600    {
1601        diagnostics.push(InitDiagnostic {
1602            id: "private-package-versioning".to_owned(),
1603            code: "private-package-versioning".to_owned(),
1604            message: if versions_private {
1605                "Changesets versions private packages; Intentional preserves that behavior because package privacy is independent from version management.".to_owned()
1606            } else {
1607                "Changesets suppresses private-package versions; Intentional manages versions independently from publication privacy, so accepting Intentional semantics is a deliberate contract change.".to_owned()
1608            },
1609            evidence: vec![config_evidence.clone()],
1610            choices: if versions_private {
1611                Vec::new()
1612            } else {
1613                vec!["intentional".to_owned()]
1614            },
1615            recommended: (!versions_private).then(|| "intentional".to_owned()),
1616            resolution: None,
1617            verified: versions_private,
1618            invalidated_resolution: false,
1619            supporting_evidence: Vec::new(),
1620            contradictory_evidence: Vec::new(),
1621            uncertainty: None,
1622        });
1623        if !tags_private {
1624            diagnostics.push(InitDiagnostic {
1625                id: "private-package-tagging".to_owned(),
1626                code: "private-package-tagging".to_owned(),
1627                message: "Changesets suppresses private-package tags; Intentional creates annotated records for every managed release unit, independently from publication privacy.".to_owned(),
1628                evidence: vec![config_evidence.clone()],
1629                choices: vec!["intentional".to_owned()],
1630                recommended: Some("intentional".to_owned()),
1631                resolution: None,
1632                verified: false,
1633                invalidated_resolution: false,
1634                supporting_evidence: Vec::new(),
1635                contradictory_evidence: Vec::new(),
1636                uncertainty: None,
1637            });
1638        }
1639    }
1640    if changesets["changelog"] != JsonValue::Bool(false) && !changesets["changelog"].is_null() {
1641        diagnostics.push(InitDiagnostic {
1642            id: "changesets-changelog".to_owned(),
1643            code: "changesets-changelog".to_owned(),
1644            message: "Intentional renders its contract-defined release-unit changelogs instead of invoking the configured Changesets changelog generator.".to_owned(),
1645            evidence: vec![config_evidence.clone()],
1646            choices: vec!["intentional".to_owned()],
1647            recommended: Some("intentional".to_owned()),
1648            resolution: None,
1649            verified: false,
1650            invalidated_resolution: false,
1651            supporting_evidence: Vec::new(),
1652            contradictory_evidence: Vec::new(),
1653            uncertainty: None,
1654        });
1655    }
1656    if changesets["commit"] != JsonValue::Bool(false) && !changesets["commit"].is_null() {
1657        diagnostics.push(InitDiagnostic {
1658            id: "changesets-commit".to_owned(),
1659            code: "changesets-commit".to_owned(),
1660            message: "Intentional never creates commits; repository orchestration must retain the configured commit behavior externally.".to_owned(),
1661            evidence: vec![config_evidence.clone()],
1662            choices: vec!["external".to_owned()],
1663            recommended: Some("external".to_owned()),
1664            resolution: None,
1665            verified: false,
1666            invalidated_resolution: false,
1667            supporting_evidence: Vec::new(),
1668            contradictory_evidence: Vec::new(),
1669            uncertainty: None,
1670        });
1671    }
1672    if changesets["___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH"]
1673        ["onlyUpdatePeerDependentsWhenOutOfRange"]
1674        .as_bool()
1675        == Some(true)
1676    {
1677        diagnostics.push(InitDiagnostic {
1678            id: "changesets-peer-dependent-policy".to_owned(),
1679            code: "changesets-peer-dependent-policy".to_owned(),
1680            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(),
1681            evidence: vec![config_evidence.clone()],
1682            choices: vec!["intentional".to_owned()],
1683            recommended: Some("intentional".to_owned()),
1684            resolution: None,
1685            verified: false,
1686            invalidated_resolution: false,
1687            supporting_evidence: Vec::new(),
1688            contradictory_evidence: Vec::new(),
1689            uncertainty: None,
1690        });
1691    }
1692    let dev_dependents = discovery
1693        .npm_dependencies
1694        .iter()
1695        .filter(|edge| edge.kind == NpmDependencyKind::Dev)
1696        .map(|edge| edge.dependent.clone())
1697        .collect::<BTreeSet<_>>();
1698    if !dev_dependents.is_empty() {
1699        let evidence = dev_dependents
1700            .iter()
1701            .filter_map(|id| {
1702                let release_unit = &discovery.config.release_units[id];
1703                release_unit
1704                    .projections
1705                    .iter()
1706                    .find(|projection| projection.adapter == Adapter::Npm)
1707                    .map(|projection| projection.file.workspace_path(&release_unit.path))
1708            })
1709            .map(|path| evidence(root, &path, Vec::new()))
1710            .collect::<Result<Vec<_>>>()?;
1711        diagnostics.push(InitDiagnostic {
1712            id: "changesets-dev-dependency-policy".to_owned(),
1713            code: "changesets-dev-dependency-policy".to_owned(),
1714            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(),
1715            evidence,
1716            choices: vec!["intentional".to_owned()],
1717            recommended: Some("intentional".to_owned()),
1718            resolution: None,
1719            verified: false,
1720            invalidated_resolution: false,
1721            supporting_evidence: Vec::new(),
1722            contradictory_evidence: Vec::new(),
1723            uncertainty: None,
1724        });
1725    }
1726    diagnostics.extend(release_tool_proxy_diagnostics(
1727        root,
1728        &discovery.candidates,
1729        &referenced_names,
1730    )?);
1731    let mut unmapped_release_units = BTreeSet::new();
1732    for release_unit in discovery.config.release_units.keys() {
1733        if referenced_names.contains(release_unit)
1734            || discovery.workspace_packages.contains(release_unit)
1735            || identity_map.values().any(|target| target == release_unit)
1736        {
1737            continue;
1738        }
1739        unmapped_release_units.insert(release_unit.clone());
1740        let release_unit_config = &discovery.config.release_units[release_unit];
1741        let manifest = release_unit_config
1742            .projections
1743            .first()
1744            .map(|projection| projection.file.workspace_path(&release_unit_config.path))
1745            .ok_or_else(|| {
1746                Error::Validation(format!(
1747                    "discovered release unit {release_unit} has no manifest evidence"
1748                ))
1749            })?;
1750        diagnostics.push(InitDiagnostic {
1751            id: format!("unmapped-release-unit-disposition:{release_unit}"),
1752            code: "unmapped-release-unit-disposition".to_owned(),
1753            message: format!(
1754                "Choose whether workspace package {release_unit}, which is outside the Changesets release inventory, is excluded, suspended, or managed."
1755            ),
1756            evidence: vec![evidence(root, &manifest, Vec::new())?],
1757            choices: vec!["excluded".to_owned(), "suspended".to_owned(), "managed".to_owned()],
1758            recommended: Some("excluded".to_owned()),
1759            resolution: None,
1760            verified: false,
1761            invalidated_resolution: false,
1762            supporting_evidence: Vec::new(),
1763            contradictory_evidence: Vec::new(),
1764            uncertainty: None,
1765        });
1766    }
1767    let current_integrations = scan_changesets_integrations(root)?;
1768    for item in &current_integrations {
1769        diagnostics.push(InitDiagnostic {
1770            id: format!("repository-integration:{}", item.path.display()),
1771            code: "repository-integration".to_owned(),
1772            message: format!(
1773                "Remove or replace executable Changesets references in {} and rerun init for verification.",
1774                item.path.display()
1775            ),
1776            evidence: vec![item.clone()],
1777            choices: vec!["removed".to_owned()],
1778            recommended: Some("removed".to_owned()),
1779            resolution: None,
1780            verified: false,
1781            invalidated_resolution: false,
1782            supporting_evidence: Vec::new(),
1783            contradictory_evidence: Vec::new(),
1784            uncertainty: None,
1785        });
1786    }
1787    let integration_paths = current_integrations
1788        .iter()
1789        .map(|item| item.path.as_path())
1790        .collect::<BTreeSet<_>>();
1791    for item in scan_external_release_evidence(root)?
1792        .into_iter()
1793        .filter(|item| !integration_paths.contains(item.path.as_path()))
1794    {
1795        diagnostics.push(InitDiagnostic {
1796            id: format!("external-release-evidence:{}", item.path.display()),
1797            code: "external-release-evidence".to_owned(),
1798            message: format!(
1799                "Keep repository-specific release behavior from {} in the external release executor; Intentional records this file as evidence without interpreting its contents.",
1800                item.path.display()
1801            ),
1802            evidence: vec![item],
1803            choices: vec!["external".to_owned()],
1804            recommended: Some("external".to_owned()),
1805            resolution: None,
1806            verified: false,
1807            invalidated_resolution: false,
1808            supporting_evidence: Vec::new(),
1809            contradictory_evidence: Vec::new(),
1810            uncertainty: None,
1811        });
1812    }
1813    reconcile_diagnostics(
1814        root,
1815        &mut diagnostics,
1816        previous.as_ref(),
1817        &discovery.candidates,
1818    )?;
1819    let mut source_config = discovery.config.clone();
1820    let ignored = changesets["ignore"]
1821        .as_array()
1822        .into_iter()
1823        .flatten()
1824        .filter_map(JsonValue::as_str)
1825        .map(str::to_owned)
1826        .collect::<BTreeSet<_>>();
1827    let source_excluded = ignored
1828        .iter()
1829        .cloned()
1830        .chain(unmapped_release_units.iter().cloned())
1831        .collect();
1832    exclude_release_units(&mut source_config, &source_excluded);
1833    apply_disposition_resolutions(&mut discovery.config, &diagnostics)?;
1834    let proxy_removals = apply_proxy_manifest_resolutions(
1835        &mut discovery.config,
1836        &diagnostics,
1837        &discovery.candidates,
1838    )?;
1839    retain_materialized_npm_dependencies(&mut discovery.config, &mut discovery.npm_dependencies);
1840    discovery.config.validate()?;
1841
1842    let declared: BTreeMap<String, Bump> =
1843        converted_intents
1844            .iter()
1845            .fold(BTreeMap::new(), |mut aggregate, intent| {
1846                for (id, bump) in &intent.release_units {
1847                    aggregate
1848                        .entry(id.clone())
1849                        .and_modify(|existing| *existing = (*existing).max(*bump))
1850                        .or_insert(*bump);
1851                }
1852                aggregate
1853            });
1854    let suppress_private_versions =
1855        changesets_private_package_settings(&changesets["privatePackages"])
1856            .is_some_and(|(versions_private, _)| !versions_private);
1857    let merged_ignored = ignored
1858        .iter()
1859        .filter_map(|source| {
1860            identity_map
1861                .get(source)
1862                .map(|target| (source.clone(), target.clone()))
1863        })
1864        .collect::<Vec<_>>();
1865    let merged_ignore_error = (!merged_ignored.is_empty()).then(|| {
1866        let conflicts = merged_ignored
1867            .iter()
1868            .map(|(source, target)| format!("{source} onto {target}"))
1869            .collect::<Vec<_>>()
1870            .join(", ");
1871        format!(
1872            "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"
1873        )
1874    });
1875    let mut skipped_release_units = ignored.clone();
1876    if suppress_private_versions {
1877        skipped_release_units.extend(discovery.private_packages.iter().cloned());
1878    }
1879    skipped_release_units.extend(
1880        discovery
1881            .config
1882            .release_units
1883            .keys()
1884            .filter(|id| !discovery.versions.contains_key(*id))
1885            .cloned(),
1886    );
1887    let source_semantics = ChangesetsSourceSemantics {
1888        private_packages: &discovery.private_packages,
1889        suppress_private_versions,
1890        npm_dependencies: &discovery.npm_dependencies,
1891        internal_dependency_bump: discovery.config.settings.internal_dependency_bump,
1892        update_internal_dependents_always: changesets
1893            ["___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH"]["updateInternalDependents"]
1894            .as_str()
1895            == Some("always"),
1896        only_update_peer_dependents_when_out_of_range: changesets
1897            ["___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH"]
1898            ["onlyUpdatePeerDependentsWhenOutOfRange"]
1899            .as_bool()
1900            .unwrap_or(false),
1901        preflight_error: merged_ignore_error
1902            .or_else(|| mixed_skipped_changeset(&converted_intents, &skipped_release_units)),
1903    };
1904    let parity = parity_result(
1905        &source_config,
1906        &discovery.config,
1907        &discovery.versions,
1908        &declared,
1909        &source_semantics,
1910    )?;
1911    if let Some(message) = &parity.source_error {
1912        diagnostics.push(InitDiagnostic {
1913            id: "changesets-release-invalid".to_owned(),
1914            code: "changesets-release-invalid".to_owned(),
1915            message: format!("The source Changesets release is invalid: {message}"),
1916            evidence: vec![config_evidence.clone()],
1917            choices: Vec::new(),
1918            recommended: None,
1919            resolution: None,
1920            verified: false,
1921            invalidated_resolution: false,
1922            supporting_evidence: Vec::new(),
1923            contradictory_evidence: Vec::new(),
1924            uncertainty: None,
1925        });
1926    }
1927    if let Some(message) = &parity.proposed_error {
1928        diagnostics.push(InitDiagnostic {
1929            id: "proposed-release-invalid".to_owned(),
1930            code: "proposed-release-invalid".to_owned(),
1931            message: format!("The proposed Intentional release is invalid: {message}"),
1932            evidence: vec![config_evidence.clone()],
1933            choices: Vec::new(),
1934            recommended: None,
1935            resolution: None,
1936            verified: false,
1937            invalidated_resolution: false,
1938            supporting_evidence: Vec::new(),
1939            contradictory_evidence: Vec::new(),
1940            uncertainty: None,
1941        });
1942        diagnostics.sort_by(|left, right| left.id.cmp(&right.id));
1943    }
1944    let unresolved = discovery
1945        .candidates
1946        .iter()
1947        .any(|candidate| candidate.resolution.is_none())
1948        || diagnostics.iter().any(|diagnostic| {
1949            !diagnostic.choices.is_empty()
1950                && (diagnostic.resolution.is_none() || !diagnostic.verified)
1951        });
1952    let state = if unresolved || parity.result.status != "equivalent" {
1953        InitState::NeedsInput
1954    } else {
1955        InitState::Ready
1956    };
1957
1958    let mut evidence_paths = discovery.evidence;
1959    evidence_paths.insert(PathBuf::from(CHANGESETS_CONFIG));
1960    for intent in &converted_intents {
1961        evidence_paths.insert(intent.source.clone());
1962    }
1963    for diagnostic in &diagnostics {
1964        for item in &diagnostic.evidence {
1965            evidence_paths.insert(item.path.clone());
1966        }
1967    }
1968    let source_fingerprint = fingerprint(root, &evidence_paths)?;
1969    let planned_operations = takeover_operations(root, &converted_intents, &proxy_removals);
1970    let mut plan = InitPlan {
1971        schema: INIT_PLAN_SCHEMA.to_owned(),
1972        state,
1973        source_kind: "changesets".to_owned(),
1974        source_fingerprint,
1975        inferred_config: discovery.config,
1976        discovery_candidates: discovery.candidates,
1977        diagnostics,
1978        converted_intents,
1979        parity: parity.result,
1980        planned_operations: planned_operations.clone(),
1981        post_commit_action: "intentional tag --baseline".to_owned(),
1982    };
1983    // The state is always recomputed after reading editable resolutions.
1984    plan.state = state;
1985    let contents = plan.to_yaml()?;
1986
1987    if take_over {
1988        if state != InitState::Ready {
1989            return Err(Error::Validation(
1990                "takeover requires a ready initialization plan with verified resolutions and parity"
1991                    .to_owned(),
1992            ));
1993        }
1994        let mut writes = vec![(PathBuf::from(CONFIG_PATH), plan.inferred_config.to_yaml()?)];
1995        for intent in &plan.converted_intents {
1996            writes.push((intent.target.clone(), intent.contents()?));
1997        }
1998        let deletes = takeover_deletes(root, &proxy_removals);
1999        verify_proxy_removal_preconditions(root, &proxy_removals)?;
2000        verify_takeover_preconditions(root, &writes, &plan.source_fingerprint, &evidence_paths)?;
2001        let takeover_evidence = Some((plan.source_fingerprint.clone(), evidence_paths));
2002        return Ok(InitResult {
2003            state: InitState::Success,
2004            path: PathBuf::from(CONFIG_PATH),
2005            contents: plan.inferred_config.to_yaml()?,
2006            operations: planned_operations,
2007            plan: Some(plan),
2008            writes,
2009            deletes,
2010            proxy_removals,
2011            takeover: true,
2012            takeover_evidence,
2013        });
2014    }
2015
2016    Ok(InitResult {
2017        state,
2018        path: PathBuf::from(INIT_PLAN_PATH),
2019        operations: vec![format!("write {INIT_PLAN_PATH}")],
2020        contents: contents.clone(),
2021        plan: Some(plan),
2022        writes: vec![(PathBuf::from(INIT_PLAN_PATH), contents)],
2023        deletes: Vec::new(),
2024        proxy_removals,
2025        takeover: false,
2026        takeover_evidence: None,
2027    })
2028}
2029
2030fn discover(root: &Path) -> Result<Discovery> {
2031    let workspace_paths = workspace_manifest_paths(root)?;
2032    let manifest_paths = all_manifest_paths(root)?;
2033    let mut discovery = Discovery {
2034        config: Config::default(),
2035        ..Discovery::default()
2036    };
2037    let mut observations = Vec::new();
2038    for path in manifest_paths {
2039        if let Some(candidate) = devcontainer_candidate(root, &path)? {
2040            discovery.evidence.insert(candidate.path.clone());
2041            if let (Some(native_identity), Some(projection), Some(tag)) = (
2042                candidate.native_identity.clone(),
2043                candidate.projection.clone(),
2044                candidate.tag.clone(),
2045            ) {
2046                observations.push(ManifestObservation {
2047                    candidate_path: candidate.path.clone(),
2048                    native_identity,
2049                    private_package: false,
2050                    projection,
2051                    raw_version: candidate
2052                        .raw_version
2053                        .as_ref()
2054                        .map(|version| version.value.clone()),
2055                    tag,
2056                    workspace_package: workspace_paths.contains(&path),
2057                });
2058            }
2059            discovery.candidates.push(candidate);
2060            continue;
2061        }
2062        let Some(adapter) = adapter_for(&path) else {
2063            continue;
2064        };
2065        if !is_project_manifest(&path, adapter)? {
2066            continue;
2067        }
2068        let (id, version) = manifest_identity(&path, adapter)?;
2069        let relative_manifest = path
2070            .strip_prefix(root)
2071            .map_err(|error| Error::Validation(format!("manifest is outside workspace: {error}")))?
2072            .to_owned();
2073        let manifest_evidence = evidence(root, &relative_manifest, Vec::new())?;
2074        let detector = detector_for(adapter).to_owned();
2075        let candidate = DiscoveryCandidate {
2076            id: DiscoveryCandidate::stable_id(&detector, &relative_manifest)?,
2077            detector,
2078            path: relative_manifest.clone(),
2079            evidence: vec![manifest_evidence.clone()],
2080            native_identity: Some(id.clone()),
2081            raw_version: version.as_ref().map(|value| RawVersionEvidence {
2082                value: value.clone(),
2083                evidence: vec![manifest_evidence],
2084            }),
2085            projection: Some(CandidateProjectionSuggestion {
2086                adapter,
2087                path: relative_manifest.clone(),
2088                mode: if adapter == Adapter::Go {
2089                    ProjectionMode::None
2090                } else {
2091                    ProjectionMode::Committed
2092                },
2093                pointer: None,
2094            }),
2095            tag: Some(CandidateTagSuggestion {
2096                id: "primary".to_owned(),
2097                role: TagRole::Primary,
2098                template: "{id}@{version}".to_owned(),
2099            }),
2100            diagnostics: Vec::new(),
2101            resolution: None,
2102        };
2103        observations.push(ManifestObservation {
2104            candidate_path: relative_manifest.clone(),
2105            native_identity: id,
2106            private_package: adapter == Adapter::Npm && npm_manifest_is_private(&path)?,
2107            projection: candidate
2108                .projection
2109                .clone()
2110                .expect("project manifest has projection"),
2111            raw_version: version,
2112            tag: candidate.tag.clone().expect("project manifest has tag"),
2113            workspace_package: workspace_paths.contains(&path),
2114        });
2115        discovery.candidates.push(candidate);
2116        discovery
2117            .evidence
2118            .insert(path.strip_prefix(root).unwrap_or(&path).to_owned());
2119    }
2120    materialize_discovery_inventory(&mut discovery, observations)?;
2121    discovery.npm_dependencies = derive_npm_dependencies(root, &mut discovery.config)?;
2122    discovery
2123        .candidates
2124        .sort_by(|left, right| left.path.cmp(&right.path));
2125    Ok(discovery)
2126}
2127
2128fn materialize_discovery_inventory(
2129    discovery: &mut Discovery,
2130    observations: Vec<ManifestObservation>,
2131) -> Result<()> {
2132    let mut by_identity = BTreeMap::<String, Vec<ManifestObservation>>::new();
2133    for observation in observations {
2134        by_identity
2135            .entry(observation.native_identity.clone())
2136            .or_default()
2137            .push(observation);
2138    }
2139    for (identity, mut observations) in by_identity {
2140        observations.sort_by(|left, right| left.candidate_path.cmp(&right.candidate_path));
2141        let directories = observations
2142            .iter()
2143            .map(|observation| {
2144                observation
2145                    .candidate_path
2146                    .parent()
2147                    .filter(|path| !path.as_os_str().is_empty())
2148                    .map(Path::to_path_buf)
2149                    .unwrap_or_else(|| PathBuf::from("."))
2150            })
2151            .collect::<BTreeSet<_>>();
2152        let path = if directories.len() == 1 {
2153            directories.into_iter().next().expect("one directory")
2154        } else {
2155            PathBuf::from(".")
2156        };
2157        let projections = observations
2158            .iter()
2159            .map(|observation| -> Result<Projection> {
2160                let file = if path == Path::new(".") {
2161                    observation.projection.path.clone()
2162                } else {
2163                    observation
2164                        .projection
2165                        .path
2166                        .strip_prefix(&path)
2167                        .map_err(|_| {
2168                            Error::Validation(format!(
2169                                "candidate projection {} is outside native identity directory {}",
2170                                observation.projection.path.display(),
2171                                path.display()
2172                            ))
2173                        })?
2174                        .to_owned()
2175                };
2176                Ok(Projection {
2177                    adapter: observation.projection.adapter,
2178                    file: ProjectionFile::unit_relative(&file),
2179                    mode: observation.projection.mode,
2180                    pointer: observation.projection.pointer.clone(),
2181                })
2182            })
2183            .collect::<Result<Vec<_>>>()?;
2184        let tag = observations
2185            .first()
2186            .map(|observation| &observation.tag)
2187            .expect("identity group is not empty");
2188        discovery.config.release_units.insert(
2189            identity.clone(),
2190            ReleaseUnitConfig {
2191                path,
2192                disposition: ReleaseUnitDisposition::Managed,
2193                projections,
2194                tags: BTreeMap::from([(
2195                    tag.id.clone(),
2196                    TagConfig {
2197                        role: tag.role,
2198                        template: tag.template.clone(),
2199                        require_phase: None,
2200                        tag_after: Vec::new(),
2201                    },
2202                )]),
2203                depends_on: Vec::new(),
2204            },
2205        );
2206        let versions = observations
2207            .iter()
2208            .filter_map(|observation| observation.raw_version.as_deref())
2209            .filter_map(|version| Version::parse(version).ok())
2210            .collect::<BTreeSet<_>>();
2211        if versions.len() == 1 {
2212            discovery.versions.insert(
2213                identity.clone(),
2214                versions.into_iter().next().expect("one version"),
2215            );
2216        }
2217        if observations
2218            .iter()
2219            .any(|observation| observation.workspace_package)
2220        {
2221            discovery.workspace_packages.insert(identity.clone());
2222        }
2223        if observations
2224            .iter()
2225            .any(|observation| observation.private_package)
2226        {
2227            discovery.private_packages.insert(identity);
2228        }
2229    }
2230    Ok(())
2231}
2232
2233fn workspace_manifest_paths(root: &Path) -> Result<BTreeSet<PathBuf>> {
2234    let mut directories = BTreeSet::new();
2235    let mut found_workspace = false;
2236    let pnpm = root.join("pnpm-workspace.yaml");
2237    if pnpm.exists() {
2238        found_workspace = true;
2239        let text = std::fs::read_to_string(&pnpm).map_err(|error| Error::io(&pnpm, error))?;
2240        let value: serde_yaml::Value = serde_yaml::from_str(&text)?;
2241        expand_workspace_patterns(
2242            root,
2243            value["packages"]
2244                .as_sequence()
2245                .into_iter()
2246                .flatten()
2247                .filter_map(serde_yaml::Value::as_str),
2248            &mut directories,
2249        )?;
2250    }
2251    let package_json = root.join("package.json");
2252    if package_json.exists() {
2253        let text = std::fs::read_to_string(&package_json)
2254            .map_err(|error| Error::io(&package_json, error))?;
2255        let value: JsonValue = serde_json::from_str(&text)
2256            .map_err(|error| Error::Validation(format!("invalid package.json: {error}")))?;
2257        let workspaces = value["workspaces"]
2258            .as_array()
2259            .or_else(|| value["workspaces"]["packages"].as_array());
2260        if let Some(workspaces) = workspaces {
2261            found_workspace = true;
2262            expand_workspace_patterns(
2263                root,
2264                workspaces.iter().filter_map(JsonValue::as_str),
2265                &mut directories,
2266            )?;
2267        }
2268    }
2269    let cargo = root.join("Cargo.toml");
2270    if cargo.exists() {
2271        let text = std::fs::read_to_string(&cargo).map_err(|error| Error::io(&cargo, error))?;
2272        let document = text
2273            .parse::<toml_edit::DocumentMut>()
2274            .map_err(|error| Error::Validation(format!("invalid Cargo.toml: {error}")))?;
2275        if let Some(members) = document
2276            .get("workspace")
2277            .and_then(|workspace| workspace.get("members"))
2278            .and_then(toml_edit::Item::as_array)
2279        {
2280            found_workspace = true;
2281            expand_workspace_patterns(
2282                root,
2283                members.iter().filter_map(toml_edit::Value::as_str),
2284                &mut directories,
2285            )?;
2286        }
2287    }
2288    if !found_workspace {
2289        directories.insert(root.to_owned());
2290    }
2291    let mut paths = BTreeSet::new();
2292    for directory in directories {
2293        add_manifests_in_directory(&directory, &mut paths)?;
2294    }
2295    paths.retain(|path| !hard_excluded(root, path));
2296    remove_git_ignored(root, &mut paths)?;
2297    Ok(paths)
2298}
2299
2300fn expand_workspace_pattern(
2301    root: &Path,
2302    pattern: &str,
2303    directories: &mut BTreeSet<PathBuf>,
2304) -> Result<()> {
2305    let (excluded, pattern) = pattern
2306        .strip_prefix('!')
2307        .map_or((false, pattern), |pattern| (true, pattern));
2308    let absolute = root.join(pattern).to_string_lossy().to_string();
2309    for entry in glob(&absolute).map_err(|error| {
2310        Error::Validation(format!("invalid workspace pattern {pattern}: {error}"))
2311    })? {
2312        let path = entry
2313            .map_err(|error| Error::Validation(format!("workspace pattern failed: {error}")))?;
2314        if path.is_dir() {
2315            if excluded {
2316                directories.remove(&path);
2317            } else {
2318                directories.insert(path);
2319            }
2320        }
2321    }
2322    Ok(())
2323}
2324
2325fn expand_workspace_patterns<'a>(
2326    root: &Path,
2327    patterns: impl IntoIterator<Item = &'a str>,
2328    directories: &mut BTreeSet<PathBuf>,
2329) -> Result<()> {
2330    let patterns = patterns.into_iter().collect::<Vec<_>>();
2331    for excluded in [false, true] {
2332        for pattern in &patterns {
2333            if pattern.starts_with('!') == excluded {
2334                expand_workspace_pattern(root, pattern, directories)?;
2335            }
2336        }
2337    }
2338    Ok(())
2339}
2340
2341fn add_manifests_in_directory(directory: &Path, paths: &mut BTreeSet<PathBuf>) -> Result<()> {
2342    for name in [
2343        "package.json",
2344        "Cargo.toml",
2345        "pubspec.yaml",
2346        "pyproject.toml",
2347        "go.mod",
2348        DEVCONTAINER_FEATURE_MANIFEST,
2349        DEVCONTAINER_TEMPLATE_MANIFEST,
2350    ] {
2351        let path = directory.join(name);
2352        if path.is_file() {
2353            paths.insert(path);
2354        }
2355    }
2356    for entry in std::fs::read_dir(directory).map_err(|error| Error::io(directory, error))? {
2357        let path = entry.map_err(|error| Error::io(directory, error))?.path();
2358        if path
2359            .extension()
2360            .is_some_and(|extension| extension == "csproj")
2361        {
2362            paths.insert(path);
2363        }
2364    }
2365    Ok(())
2366}
2367
2368fn all_manifest_paths(root: &Path) -> Result<BTreeSet<PathBuf>> {
2369    let mut paths = WalkDir::new(root)
2370        .into_iter()
2371        .filter_entry(|entry| !hard_excluded(root, entry.path()))
2372        .filter_map(std::result::Result::ok)
2373        .filter(|entry| entry.file_type().is_file() && is_discoverable_manifest(entry.path()))
2374        .map(|entry| entry.into_path())
2375        .collect();
2376    remove_git_ignored(root, &mut paths)?;
2377    Ok(paths)
2378}
2379
2380fn hard_excluded(root: &Path, path: &Path) -> bool {
2381    path.strip_prefix(root)
2382        .unwrap_or(path)
2383        .components()
2384        .filter_map(|component| match component {
2385            std::path::Component::Normal(value) => value.to_str(),
2386            _ => None,
2387        })
2388        .any(|component| {
2389            matches!(
2390                component,
2391                ".git"
2392                    | ".intentional"
2393                    | "node_modules"
2394                    | "target"
2395                    | ".pnpm-store"
2396                    | ".yarn"
2397                    | ".npm"
2398                    | ".cache"
2399                    | ".venv"
2400                    | "venv"
2401                    | "__pycache__"
2402                    | ".tox"
2403                    | ".nox"
2404                    | ".pytest_cache"
2405                    | ".mypy_cache"
2406                    | ".ruff_cache"
2407                    | ".dart_tool"
2408                    | ".pub-cache"
2409                    | "obj"
2410            )
2411        })
2412}
2413
2414fn remove_git_ignored(root: &Path, paths: &mut BTreeSet<PathBuf>) -> Result<()> {
2415    if paths.is_empty() {
2416        return Ok(());
2417    }
2418    let relative_paths = paths
2419        .iter()
2420        .map(|path| path.strip_prefix(root).unwrap_or(path).to_owned())
2421        .collect::<Vec<_>>();
2422    let mut input = Vec::new();
2423    for path in &relative_paths {
2424        input.extend_from_slice(path.to_string_lossy().as_bytes());
2425        input.push(0);
2426    }
2427    let mut child = Command::new("git")
2428        .args(["check-ignore", "--stdin", "-z"])
2429        .current_dir(root)
2430        .stdin(Stdio::piped())
2431        .stdout(Stdio::piped())
2432        .stderr(Stdio::piped())
2433        .spawn()
2434        .map_err(|error| Error::io(root, error))?;
2435    let mut stdin = child.stdin.take().expect("piped git check-ignore stdin");
2436    let writer = std::thread::spawn(move || stdin.write_all(&input));
2437    let output = child
2438        .wait_with_output()
2439        .map_err(|error| Error::io(root, error))?;
2440    writer
2441        .join()
2442        .map_err(|_| Error::Validation("git check-ignore input writer panicked".to_owned()))?
2443        .map_err(|error| Error::io(root, error))?;
2444    if !matches!(output.status.code(), Some(0 | 1)) {
2445        return Err(Error::Validation(format!(
2446            "git check-ignore failed: {}",
2447            String::from_utf8_lossy(&output.stderr).trim()
2448        )));
2449    }
2450    let ignored = output
2451        .stdout
2452        .split(|byte| *byte == 0)
2453        .filter(|path| !path.is_empty())
2454        .map(|path| PathBuf::from(String::from_utf8_lossy(path).into_owned()))
2455        .collect::<BTreeSet<_>>();
2456    paths.retain(|path| {
2457        let relative = path.strip_prefix(root).unwrap_or(path);
2458        !ignored.contains(relative)
2459    });
2460    Ok(())
2461}
2462
2463fn detector_for(adapter: Adapter) -> &'static str {
2464    match adapter {
2465        Adapter::Npm => "npm-package",
2466        Adapter::Cargo => "cargo-package",
2467        Adapter::Go => "go-module",
2468        Adapter::Python => "python-project",
2469        Adapter::Msbuild => "msbuild-project",
2470        Adapter::Pub => "dart-package",
2471        Adapter::Json | Adapter::Toml | Adapter::Yaml => {
2472            unreachable!("generic adapters are not discovered")
2473        }
2474    }
2475}
2476
2477fn devcontainer_detector_for(path: &Path) -> Option<&'static str> {
2478    match path.file_name()?.to_str()? {
2479        DEVCONTAINER_FEATURE_MANIFEST => Some("devcontainer-feature"),
2480        DEVCONTAINER_TEMPLATE_MANIFEST => Some("devcontainer-template"),
2481        _ => None,
2482    }
2483}
2484
2485fn is_discoverable_manifest(path: &Path) -> bool {
2486    adapter_for(path).is_some() || devcontainer_detector_for(path).is_some()
2487}
2488
2489fn devcontainer_candidate(root: &Path, path: &Path) -> Result<Option<DiscoveryCandidate>> {
2490    let Some(detector) = devcontainer_detector_for(path) else {
2491        return Ok(None);
2492    };
2493    let relative = path
2494        .strip_prefix(root)
2495        .map_err(|error| Error::Validation(format!("manifest is outside workspace: {error}")))?
2496        .to_owned();
2497    let bytes = std::fs::read(path).map_err(|error| Error::io(path, error))?;
2498    let source = SourceEvidence {
2499        path: relative.clone(),
2500        digest: sha256_digest(&bytes),
2501        lines: Vec::new(),
2502    };
2503    let mut diagnostics = Vec::new();
2504    let value = match std::str::from_utf8(&bytes) {
2505        Ok(text) => serde_json::from_str::<JsonValue>(text).map_err(|error| error.to_string()),
2506        Err(error) => Err(error.to_string()),
2507    };
2508    let (native_identity, raw_version, projection, tag) = match value {
2509        Ok(value) => {
2510            let native_identity = non_empty_json_string(&value["id"]);
2511            if native_identity.is_none() {
2512                diagnostics.push(extraction_diagnostic(
2513                    "identity-extraction",
2514                    "devcontainer-id-unreadable",
2515                    format!(
2516                        "{} has no non-empty string id; the detector inspects only id for identity.",
2517                        relative.display()
2518                    ),
2519                    &source,
2520                ));
2521            }
2522
2523            let version = non_empty_json_string(&value["version"]);
2524            if version.is_none() {
2525                diagnostics.push(extraction_diagnostic(
2526                    "version-extraction",
2527                    "devcontainer-version-unreadable",
2528                    format!(
2529                        "{} has no non-empty string version; the detector inspects only version for version evidence.",
2530                        relative.display()
2531                    ),
2532                    &source,
2533                ));
2534            }
2535            let valid_version = version.as_deref().is_some_and(|version| {
2536                if let Err(error) = Version::parse(version) {
2537                    diagnostics.push(extraction_diagnostic(
2538                        "version-semver",
2539                        "devcontainer-version-not-semver",
2540                        format!(
2541                            "{} version {version:?} is not Semantic Versioning 2.0.0: {error}.",
2542                            relative.display()
2543                        ),
2544                        &source,
2545                    ));
2546                    false
2547                } else {
2548                    true
2549                }
2550            });
2551            let raw_version = version.map(|value| RawVersionEvidence {
2552                value,
2553                evidence: vec![source.clone()],
2554            });
2555            let projection = valid_version.then(|| CandidateProjectionSuggestion {
2556                adapter: Adapter::Json,
2557                path: relative.clone(),
2558                mode: ProjectionMode::Committed,
2559                pointer: Some("/version".to_owned()),
2560            });
2561            let tag = native_identity.as_ref().map(|_| CandidateTagSuggestion {
2562                id: "primary".to_owned(),
2563                role: TagRole::Primary,
2564                template: "{id}@{version}".to_owned(),
2565            });
2566            (native_identity, raw_version, projection, tag)
2567        }
2568        Err(error) => {
2569            diagnostics.push(extraction_diagnostic(
2570                "manifest-extraction",
2571                "devcontainer-json-unreadable",
2572                format!(
2573                    "{} could not be read as JSON for id and version extraction: {error}.",
2574                    relative.display()
2575                ),
2576                &source,
2577            ));
2578            (None, None, None, None)
2579        }
2580    };
2581
2582    Ok(Some(DiscoveryCandidate {
2583        id: DiscoveryCandidate::stable_id(detector, &relative)?,
2584        detector: detector.to_owned(),
2585        path: relative,
2586        evidence: vec![source],
2587        native_identity,
2588        raw_version,
2589        projection,
2590        tag,
2591        diagnostics,
2592        resolution: None,
2593    }))
2594}
2595
2596fn non_empty_json_string(value: &JsonValue) -> Option<String> {
2597    value
2598        .as_str()
2599        .filter(|value| !value.is_empty())
2600        .map(str::to_owned)
2601}
2602
2603fn extraction_diagnostic(
2604    id: &str,
2605    code: &str,
2606    message: String,
2607    evidence: &SourceEvidence,
2608) -> ExtractionDiagnostic {
2609    ExtractionDiagnostic {
2610        id: id.to_owned(),
2611        code: code.to_owned(),
2612        message,
2613        evidence: vec![evidence.clone()],
2614    }
2615}
2616
2617fn adapter_for(path: &Path) -> Option<Adapter> {
2618    let name = path.file_name()?.to_str()?;
2619    match name {
2620        "package.json" => Some(Adapter::Npm),
2621        "Cargo.toml" => Some(Adapter::Cargo),
2622        "pubspec.yaml" => Some(Adapter::Pub),
2623        "pyproject.toml" => Some(Adapter::Python),
2624        "go.mod" => Some(Adapter::Go),
2625        name if name.ends_with(".csproj") => Some(Adapter::Msbuild),
2626        _ => None,
2627    }
2628}
2629
2630fn is_project_manifest(path: &Path, adapter: Adapter) -> Result<bool> {
2631    if adapter != Adapter::Cargo {
2632        return Ok(true);
2633    }
2634    let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2635    let document = text
2636        .parse::<toml_edit::DocumentMut>()
2637        .map_err(|error| Error::Validation(format!("invalid Cargo.toml: {error}")))?;
2638    Ok(document.get("package").is_some())
2639}
2640
2641fn manifest_identity(path: &Path, adapter: Adapter) -> Result<(String, Option<String>)> {
2642    let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2643    match adapter {
2644        Adapter::Npm => {
2645            let value: JsonValue = serde_json::from_str(&text).map_err(|error| {
2646                Error::Validation(format!("invalid {}: {error}", path.display()))
2647            })?;
2648            Ok((
2649                required_string(&value["name"], path, "name")?,
2650                value["version"].as_str().map(str::to_owned),
2651            ))
2652        }
2653        Adapter::Cargo => {
2654            let document = text.parse::<toml_edit::DocumentMut>().map_err(|error| {
2655                Error::Validation(format!("invalid {}: {error}", path.display()))
2656            })?;
2657            let package = document.get("package").ok_or_else(|| {
2658                Error::Validation(format!("{} has no package table", path.display()))
2659            })?;
2660            let name = package
2661                .get("name")
2662                .and_then(toml_edit::Item::as_str)
2663                .ok_or_else(|| {
2664                    Error::Validation(format!("{} has no package.name", path.display()))
2665                })?;
2666            let version = package
2667                .get("version")
2668                .and_then(toml_edit::Item::as_str)
2669                .map(str::to_owned);
2670            Ok((name.to_owned(), version))
2671        }
2672        Adapter::Pub => {
2673            let value: serde_yaml::Value = serde_yaml::from_str(&text)?;
2674            let name = value["name"]
2675                .as_str()
2676                .ok_or_else(|| Error::Validation(format!("{} has no name", path.display())))?;
2677            Ok((
2678                name.to_owned(),
2679                value["version"].as_str().map(str::to_owned),
2680            ))
2681        }
2682        Adapter::Python => {
2683            let document = text.parse::<toml_edit::DocumentMut>().map_err(|error| {
2684                Error::Validation(format!("invalid {}: {error}", path.display()))
2685            })?;
2686            let project = document.get("project").ok_or_else(|| {
2687                Error::Validation(format!("{} has no project table", path.display()))
2688            })?;
2689            let name = project
2690                .get("name")
2691                .and_then(toml_edit::Item::as_str)
2692                .ok_or_else(|| {
2693                    Error::Validation(format!("{} has no project.name", path.display()))
2694                })?;
2695            let version = project
2696                .get("version")
2697                .and_then(toml_edit::Item::as_str)
2698                .map(str::to_owned);
2699            Ok((name.to_owned(), version))
2700        }
2701        Adapter::Go => {
2702            let name = text
2703                .lines()
2704                .find_map(|line| line.trim().strip_prefix("module "))
2705                .ok_or_else(|| {
2706                    Error::Validation(format!("{} has no module directive", path.display()))
2707                })?;
2708            Ok((name.to_owned(), None))
2709        }
2710        Adapter::Msbuild => {
2711            let name = xml_element(&text, "PackageId")
2712                .or_else(|| xml_element(&text, "AssemblyName"))
2713                .or_else(|| {
2714                    path.file_stem()
2715                        .and_then(|value| value.to_str())
2716                        .map(str::to_owned)
2717                })
2718                .ok_or_else(|| {
2719                    Error::Validation(format!("{} has no package identity", path.display()))
2720                })?;
2721            Ok((name, xml_element(&text, "Version")))
2722        }
2723        Adapter::Json | Adapter::Toml | Adapter::Yaml => {
2724            unreachable!("generic adapters are not discovered")
2725        }
2726    }
2727}
2728
2729fn npm_manifest_is_private(path: &Path) -> Result<bool> {
2730    let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2731    let value: JsonValue = serde_json::from_str(&text)
2732        .map_err(|error| Error::Validation(format!("invalid {}: {error}", path.display())))?;
2733    Ok(value["private"].as_bool() == Some(true))
2734}
2735
2736fn required_string(value: &JsonValue, path: &Path, field: &str) -> Result<String> {
2737    value
2738        .as_str()
2739        .map(str::to_owned)
2740        .ok_or_else(|| Error::Validation(format!("{} has no string {field}", path.display())))
2741}
2742
2743fn xml_element(text: &str, name: &str) -> Option<String> {
2744    let start = format!("<{name}>");
2745    let end = format!("</{name}>");
2746    text.split_once(&start)?
2747        .1
2748        .split_once(&end)
2749        .map(|(value, _)| value.trim().to_owned())
2750}
2751
2752fn derive_npm_dependencies(root: &Path, config: &mut Config) -> Result<Vec<NpmDependencyEdge>> {
2753    let ids = config
2754        .release_units
2755        .keys()
2756        .cloned()
2757        .collect::<BTreeSet<_>>();
2758    let mut edges = Vec::new();
2759    for (id, release_unit) in &mut config.release_units {
2760        let npm_manifests = release_unit
2761            .projections
2762            .iter()
2763            .filter(|projection| projection.adapter == Adapter::Npm)
2764            .map(|projection| projection.file.workspace_path(&release_unit.path))
2765            .collect::<Vec<_>>();
2766        if npm_manifests.is_empty() {
2767            continue;
2768        }
2769        let mut dependencies = BTreeSet::new();
2770        for manifest in npm_manifests {
2771            let path = root.join(&manifest);
2772            let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
2773            let value: JsonValue = serde_json::from_str(&text).map_err(|error| {
2774                Error::Validation(format!("invalid {}: {error}", path.display()))
2775            })?;
2776            for (group, kind) in [
2777                ("dependencies", NpmDependencyKind::Dependency),
2778                ("optionalDependencies", NpmDependencyKind::Optional),
2779                ("peerDependencies", NpmDependencyKind::Peer),
2780                ("devDependencies", NpmDependencyKind::Dev),
2781            ] {
2782                if let Some(entries) = value[group].as_object() {
2783                    for (dependency, range) in entries {
2784                        if !ids.contains(dependency) {
2785                            continue;
2786                        }
2787                        if kind != NpmDependencyKind::Dev {
2788                            dependencies.insert(dependency.clone());
2789                        }
2790                        edges.push(NpmDependencyEdge {
2791                            dependent: id.clone(),
2792                            dependency: dependency.clone(),
2793                            kind,
2794                            range: range.as_str().unwrap_or_default().to_owned(),
2795                            manifest: manifest.clone(),
2796                        });
2797                    }
2798                }
2799            }
2800        }
2801        release_unit.depends_on = dependencies.into_iter().collect();
2802    }
2803    edges.sort_by(|left, right| {
2804        (
2805            &left.dependent,
2806            &left.dependency,
2807            left.kind as u8,
2808            &left.range,
2809            &left.manifest,
2810        )
2811            .cmp(&(
2812                &right.dependent,
2813                &right.dependency,
2814                right.kind as u8,
2815                &right.range,
2816                &right.manifest,
2817            ))
2818    });
2819    Ok(edges)
2820}
2821
2822fn retain_materialized_npm_dependencies(config: &mut Config, edges: &mut Vec<NpmDependencyEdge>) {
2823    edges.retain(|edge| {
2824        config
2825            .release_units
2826            .get(&edge.dependent)
2827            .is_some_and(|unit| {
2828                unit.projections.iter().any(|projection| {
2829                    projection.adapter == Adapter::Npm
2830                        && projection.file.workspace_path(&unit.path) == edge.manifest
2831                })
2832            })
2833            && config.release_units.contains_key(&edge.dependency)
2834    });
2835    for (id, unit) in &mut config.release_units {
2836        unit.depends_on = edges
2837            .iter()
2838            .filter(|edge| edge.dependent == *id && edge.kind != NpmDependencyKind::Dev)
2839            .map(|edge| edge.dependency.clone())
2840            .collect::<BTreeSet<_>>()
2841            .into_iter()
2842            .collect();
2843    }
2844}
2845
2846fn load_changesets_intents(root: &Path) -> Result<Vec<ConvertedIntent>> {
2847    let directory = root.join(".changeset");
2848    let mut paths = std::fs::read_dir(&directory)
2849        .map_err(|error| Error::io(&directory, error))?
2850        .filter_map(std::result::Result::ok)
2851        .map(|entry| entry.path())
2852        .filter(|path| path.extension().is_some_and(|extension| extension == "md"))
2853        .filter(|path| path.file_name().is_none_or(|name| name != "README.md"))
2854        .collect::<Vec<_>>();
2855    paths.sort();
2856    paths
2857        .into_iter()
2858        .map(|path| {
2859            let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
2860            let rest = text.strip_prefix("---\n").ok_or_else(|| {
2861                Error::Validation(format!("{} has no Changesets frontmatter", path.display()))
2862            })?;
2863            let (frontmatter, message) = rest.split_once("\n---\n").ok_or_else(|| {
2864                Error::Validation(format!(
2865                    "{} has unterminated Changesets frontmatter",
2866                    path.display()
2867                ))
2868            })?;
2869            let packages = serde_yaml::from_str(frontmatter)?;
2870            let id = path
2871                .file_stem()
2872                .and_then(|value| value.to_str())
2873                .ok_or_else(|| {
2874                    Error::Validation(format!("invalid Changesets filename {}", path.display()))
2875                })?
2876                .to_owned();
2877            Ok(ConvertedIntent {
2878                target: PathBuf::from(crate::intent::INTENTS_PATH).join(format!("{id}.md")),
2879                source: path.strip_prefix(root).unwrap_or(&path).to_owned(),
2880                id,
2881                release_units: packages,
2882                message: message.trim().to_owned(),
2883            })
2884        })
2885        .collect()
2886}
2887
2888fn parse_groups(value: &JsonValue) -> Result<Vec<Vec<String>>> {
2889    value
2890        .as_array()
2891        .into_iter()
2892        .flatten()
2893        .map(|group| {
2894            group
2895                .as_array()
2896                .ok_or_else(|| {
2897                    Error::Validation("Changesets release group must be an array".to_owned())
2898                })?
2899                .iter()
2900                .map(|member| {
2901                    member.as_str().map(str::to_owned).ok_or_else(|| {
2902                        Error::Validation("Changesets group member must be a string".to_owned())
2903                    })
2904                })
2905                .collect()
2906        })
2907        .collect()
2908}
2909
2910fn collect_json_strings(value: &JsonValue, output: &mut BTreeSet<String>) {
2911    match value {
2912        JsonValue::String(value) => {
2913            output.insert(value.clone());
2914        }
2915        JsonValue::Array(values) => {
2916            for value in values {
2917                collect_json_strings(value, output);
2918            }
2919        }
2920        _ => {}
2921    }
2922}
2923
2924fn changesets_private_package_settings(value: &JsonValue) -> Option<(bool, bool)> {
2925    match value {
2926        JsonValue::Bool(enabled) => Some((*enabled, *enabled)),
2927        JsonValue::Object(settings) => Some((
2928            settings
2929                .get("version")
2930                .and_then(JsonValue::as_bool)
2931                .unwrap_or(true),
2932            settings
2933                .get("tag")
2934                .and_then(JsonValue::as_bool)
2935                .unwrap_or(true),
2936        )),
2937        _ => None,
2938    }
2939}
2940
2941fn remap_groups(groups: &mut Vec<Vec<String>>, identity_map: &BTreeMap<String, String>) {
2942    for group in groups.iter_mut() {
2943        for release_unit in group.iter_mut() {
2944            if let Some(target) = identity_map.get(release_unit) {
2945                *release_unit = target.clone();
2946            }
2947        }
2948        group.sort();
2949        group.dedup();
2950    }
2951    groups.retain(|group| group.len() > 1);
2952}
2953
2954fn remap_converted_intents(
2955    intents: &mut [ConvertedIntent],
2956    identity_map: &BTreeMap<String, String>,
2957    config: &Config,
2958) -> Result<()> {
2959    for intent in intents {
2960        let mut release_units = BTreeMap::<String, Bump>::new();
2961        for (id, bump) in std::mem::take(&mut intent.release_units) {
2962            let logical_id = identity_map.get(&id).cloned().unwrap_or(id);
2963            if !config.release_units.contains_key(&logical_id) {
2964                return Err(Error::Validation(format!(
2965                    "Changesets intent {} references release unit {logical_id}, which has no Intentional release-unit identity",
2966                    intent.id
2967                )));
2968            }
2969            release_units
2970                .entry(logical_id)
2971                .and_modify(|existing| *existing = (*existing).max(bump))
2972                .or_insert(bump);
2973        }
2974        intent.release_units = release_units;
2975    }
2976    Ok(())
2977}
2978
2979fn scan_changesets_integrations(root: &Path) -> Result<Vec<SourceEvidence>> {
2980    let mut findings = Vec::new();
2981    for entry in WalkDir::new(root)
2982        .into_iter()
2983        .filter_entry(|entry| !hard_excluded(root, entry.path()))
2984        .filter_map(std::result::Result::ok)
2985        .filter(|entry| entry.file_type().is_file())
2986    {
2987        let path = entry.path();
2988        let relative = path.strip_prefix(root).unwrap_or(path);
2989        if relative.starts_with(".changeset") || relative.starts_with(".intentional") {
2990            continue;
2991        }
2992        let relevant = relative == Path::new("package.json")
2993            || relative == Path::new("pnpm-lock.yaml")
2994            || relative.starts_with(".github")
2995            || relative.starts_with("scripts")
2996            || relative
2997                .components()
2998                .any(|component| component.as_os_str().to_string_lossy().contains("test"));
2999        if !relevant {
3000            continue;
3001        }
3002        let Ok(text) = std::fs::read_to_string(path) else {
3003            continue;
3004        };
3005        let lines = text
3006            .lines()
3007            .enumerate()
3008            .filter(|(_, line)| line.to_ascii_lowercase().contains("changeset"))
3009            .map(|(index, _)| index + 1)
3010            .collect::<Vec<_>>();
3011        if !lines.is_empty() {
3012            findings.push(evidence(root, relative, lines)?);
3013        }
3014    }
3015    findings.sort();
3016    Ok(findings)
3017}
3018
3019fn scan_external_release_evidence(root: &Path) -> Result<Vec<SourceEvidence>> {
3020    let mut findings = Vec::new();
3021    for entry in WalkDir::new(root)
3022        .into_iter()
3023        .filter_entry(|entry| !hard_excluded(root, entry.path()))
3024        .filter_map(std::result::Result::ok)
3025        .filter(|entry| entry.file_type().is_file())
3026    {
3027        let path = entry.path();
3028        let relative = path.strip_prefix(root).unwrap_or(path);
3029        let is_release_file = relative.file_name().is_some_and(|name| {
3030            name.to_string_lossy()
3031                .to_ascii_lowercase()
3032                .contains("release")
3033        });
3034        if (relative.starts_with("scripts") || relative.starts_with(".github")) && is_release_file {
3035            findings.push(evidence(root, relative, Vec::new())?);
3036        }
3037    }
3038    findings.sort();
3039    Ok(findings)
3040}
3041
3042fn release_tool_proxy_diagnostics(
3043    root: &Path,
3044    candidates: &[DiscoveryCandidate],
3045    referenced_names: &BTreeSet<String>,
3046) -> Result<Vec<InitDiagnostic>> {
3047    let workspace_paths = workspace_manifest_paths(root)?;
3048    let mut diagnostics = Vec::new();
3049    for candidate in candidates {
3050        let Some(native_identity) = candidate.native_identity.as_deref() else {
3051            continue;
3052        };
3053        let Some(projection) = candidate.projection.as_ref() else {
3054            continue;
3055        };
3056        if projection.adapter != Adapter::Npm {
3057            continue;
3058        }
3059        let workspace_member = workspace_paths.contains(&root.join(&candidate.path));
3060        if !referenced_names.contains(native_identity) && !workspace_member {
3061            continue;
3062        }
3063        let Some(CandidateResolution::Projection { release_unit, .. }) =
3064            candidate.resolution.as_ref()
3065        else {
3066            continue;
3067        };
3068        if release_unit == native_identity {
3069            continue;
3070        }
3071        let target_candidates = candidates
3072            .iter()
3073            .filter(|target| target.id != candidate.id)
3074            .filter(|target| {
3075                target.projection.as_ref().is_some_and(|projection| {
3076                    projection.adapter != Adapter::Npm
3077                        && matches!(
3078                            target.resolution.as_ref(),
3079                            Some(CandidateResolution::Projection {
3080                                release_unit: target_release_unit,
3081                                ..
3082                            }) if target_release_unit == release_unit
3083                        )
3084                })
3085            })
3086            .collect::<Vec<_>>();
3087        if target_candidates.is_empty() {
3088            continue;
3089        }
3090
3091        let mut source_references = changesets_identity_evidence(root, native_identity)?;
3092        if source_references.is_empty() && workspace_member {
3093            source_references.push(evidence(root, Path::new(CHANGESETS_CONFIG), Vec::new())?);
3094            source_references.extend(workspace_declaration_evidence(root)?);
3095            source_references.sort();
3096            source_references.dedup();
3097        }
3098        if source_references.is_empty() {
3099            continue;
3100        }
3101        let (private, responsibilities) = npm_manifest_responsibilities(root, &candidate.path)?;
3102        let mut supporting_evidence = candidate.evidence.clone();
3103        supporting_evidence.extend(source_references);
3104        for target in &target_candidates {
3105            supporting_evidence.extend(target.evidence.clone());
3106        }
3107        supporting_evidence.sort();
3108        supporting_evidence.dedup();
3109
3110        let mut contradictory_evidence = Vec::new();
3111        if !private || !responsibilities.is_empty() {
3112            contradictory_evidence.extend(candidate.evidence.clone());
3113        }
3114        contradictory_evidence.extend(non_displaced_manifest_references(
3115            root,
3116            &candidate.path,
3117            native_identity,
3118        )?);
3119        contradictory_evidence.sort();
3120        contradictory_evidence.dedup();
3121
3122        let recommended =
3123            (private && responsibilities.is_empty() && contradictory_evidence.is_empty())
3124                .then(|| "remove".to_owned());
3125        let mut evidence = supporting_evidence.clone();
3126        evidence.extend(contradictory_evidence.clone());
3127        evidence.sort();
3128        evidence.dedup();
3129        let responsibility_message = if responsibilities.is_empty() {
3130            "no independent npm responsibility keys were observed".to_owned()
3131        } else {
3132            format!(
3133                "npm responsibility keys were observed: {}",
3134                responsibilities.join(", ")
3135            )
3136        };
3137        diagnostics.push(InitDiagnostic {
3138            id: format!("release-tool-proxy-disposition:{}", candidate.id),
3139            code: "release-tool-proxy-disposition".to_owned(),
3140            message: format!(
3141                "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}.",
3142                candidate.path.display()
3143            ),
3144            evidence,
3145            choices: vec!["retain".to_owned(), "remove".to_owned()],
3146            recommended,
3147            resolution: None,
3148            verified: false,
3149            invalidated_resolution: false,
3150            supporting_evidence,
3151            contradictory_evidence,
3152            uncertainty: Some(
3153                "This is a best-effort structural assessment, not proof that the manifest is disposable. Descriptions, comments, prose, and semantic keyword matches are not evidence."
3154                    .to_owned(),
3155            ),
3156        });
3157    }
3158    diagnostics.sort_by(|left, right| left.id.cmp(&right.id));
3159    Ok(diagnostics)
3160}
3161
3162fn npm_manifest_responsibilities(root: &Path, relative: &Path) -> Result<(bool, Vec<String>)> {
3163    let path = root.join(relative);
3164    let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
3165    let value: JsonValue = serde_json::from_str(&text)
3166        .map_err(|error| Error::Validation(format!("invalid package.json: {error}")))?;
3167    let object = value.as_object().ok_or_else(|| {
3168        Error::Validation(format!("{} must contain a JSON object", relative.display()))
3169    })?;
3170    let private = object
3171        .get("private")
3172        .and_then(JsonValue::as_bool)
3173        .unwrap_or(false);
3174    let responsibility_keys = [
3175        "bin",
3176        "browser",
3177        "bundledDependencies",
3178        "cpu",
3179        "dependencies",
3180        "devDependencies",
3181        "engines",
3182        "exports",
3183        "files",
3184        "main",
3185        "module",
3186        "optionalDependencies",
3187        "os",
3188        "peerDependencies",
3189        "publishConfig",
3190        "repository",
3191        "scripts",
3192        "types",
3193        "typings",
3194        "workspaces",
3195    ];
3196    let responsibilities = responsibility_keys
3197        .into_iter()
3198        .filter(|key| object.get(*key).is_some_and(json_value_has_content))
3199        .map(str::to_owned)
3200        .collect();
3201    Ok((private, responsibilities))
3202}
3203
3204fn json_value_has_content(value: &JsonValue) -> bool {
3205    match value {
3206        JsonValue::Null => false,
3207        JsonValue::Bool(value) => *value,
3208        JsonValue::Number(_) => true,
3209        JsonValue::String(value) => !value.is_empty(),
3210        JsonValue::Array(values) => !values.is_empty(),
3211        JsonValue::Object(values) => !values.is_empty(),
3212    }
3213}
3214
3215fn changesets_identity_evidence(root: &Path, identity: &str) -> Result<Vec<SourceEvidence>> {
3216    let mut findings = Vec::new();
3217    let config_path = PathBuf::from(CHANGESETS_CONFIG);
3218    let config_text = std::fs::read_to_string(root.join(&config_path))
3219        .map_err(|error| Error::io(root.join(&config_path), error))?;
3220    let config: JsonValue = serde_json::from_str(&config_text)
3221        .map_err(|error| Error::Validation(format!("invalid Changesets config: {error}")))?;
3222    if json_contains_exact_string(&config, identity) {
3223        findings.push(evidence(root, &config_path, Vec::new())?);
3224    }
3225    for entry in WalkDir::new(root.join(".changeset"))
3226        .min_depth(1)
3227        .max_depth(1)
3228        .into_iter()
3229        .filter_map(std::result::Result::ok)
3230        .filter(|entry| entry.file_type().is_file())
3231        .filter(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("md"))
3232        .filter(|entry| entry.file_name() != "README.md")
3233    {
3234        let text = std::fs::read_to_string(entry.path())
3235            .map_err(|error| Error::io(entry.path(), error))?;
3236        let Some(frontmatter) = text
3237            .strip_prefix("---\n")
3238            .and_then(|text| text.split_once("\n---"))
3239            .map(|(frontmatter, _)| frontmatter)
3240        else {
3241            continue;
3242        };
3243        let values: BTreeMap<String, Bump> = serde_yaml::from_str(frontmatter)?;
3244        if values.contains_key(identity) {
3245            let relative = entry
3246                .path()
3247                .strip_prefix(root)
3248                .unwrap_or(entry.path())
3249                .to_owned();
3250            findings.push(evidence(root, &relative, Vec::new())?);
3251        }
3252    }
3253    findings.sort();
3254    findings.dedup();
3255    Ok(findings)
3256}
3257
3258fn json_contains_exact_string(value: &JsonValue, expected: &str) -> bool {
3259    match value {
3260        JsonValue::String(value) => value == expected,
3261        JsonValue::Array(values) => values
3262            .iter()
3263            .any(|value| json_contains_exact_string(value, expected)),
3264        JsonValue::Object(values) => values
3265            .iter()
3266            .any(|(key, value)| key == expected || json_contains_exact_string(value, expected)),
3267        JsonValue::Null | JsonValue::Bool(_) | JsonValue::Number(_) => false,
3268    }
3269}
3270
3271fn workspace_declaration_evidence(root: &Path) -> Result<Vec<SourceEvidence>> {
3272    ["pnpm-workspace.yaml", "package.json", "Cargo.toml"]
3273        .into_iter()
3274        .map(PathBuf::from)
3275        .filter(|path| root.join(path).is_file())
3276        .map(|path| evidence(root, &path, Vec::new()))
3277        .collect()
3278}
3279
3280fn non_displaced_manifest_references(
3281    root: &Path,
3282    candidate_path: &Path,
3283    native_identity: &str,
3284) -> Result<Vec<SourceEvidence>> {
3285    let path_text = candidate_path.to_string_lossy().replace('\\', "/");
3286    let mut findings = Vec::new();
3287    let mut paths = WalkDir::new(root)
3288        .into_iter()
3289        .filter_entry(|entry| !hard_excluded(root, entry.path()))
3290        .filter_map(std::result::Result::ok)
3291        .filter(|entry| entry.file_type().is_file())
3292        .map(|entry| entry.into_path())
3293        .collect::<BTreeSet<_>>();
3294    remove_git_ignored(root, &mut paths)?;
3295    for path in paths {
3296        let relative = path.strip_prefix(root).unwrap_or(&path);
3297        if relative == candidate_path
3298            || relative.starts_with(".changeset")
3299            || relative.starts_with(".intentional")
3300            || is_prose_path(relative)
3301        {
3302            continue;
3303        }
3304        let Ok(text) = std::fs::read_to_string(&path) else {
3305            continue;
3306        };
3307        let lines = text
3308            .lines()
3309            .enumerate()
3310            .filter(|(_, line)| structural_line_references_path(line, &path_text))
3311            .map(|(index, _)| index + 1)
3312            .collect::<Vec<_>>();
3313        let has_dependency_reference = relative.file_name().and_then(|name| name.to_str())
3314            == Some("package.json")
3315            && serde_json::from_str::<JsonValue>(&text).is_ok_and(|value| {
3316                [
3317                    "dependencies",
3318                    "devDependencies",
3319                    "optionalDependencies",
3320                    "peerDependencies",
3321                ]
3322                .iter()
3323                .any(|key| {
3324                    value[*key]
3325                        .as_object()
3326                        .is_some_and(|dependencies| dependencies.contains_key(native_identity))
3327                })
3328            });
3329        if !lines.is_empty() || has_dependency_reference {
3330            findings.push(evidence(root, relative, lines)?);
3331        }
3332    }
3333    findings.sort();
3334    findings.dedup();
3335    Ok(findings)
3336}
3337
3338fn structural_line_references_path(line: &str, path: &str) -> bool {
3339    let normalized = line.replace('\\', "/");
3340    let Some(reference_offset) = normalized.find(path) else {
3341        return false;
3342    };
3343    let trimmed = normalized.trim_start();
3344    if trimmed.starts_with("//")
3345        || trimmed.starts_with('#')
3346        || trimmed.starts_with("/*")
3347        || trimmed.starts_with("<!--")
3348        || trimmed == "*"
3349        || trimmed.starts_with("* ")
3350    {
3351        return false;
3352    }
3353    let before_reference = &normalized[..reference_offset];
3354    if comment_starts_before_reference(before_reference) {
3355        return false;
3356    }
3357    let Some((field, _)) = trimmed.split_once(':') else {
3358        return true;
3359    };
3360    let field = field
3361        .trim()
3362        .trim_matches(|character| matches!(character, '"' | '\''))
3363        .to_ascii_lowercase();
3364    !matches!(
3365        field.as_str(),
3366        "$comment"
3367            | "comment"
3368            | "comments"
3369            | "description"
3370            | "message"
3371            | "note"
3372            | "notes"
3373            | "summary"
3374            | "title"
3375    )
3376}
3377
3378fn comment_starts_before_reference(line: &str) -> bool {
3379    let bytes = line.as_bytes();
3380    let mut quote = None;
3381    let mut escaped = false;
3382    let mut index = 0;
3383    while index < bytes.len() {
3384        let byte = bytes[index];
3385        if let Some(expected) = quote {
3386            if escaped {
3387                escaped = false;
3388            } else if byte == b'\\' {
3389                escaped = true;
3390            } else if byte == expected {
3391                quote = None;
3392            }
3393            index += 1;
3394            continue;
3395        }
3396        if matches!(byte, b'\'' | b'"') {
3397            quote = Some(byte);
3398            index += 1;
3399            continue;
3400        }
3401        let previous_is_boundary =
3402            index == 0 || bytes[index.saturating_sub(1)].is_ascii_whitespace();
3403        if byte == b'#' && previous_is_boundary {
3404            return true;
3405        }
3406        if bytes[index..].starts_with(b"<!--") {
3407            return true;
3408        }
3409        if bytes[index..].starts_with(b"/*")
3410            && bytes
3411                .get(index + 2)
3412                .is_none_or(|next| next.is_ascii_whitespace())
3413        {
3414            return true;
3415        }
3416        if bytes[index..].starts_with(b"//")
3417            && bytes
3418                .get(index + 2)
3419                .is_none_or(|next| next.is_ascii_whitespace())
3420        {
3421            return true;
3422        }
3423        index += 1;
3424    }
3425    false
3426}
3427
3428fn is_prose_path(path: &Path) -> bool {
3429    matches!(
3430        path.extension().and_then(|extension| extension.to_str()),
3431        Some("md" | "mdx" | "rst" | "txt")
3432    )
3433}
3434
3435fn reconcile_diagnostics(
3436    root: &Path,
3437    current: &mut Vec<InitDiagnostic>,
3438    previous: Option<&InitPlan>,
3439    candidates: &[DiscoveryCandidate],
3440) -> Result<()> {
3441    let Some(previous) = previous else {
3442        return Ok(());
3443    };
3444    let previous_by_id = previous
3445        .diagnostics
3446        .iter()
3447        .map(|diagnostic| (diagnostic.id.as_str(), diagnostic))
3448        .collect::<BTreeMap<_, _>>();
3449    for diagnostic in current.iter_mut() {
3450        let Some(old) = previous_by_id.get(diagnostic.id.as_str()) else {
3451            continue;
3452        };
3453        if old.evidence == diagnostic.evidence {
3454            diagnostic.resolution = old.resolution.clone();
3455            if !diagnostic.choices.is_empty() {
3456                diagnostic.verified = match diagnostic.code.as_str() {
3457                    "repository-integration" => false,
3458                    "release-tool-proxy-disposition" => {
3459                        let candidate = candidates.iter().find(|candidate| {
3460                            diagnostic.id
3461                                == format!("release-tool-proxy-disposition:{}", candidate.id)
3462                        });
3463                        match (diagnostic.resolution.as_deref(), candidate) {
3464                            (Some("retain"), Some(_)) => true,
3465                            (Some("remove"), Some(candidate)) => non_displaced_manifest_references(
3466                                root,
3467                                &candidate.path,
3468                                candidate.native_identity.as_deref().unwrap_or_default(),
3469                            )?
3470                            .is_empty(),
3471                            _ => false,
3472                        }
3473                    }
3474                    _ => diagnostic.resolution.is_some(),
3475                };
3476            }
3477        } else if old.resolution.is_some() {
3478            diagnostic.invalidated_resolution = true;
3479        }
3480    }
3481    let current_ids = current
3482        .iter()
3483        .map(|diagnostic| diagnostic.id.clone())
3484        .collect::<BTreeSet<_>>();
3485    for old in &previous.diagnostics {
3486        if old.code != "repository-integration"
3487            || current_ids.contains(&old.id)
3488            || old.resolution.as_deref() != Some("removed")
3489        {
3490            continue;
3491        }
3492        let Some(path) = old.evidence.first().map(|evidence| evidence.path.clone()) else {
3493            continue;
3494        };
3495        let full = root.join(&path);
3496        if !full.is_file() {
3497            continue;
3498        }
3499        let text = std::fs::read_to_string(&full).map_err(|error| Error::io(&full, error))?;
3500        if text.to_ascii_lowercase().contains("changeset") {
3501            continue;
3502        }
3503        current.push(InitDiagnostic {
3504            id: old.id.clone(),
3505            code: old.code.clone(),
3506            message: old.message.clone(),
3507            evidence: vec![evidence(root, &path, Vec::new())?],
3508            choices: old.choices.clone(),
3509            recommended: old.recommended.clone(),
3510            resolution: old.resolution.clone(),
3511            verified: true,
3512            invalidated_resolution: false,
3513            supporting_evidence: old.supporting_evidence.clone(),
3514            contradictory_evidence: old.contradictory_evidence.clone(),
3515            uncertainty: old.uncertainty.clone(),
3516        });
3517    }
3518    current.sort_by(|left, right| left.id.cmp(&right.id));
3519    Ok(())
3520}
3521
3522fn apply_disposition_resolutions(
3523    config: &mut Config,
3524    diagnostics: &[InitDiagnostic],
3525) -> Result<()> {
3526    let mut excluded = BTreeSet::new();
3527    for diagnostic in diagnostics.iter().filter(|diagnostic| {
3528        diagnostic.code == "ignored-release-unit-disposition"
3529            || diagnostic.code == "unmapped-release-unit-disposition"
3530    }) {
3531        let release_unit = diagnostic
3532            .id
3533            .split_once(':')
3534            .map(|(_, release_unit)| release_unit)
3535            .expect("diagnostic id prefix");
3536        match diagnostic.resolution.as_deref() {
3537            Some("suspended") => {
3538                if let Some(config) = config.release_units.get_mut(release_unit) {
3539                    config.disposition = ReleaseUnitDisposition::Suspended;
3540                }
3541            }
3542            Some("excluded") => {
3543                config.release_units.remove(release_unit);
3544                excluded.insert(release_unit.to_owned());
3545            }
3546            Some("managed") | None => {}
3547            Some(value) => {
3548                return Err(Error::Validation(format!(
3549                    "invalid resolution {value} for {}",
3550                    diagnostic.id
3551                )))
3552            }
3553        }
3554    }
3555    exclude_release_units(config, &excluded);
3556    Ok(())
3557}
3558
3559fn apply_proxy_manifest_resolutions(
3560    config: &mut Config,
3561    diagnostics: &[InitDiagnostic],
3562    candidates: &[DiscoveryCandidate],
3563) -> Result<Vec<ProxyRemoval>> {
3564    let mut removals = Vec::new();
3565    for diagnostic in diagnostics
3566        .iter()
3567        .filter(|diagnostic| diagnostic.code == "release-tool-proxy-disposition")
3568    {
3569        let candidate = candidates
3570            .iter()
3571            .find(|candidate| {
3572                diagnostic.id == format!("release-tool-proxy-disposition:{}", candidate.id)
3573            })
3574            .ok_or_else(|| {
3575                Error::Validation(format!(
3576                    "{} does not identify a current discovery candidate",
3577                    diagnostic.id
3578                ))
3579            })?;
3580        match diagnostic.resolution.as_deref() {
3581            None | Some("retain") => {}
3582            Some("remove") => {
3583                let Some(CandidateResolution::Projection { release_unit, .. }) =
3584                    candidate.resolution.as_ref()
3585                else {
3586                    return Err(Error::Validation(format!(
3587                        "{} can remove only a candidate resolved as a projection",
3588                        diagnostic.id
3589                    )));
3590                };
3591                let unit = config.release_units.get_mut(release_unit).ok_or_else(|| {
3592                    Error::Validation(format!(
3593                        "{} targets absent release unit {release_unit}",
3594                        diagnostic.id
3595                    ))
3596                })?;
3597                let unit_path = unit.path.clone();
3598                unit.projections.retain(|projection| {
3599                    projection.file.workspace_path(&unit_path) != candidate.path
3600                });
3601                config.discovery.managed_paths.retain(|receipt| {
3602                    receipt.detector != candidate.detector || receipt.path != candidate.path
3603                });
3604                removals.push(ProxyRemoval {
3605                    path: candidate.path.clone(),
3606                    native_identity: candidate
3607                        .native_identity
3608                        .clone()
3609                        .expect("proxy diagnostic requires native identity"),
3610                });
3611            }
3612            Some(value) => {
3613                return Err(Error::Validation(format!(
3614                    "invalid resolution {value} for {}",
3615                    diagnostic.id
3616                )));
3617            }
3618        }
3619    }
3620    removals.sort_by(|left, right| left.path.cmp(&right.path));
3621    Ok(removals)
3622}
3623
3624fn exclude_release_units(config: &mut Config, excluded: &BTreeSet<String>) {
3625    for id in excluded {
3626        config.release_units.remove(id);
3627    }
3628    if excluded.is_empty() {
3629        return;
3630    }
3631    for release_unit in config.release_units.values_mut() {
3632        release_unit.depends_on.retain(|id| !excluded.contains(id));
3633    }
3634    for groups in [&mut config.fixed, &mut config.linked] {
3635        for group in groups.iter_mut() {
3636            group.retain(|id| !excluded.contains(id));
3637        }
3638        groups.retain(|group| group.len() >= 2);
3639    }
3640}
3641
3642fn parity_result(
3643    source_config: &Config,
3644    proposed_config: &Config,
3645    current: &BTreeMap<String, Version>,
3646    declared: &BTreeMap<String, Bump>,
3647    source_semantics: &ChangesetsSourceSemantics<'_>,
3648) -> Result<ParityComputation> {
3649    let mut source_config = source_config.clone();
3650    let mut proposed_config = proposed_config.clone();
3651    let effective = effective_bumps(&proposed_config, declared);
3652    let non_releasing_without_versions = proposed_config
3653        .release_units
3654        .keys()
3655        .filter(|id| !current.contains_key(*id) && effective[*id] == Bump::None)
3656        .cloned()
3657        .collect::<BTreeSet<_>>();
3658    exclude_release_units(&mut source_config, &non_releasing_without_versions);
3659    exclude_release_units(&mut proposed_config, &non_releasing_without_versions);
3660    let excluded_pending = declared
3661        .keys()
3662        .filter(|id| !proposed_config.release_units.contains_key(*id))
3663        .cloned()
3664        .collect::<Vec<_>>();
3665    let proposed_preflight_error = (!excluded_pending.is_empty()).then(|| {
3666        format!(
3667            "converted intents reference release units excluded from the proposed inventory: {}",
3668            excluded_pending.join(", ")
3669        )
3670    });
3671    if source_semantics.preflight_error.is_some() || proposed_preflight_error.is_some() {
3672        return Ok(ParityComputation {
3673            result: ParityResult {
3674                status: "blocked".to_owned(),
3675                release_units: Vec::new(),
3676            },
3677            source_error: source_semantics.preflight_error.clone(),
3678            proposed_error: proposed_preflight_error,
3679        });
3680    }
3681    let missing = source_config
3682        .release_units
3683        .keys()
3684        .chain(proposed_config.release_units.keys())
3685        .filter(|id| !current.contains_key(*id))
3686        .cloned()
3687        .collect::<BTreeSet<_>>();
3688    if !missing.is_empty() {
3689        return Ok(ParityComputation {
3690            result: ParityResult {
3691                status: "blocked".to_owned(),
3692                release_units: Vec::new(),
3693            },
3694            source_error: None,
3695            proposed_error: Some(format!(
3696                "missing current versions for release units: {}",
3697                missing.into_iter().collect::<Vec<_>>().join(", ")
3698            )),
3699        });
3700    }
3701    let (source, source_error) =
3702        match resolve_changesets_source(&source_config, declared, current, source_semantics) {
3703            Ok(resolved) => (resolved, None),
3704            Err(Error::Validation(message)) => (BTreeMap::new(), Some(message)),
3705            Err(error) => return Err(error),
3706        };
3707    let (proposed, proposed_error) = match resolve_versions(&proposed_config, declared, current) {
3708        Ok(resolved) => (resolved, None),
3709        Err(Error::Validation(message)) => (BTreeMap::new(), Some(message)),
3710        Err(error) => return Err(error),
3711    };
3712    let release_ids = source
3713        .iter()
3714        .chain(&proposed)
3715        .filter(|(_, versions)| versions.bump != Bump::None)
3716        .map(|(id, _)| id.clone())
3717        .collect::<BTreeSet<_>>();
3718    let release_units = release_ids
3719        .into_iter()
3720        .map(|release_unit| ParityReleaseUnit {
3721            current_version: current[&release_unit].to_string(),
3722            source: parity_release(source.get(&release_unit)),
3723            proposed: parity_release(proposed.get(&release_unit)),
3724            release_unit,
3725        })
3726        .collect::<Vec<_>>();
3727    let equivalent = source_error.is_none()
3728        && proposed_error.is_none()
3729        && release_units
3730            .iter()
3731            .all(|release_unit| release_unit.source == release_unit.proposed);
3732    Ok(ParityComputation {
3733        result: ParityResult {
3734            status: if equivalent { "equivalent" } else { "blocked" }.to_owned(),
3735            release_units,
3736        },
3737        source_error,
3738        proposed_error,
3739    })
3740}
3741
3742fn mixed_skipped_changeset(
3743    intents: &[ConvertedIntent],
3744    skipped_release_units: &BTreeSet<String>,
3745) -> Option<String> {
3746    intents.iter().find_map(|intent| {
3747        let has_skipped = intent
3748            .release_units
3749            .keys()
3750            .any(|id| skipped_release_units.contains(id));
3751        let has_managed = intent
3752            .release_units
3753            .keys()
3754            .any(|id| !skipped_release_units.contains(id));
3755        (has_skipped && has_managed).then(|| {
3756            format!(
3757                "Changesets intent {} mixes skipped and managed release units; split or revise the source changeset before takeover",
3758                intent.id
3759            )
3760        })
3761    })
3762}
3763
3764struct ParityComputation {
3765    result: ParityResult,
3766    source_error: Option<String>,
3767    proposed_error: Option<String>,
3768}
3769
3770fn parity_release(version: Option<&ReleaseUnitVersion>) -> Option<ParityRelease> {
3771    version
3772        .filter(|version| version.bump != Bump::None)
3773        .map(|version| ParityRelease {
3774            requested_bump: version.bump,
3775            next_version: version.next.to_string(),
3776        })
3777}
3778
3779/// Compute the source Changesets result without invoking Intentional's resolver.
3780///
3781/// This deliberately duplicates the small release-propagation algorithm so the
3782/// takeover gate compares two computations rather than comparing a result with
3783/// itself.
3784fn resolve_changesets_source(
3785    config: &Config,
3786    declared: &BTreeMap<String, Bump>,
3787    current: &BTreeMap<String, Version>,
3788    semantics: &ChangesetsSourceSemantics<'_>,
3789) -> Result<BTreeMap<String, ReleaseUnitVersion>> {
3790    let mut effective = config
3791        .release_units
3792        .keys()
3793        .map(|id| {
3794            let suppressed =
3795                semantics.suppress_private_versions && semantics.private_packages.contains(id);
3796            (
3797                id.clone(),
3798                if suppressed {
3799                    Bump::None
3800                } else {
3801                    declared.get(id).copied().unwrap_or(Bump::None)
3802                },
3803            )
3804        })
3805        .collect::<BTreeMap<_, _>>();
3806    loop {
3807        let before = effective.clone();
3808        for edge in semantics.npm_dependencies {
3809            if !config.release_units.contains_key(&edge.dependent)
3810                || !config.release_units.contains_key(&edge.dependency)
3811                || (semantics.suppress_private_versions
3812                    && semantics.private_packages.contains(&edge.dependent))
3813            {
3814                continue;
3815            }
3816            let dependency_bump = effective[&edge.dependency];
3817            if dependency_bump == Bump::None {
3818                continue;
3819            }
3820            let next = changesets_dependency_next(config, &edge.dependency, &effective, current);
3821            let in_range = npm_range_satisfies(&edge.range, &current[&edge.dependency], &next)?;
3822            let required = if edge.kind == NpmDependencyKind::Peer
3823                && dependency_bump >= Bump::Minor
3824                && (!semantics.only_update_peer_dependents_when_out_of_range || !in_range)
3825            {
3826                Bump::Major
3827            } else if semantics.update_internal_dependents_always || !in_range {
3828                semantics.internal_dependency_bump
3829            } else {
3830                Bump::None
3831            };
3832            if required != Bump::None {
3833                effective
3834                    .entry(edge.dependent.clone())
3835                    .and_modify(|bump| *bump = (*bump).max(required));
3836            }
3837        }
3838        for group in &config.fixed {
3839            let bump = group
3840                .iter()
3841                .map(|id| effective[id])
3842                .max()
3843                .unwrap_or_default();
3844            if bump != Bump::None {
3845                for id in group {
3846                    if !semantics.suppress_private_versions
3847                        || !semantics.private_packages.contains(id)
3848                    {
3849                        effective.insert(id.clone(), bump);
3850                    }
3851                }
3852            }
3853        }
3854        for group in &config.linked {
3855            let bump = group
3856                .iter()
3857                .map(|id| effective[id])
3858                .max()
3859                .unwrap_or_default();
3860            if bump != Bump::None {
3861                for id in group {
3862                    if effective[id] != Bump::None {
3863                        effective.insert(id.clone(), bump);
3864                    }
3865                }
3866            }
3867        }
3868        if effective == before {
3869            break;
3870        }
3871    }
3872
3873    let mut resolved = config
3874        .release_units
3875        .keys()
3876        .map(|id| {
3877            let current_version = current.get(id).ok_or_else(|| {
3878                Error::Validation(format!("missing current version for release unit {id}"))
3879            })?;
3880            Ok((
3881                id.clone(),
3882                ReleaseUnitVersion::new_with_mapping(
3883                    current_version.clone(),
3884                    effective[id],
3885                    Pre1BumpMapping::Component,
3886                ),
3887            ))
3888        })
3889        .collect::<Result<BTreeMap<_, _>>>()?;
3890    for (fixed, groups) in [(true, &config.fixed), (false, &config.linked)] {
3891        for group in groups {
3892            let highest_current = group
3893                .iter()
3894                .map(|id| &current[id])
3895                .max()
3896                .expect("validated release group")
3897                .clone();
3898            let highest_bump = group
3899                .iter()
3900                .map(|id| effective[id])
3901                .max()
3902                .unwrap_or_default();
3903            let next = bump_version_with_mapping(
3904                &highest_current,
3905                highest_bump,
3906                Pre1BumpMapping::Component,
3907            );
3908            for id in group {
3909                let suppressed =
3910                    semantics.suppress_private_versions && semantics.private_packages.contains(id);
3911                let releases = !suppressed
3912                    && highest_bump != Bump::None
3913                    && (fixed || effective[id] != Bump::None);
3914                resolved.insert(
3915                    id.clone(),
3916                    ReleaseUnitVersion {
3917                        current: current[id].clone(),
3918                        next: if releases {
3919                            next.clone()
3920                        } else {
3921                            current[id].clone()
3922                        },
3923                        bump: if releases { highest_bump } else { Bump::None },
3924                    },
3925                );
3926            }
3927        }
3928    }
3929    Ok(resolved)
3930}
3931
3932fn changesets_dependency_next(
3933    config: &Config,
3934    id: &str,
3935    effective: &BTreeMap<String, Bump>,
3936    current: &BTreeMap<String, Version>,
3937) -> Version {
3938    for group in config.fixed.iter().chain(&config.linked) {
3939        if group.iter().any(|member| member == id) && effective[id] != Bump::None {
3940            let highest_current = group
3941                .iter()
3942                .map(|member| &current[member])
3943                .max()
3944                .expect("validated group");
3945            let highest_bump = group
3946                .iter()
3947                .map(|member| effective[member])
3948                .max()
3949                .unwrap_or_default();
3950            return bump_version_with_mapping(
3951                highest_current,
3952                highest_bump,
3953                Pre1BumpMapping::Component,
3954            );
3955        }
3956    }
3957    bump_version_with_mapping(&current[id], effective[id], Pre1BumpMapping::Component)
3958}
3959
3960fn npm_range_satisfies(range: &str, current: &Version, next: &Version) -> Result<bool> {
3961    let normalized = match range.strip_prefix("workspace:") {
3962        Some("*") => current.to_string(),
3963        Some("^") => format!("^{current}"),
3964        Some("~") => format!("~{current}"),
3965        Some(range) => range.to_owned(),
3966        None => range.to_owned(),
3967    };
3968    let Ok(range) = NodeRange::parse(&normalized) else {
3969        return Ok(false);
3970    };
3971    let next = NodeVersion::parse(next.to_string())
3972        .map_err(|error| Error::Validation(format!("invalid next npm version: {error}")))?;
3973    Ok(range.satisfies(&next))
3974}
3975
3976fn load_previous_plan(root: &Path) -> Result<Option<InitPlan>> {
3977    let path = root.join(INIT_PLAN_PATH);
3978    match std::fs::read_to_string(&path) {
3979        Ok(text) => {
3980            let plan: InitPlan = serde_yaml::from_str(&text)?;
3981            plan.validate()?;
3982            Ok(Some(plan))
3983        }
3984        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
3985        Err(error) => Err(Error::io(path, error)),
3986    }
3987}
3988
3989fn evidence(root: &Path, relative: &Path, lines: Vec<usize>) -> Result<SourceEvidence> {
3990    let path = root.join(relative);
3991    let bytes = std::fs::read(&path).map_err(|error| Error::io(&path, error))?;
3992    Ok(SourceEvidence {
3993        path: relative.to_owned(),
3994        digest: sha256_digest(&bytes),
3995        lines,
3996    })
3997}
3998
3999fn fingerprint(root: &Path, paths: &BTreeSet<PathBuf>) -> Result<String> {
4000    let mut source = BTreeMap::new();
4001    for path in paths {
4002        if root.join(path).is_file() {
4003            source.insert(
4004                path.to_string_lossy().to_string(),
4005                evidence(root, path, Vec::new())?.digest,
4006            );
4007        }
4008    }
4009    for path in ["package.json", "pnpm-workspace.yaml", "Cargo.toml"] {
4010        let path = PathBuf::from(path);
4011        if root.join(&path).is_file() {
4012            source.insert(
4013                path.to_string_lossy().to_string(),
4014                evidence(root, &path, Vec::new())?.digest,
4015            );
4016        }
4017    }
4018    for entry in WalkDir::new(root.join(".changeset"))
4019        .into_iter()
4020        .filter_map(std::result::Result::ok)
4021        .filter(|entry| entry.file_type().is_file())
4022    {
4023        let path = entry
4024            .path()
4025            .strip_prefix(root)
4026            .unwrap_or(entry.path())
4027            .to_owned();
4028        source.insert(
4029            path.to_string_lossy().to_string(),
4030            evidence(root, &path, Vec::new())?.digest,
4031        );
4032    }
4033    Ok(sha256_digest(canonical_json(&source)?.as_bytes()))
4034}
4035
4036fn verify_takeover_preconditions(
4037    root: &Path,
4038    writes: &[(PathBuf, String)],
4039    expected_fingerprint: &str,
4040    evidence_paths: &BTreeSet<PathBuf>,
4041) -> Result<()> {
4042    let actual_fingerprint = fingerprint(root, evidence_paths)?;
4043    if actual_fingerprint != expected_fingerprint {
4044        return Err(Error::Validation(
4045            "initialization plan source evidence became stale; rerun intentional init".to_owned(),
4046        ));
4047    }
4048    for (relative, _) in writes {
4049        if root.join(relative).exists() {
4050            return Err(Error::Validation(format!(
4051                "takeover target {} already exists; resolve the competing Intentional state first",
4052                relative.display()
4053            )));
4054        }
4055    }
4056    Ok(())
4057}
4058
4059fn takeover_operations(
4060    root: &Path,
4061    intents: &[ConvertedIntent],
4062    proxy_removals: &[ProxyRemoval],
4063) -> Vec<String> {
4064    let mut operations = vec![format!("write {CONFIG_PATH}")];
4065    operations.extend(intents.iter().map(|intent| {
4066        format!(
4067            "write {} from {}",
4068            intent.target.display(),
4069            intent.source.display()
4070        )
4071    }));
4072    operations.extend(
4073        takeover_deletes(root, proxy_removals)
4074            .iter()
4075            .map(|path| format!("delete {}", path.display())),
4076    );
4077    operations.push("after commit: intentional tag --baseline".to_owned());
4078    operations
4079}
4080
4081fn takeover_deletes(root: &Path, proxy_removals: &[ProxyRemoval]) -> Vec<PathBuf> {
4082    let mut paths = WalkDir::new(root.join(".changeset"))
4083        .into_iter()
4084        .filter_map(std::result::Result::ok)
4085        .filter(|entry| entry.file_type().is_file())
4086        .map(|entry| {
4087            entry
4088                .path()
4089                .strip_prefix(root)
4090                .unwrap_or(entry.path())
4091                .to_owned()
4092        })
4093        .collect::<Vec<_>>();
4094    paths.push(PathBuf::from(INIT_PLAN_PATH));
4095    paths.extend(proxy_removals.iter().map(|removal| removal.path.clone()));
4096    paths.sort();
4097    paths.dedup();
4098    paths
4099}
4100
4101fn verify_proxy_removal_preconditions(root: &Path, removals: &[ProxyRemoval]) -> Result<()> {
4102    for removal in removals {
4103        let references =
4104            non_displaced_manifest_references(root, &removal.path, &removal.native_identity)?;
4105        if !references.is_empty() {
4106            return Err(Error::Validation(format!(
4107                "cannot remove probable release-tool proxy {} while non-source-tool repository references remain in {}",
4108                removal.path.display(),
4109                references
4110                    .iter()
4111                    .map(|item| item.path.display().to_string())
4112                    .collect::<Vec<_>>()
4113                    .join(", ")
4114            )));
4115        }
4116    }
4117    Ok(())
4118}
4119
4120fn apply_takeover_transaction(
4121    root: &Path,
4122    writes: &[(PathBuf, String)],
4123    deletes: &[PathBuf],
4124) -> Result<()> {
4125    let transaction = root.join(TRANSACTION_PATH);
4126    if transaction.exists() {
4127        recover_interrupted_takeover(root)?;
4128    }
4129    let setup = (|| {
4130        std::fs::create_dir_all(transaction.join("original"))
4131            .map_err(|error| Error::io(&transaction, error))?;
4132        let affected = writes
4133            .iter()
4134            .map(|(path, _)| path.clone())
4135            .chain(deletes.iter().cloned())
4136            .collect::<BTreeSet<_>>();
4137        for relative in &affected {
4138            let source = root.join(relative);
4139            if source.is_file() {
4140                let backup = transaction.join("original").join(relative);
4141                if let Some(parent) = backup.parent() {
4142                    std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4143                }
4144                std::fs::copy(&source, &backup).map_err(|error| Error::io(&source, error))?;
4145            }
4146        }
4147        let manifest = serde_yaml::to_string(
4148            &affected
4149                .iter()
4150                .map(|path| path.to_string_lossy().to_string())
4151                .collect::<Vec<_>>(),
4152        )?;
4153        let manifest_path = transaction.join("manifest.yml");
4154        let manifest_temporary = transaction.join("manifest-tmp.yml");
4155        std::fs::write(&manifest_temporary, manifest)
4156            .map_err(|error| Error::io(&manifest_temporary, error))?;
4157        std::fs::rename(&manifest_temporary, &manifest_path)
4158            .map_err(|error| Error::io(&manifest_path, error))?;
4159        Ok::<(), Error>(())
4160    })();
4161    match setup {
4162        Ok(()) => {}
4163        Err(error) => {
4164            if transaction.exists() {
4165                std::fs::remove_dir_all(&transaction)
4166                    .map_err(|cleanup| Error::io(&transaction, cleanup))?;
4167            }
4168            return Err(error);
4169        }
4170    }
4171    let result = (|| {
4172        for (relative, contents) in writes {
4173            let path = root.join(relative);
4174            if let Some(parent) = path.parent() {
4175                std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4176            }
4177            let temporary = transaction.join("staged").join(relative);
4178            if let Some(parent) = temporary.parent() {
4179                std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4180            }
4181            std::fs::write(&temporary, contents).map_err(|error| Error::io(&temporary, error))?;
4182            std::fs::rename(&temporary, &path).map_err(|error| Error::io(&path, error))?;
4183        }
4184        for relative in deletes {
4185            let path = root.join(relative);
4186            if path.is_file() {
4187                std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
4188            }
4189        }
4190        let changeset_directory = root.join(".changeset");
4191        if changeset_directory.is_dir()
4192            && std::fs::read_dir(&changeset_directory)
4193                .map_err(|error| Error::io(&changeset_directory, error))?
4194                .next()
4195                .is_none()
4196        {
4197            std::fs::remove_dir(&changeset_directory)
4198                .map_err(|error| Error::io(&changeset_directory, error))?;
4199        }
4200        Ok(())
4201    })();
4202    if result.is_err() {
4203        recover_interrupted_takeover(root)?;
4204        return result;
4205    }
4206    if let Err(error) = write_transaction_state(root, "committed") {
4207        recover_interrupted_takeover(root)?;
4208        return Err(error);
4209    }
4210    finish_transaction_cleanup(root)
4211}
4212
4213fn recover_interrupted_takeover(root: &Path) -> Result<()> {
4214    let transaction = root.join(TRANSACTION_PATH);
4215    let state_path = root.join(TRANSACTION_STATE_PATH);
4216    if state_path.is_file() {
4217        return finish_transaction_cleanup(root);
4218    }
4219    if !transaction.exists() {
4220        return Ok(());
4221    }
4222    let manifest_path = transaction.join("manifest.yml");
4223    if !manifest_path.is_file() {
4224        return std::fs::remove_dir_all(&transaction)
4225            .map_err(|error| Error::io(&transaction, error));
4226    }
4227    let manifest = std::fs::read_to_string(&manifest_path)
4228        .map_err(|error| Error::io(&manifest_path, error))?;
4229    let affected: Vec<String> = serde_yaml::from_str(&manifest)?;
4230    for relative in affected {
4231        let relative = PathBuf::from(relative);
4232        let target = root.join(&relative);
4233        let backup = transaction.join("original").join(&relative);
4234        if backup.is_file() {
4235            if let Some(parent) = target.parent() {
4236                std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
4237            }
4238            std::fs::copy(&backup, &target).map_err(|error| Error::io(&target, error))?;
4239        } else if target.is_file() {
4240            std::fs::remove_file(&target).map_err(|error| Error::io(&target, error))?;
4241        }
4242    }
4243    write_transaction_state(root, "rolled-back")?;
4244    finish_transaction_cleanup(root)
4245}
4246
4247fn write_transaction_state(root: &Path, state: &str) -> Result<()> {
4248    let path = root.join(TRANSACTION_STATE_PATH);
4249    let temporary = root.join(".intentional/.takeover-state-tmp");
4250    std::fs::write(&temporary, state).map_err(|error| Error::io(&temporary, error))?;
4251    std::fs::rename(&temporary, &path).map_err(|error| Error::io(&path, error))
4252}
4253
4254fn finish_transaction_cleanup(root: &Path) -> Result<()> {
4255    let transaction = root.join(TRANSACTION_PATH);
4256    if transaction.exists() {
4257        std::fs::remove_dir_all(&transaction).map_err(|error| Error::io(&transaction, error))?;
4258    }
4259    for path in [
4260        root.join(TRANSACTION_STATE_PATH),
4261        root.join(".intentional/.takeover-state-tmp"),
4262    ] {
4263        if path.is_file() {
4264            std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
4265        }
4266    }
4267    Ok(())
4268}
4269
4270fn annotate_choice_lines(yaml: &str) -> String {
4271    let comments = BTreeMap::from([
4272        ("suspended", "configured, but releases are blocked"),
4273        ("excluded", "outside Intentional's release-unit inventory"),
4274        ("managed", "configured and eligible for release"),
4275        (
4276            "removed",
4277            "reference removed or replaced and verified from the file",
4278        ),
4279        (
4280            "external",
4281            "publication sequencing remains with the external executor",
4282        ),
4283        ("intentional", "Intentional's contract owns this behavior"),
4284    ]);
4285    let mut output = String::new();
4286    let mut choices_indent = None;
4287    for line in yaml.lines() {
4288        let indent = line.len() - line.trim_start().len();
4289        if line.trim() == "choices:" {
4290            choices_indent = Some(indent);
4291            output.push_str(line);
4292            output.push('\n');
4293            continue;
4294        }
4295        if let Some(base) = choices_indent {
4296            if let Some(value) = line.trim().strip_prefix("- ") {
4297                if let Some(comment) = comments.get(value) {
4298                    output.push_str(line);
4299                    output.push_str(" # ");
4300                    output.push_str(comment);
4301                    output.push('\n');
4302                    continue;
4303                }
4304            } else if indent <= base {
4305                choices_indent = None;
4306            }
4307        }
4308        output.push_str(line);
4309        output.push('\n');
4310    }
4311    output
4312}
4313
4314#[cfg(test)]
4315mod tests {
4316    use super::*;
4317
4318    const DIGEST: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
4319
4320    fn candidate(path: &str, resolution: Option<CandidateResolution>) -> DiscoveryCandidate {
4321        let path = PathBuf::from(path);
4322        let evidence = SourceEvidence {
4323            path: path.clone(),
4324            digest: DIGEST.to_owned(),
4325            lines: vec![1],
4326        };
4327        DiscoveryCandidate {
4328            id: DiscoveryCandidate::stable_id("sample-detector", &path).expect("stable id"),
4329            detector: "sample-detector".to_owned(),
4330            path: path.clone(),
4331            evidence: vec![evidence.clone()],
4332            native_identity: Some("sample-native-id".to_owned()),
4333            raw_version: Some(RawVersionEvidence {
4334                value: "raw-version-text".to_owned(),
4335                evidence: vec![evidence.clone()],
4336            }),
4337            projection: Some(CandidateProjectionSuggestion {
4338                adapter: Adapter::Npm,
4339                path,
4340                mode: ProjectionMode::Committed,
4341                pointer: None,
4342            }),
4343            tag: Some(CandidateTagSuggestion {
4344                id: "primary".to_owned(),
4345                role: TagRole::Primary,
4346                template: "{id}@{version}".to_owned(),
4347            }),
4348            diagnostics: vec![ExtractionDiagnostic {
4349                id: "sample-diagnostic".to_owned(),
4350                code: "sample-extraction".to_owned(),
4351                message: "Sample extraction evidence is incomplete.".to_owned(),
4352                evidence: vec![evidence],
4353            }],
4354            resolution,
4355        }
4356    }
4357
4358    fn candidate_plan(candidates: Vec<DiscoveryCandidate>) -> InitPlan {
4359        let config = Config::from_yaml(
4360            "contract: contract-1\nrelease-units:\n  configured:\n    path: configured\n    tags:\n      primary: { role: primary, template: '{id}@{version}' }\n",
4361        )
4362        .expect("candidate test config");
4363        InitPlan {
4364            schema: INIT_PLAN_SCHEMA.to_owned(),
4365            state: InitState::NeedsInput,
4366            source_kind: "changesets".to_owned(),
4367            source_fingerprint: DIGEST.to_owned(),
4368            inferred_config: config,
4369            discovery_candidates: candidates,
4370            diagnostics: Vec::new(),
4371            converted_intents: Vec::new(),
4372            parity: ParityResult {
4373                status: "equivalent".to_owned(),
4374                release_units: Vec::new(),
4375            },
4376            planned_operations: Vec::new(),
4377            post_commit_action: "intentional tag --baseline".to_owned(),
4378        }
4379    }
4380
4381    #[test]
4382    fn candidate_resolution_uses_only_documented_kebab_case_fields() {
4383        let independent: CandidateResolution =
4384            serde_yaml::from_str("kind: independent\nrelease-unit: sample-library\n")
4385                .expect("documented independent resolution");
4386        assert_eq!(
4387            independent,
4388            CandidateResolution::Independent {
4389                release_unit: "sample-library".to_owned(),
4390            }
4391        );
4392
4393        let projection: CandidateResolution = serde_yaml::from_str(
4394            "kind: projection\nrelease-unit: sample-library\ntarget-candidate: candidate:1234\n",
4395        )
4396        .expect("documented projection resolution");
4397        assert_eq!(
4398            projection,
4399            CandidateResolution::Projection {
4400                release_unit: "sample-library".to_owned(),
4401                target_candidate: Some("candidate:1234".to_owned()),
4402            }
4403        );
4404        let rendered = serde_yaml::to_string(&projection).expect("canonical resolution YAML");
4405        assert!(rendered.contains("release-unit: sample-library"));
4406        assert!(rendered.contains("target-candidate: candidate:1234"));
4407        assert!(!rendered.contains("release_unit"));
4408        assert!(!rendered.contains("target_candidate"));
4409
4410        for undocumented in [
4411            "kind: independent\nrelease_unit: sample-library\n",
4412            "kind: projection\nrelease-unit: sample-library\ntarget_candidate: candidate:1234\n",
4413        ] {
4414            serde_yaml::from_str::<CandidateResolution>(undocumented)
4415                .expect_err("undocumented snake-case field");
4416        }
4417    }
4418
4419    #[test]
4420    fn recognizes_supported_manifests() {
4421        assert_eq!(adapter_for(Path::new("package.json")), Some(Adapter::Npm));
4422        assert_eq!(
4423            adapter_for(Path::new("module.csproj")),
4424            Some(Adapter::Msbuild)
4425        );
4426        assert_eq!(adapter_for(Path::new("README.md")), None);
4427    }
4428
4429    #[test]
4430    fn choice_rendering_is_compact_and_explained() {
4431        let yaml = "choices:\n- suspended\n- excluded\nresolution: null\n";
4432        let rendered = annotate_choice_lines(yaml);
4433        assert!(rendered.contains("- suspended # configured, but releases are blocked"));
4434        assert!(rendered.contains("- excluded # outside Intentional's release-unit inventory"));
4435    }
4436
4437    #[test]
4438    fn proxy_reference_detection_ignores_prose_and_comments() {
4439        let path = "examples/sample.json";
4440        assert!(!structural_line_references_path(
4441            r#""description": "See examples/sample.json""#,
4442            path
4443        ));
4444        assert!(!structural_line_references_path(
4445            "note: examples/sample.json",
4446            path
4447        ));
4448        assert!(!structural_line_references_path(
4449            "// load examples/sample.json",
4450            path
4451        ));
4452        assert!(!structural_line_references_path(
4453            "const enabled = true; // load examples/sample.json",
4454            path
4455        ));
4456        assert!(!structural_line_references_path(
4457            "/* load examples/sample.json */",
4458            path
4459        ));
4460        assert!(structural_line_references_path(
4461            "run: curl https://registry.invalid && use examples/sample.json",
4462            path
4463        ));
4464        assert!(structural_line_references_path(
4465            r##"run: "curl https://registry.invalid/#fragment && use examples/sample.json""##,
4466            path
4467        ));
4468        assert!(structural_line_references_path(
4469            r#"const manifest = "examples/sample.json";"#,
4470            path
4471        ));
4472    }
4473
4474    #[test]
4475    fn npm_dependency_evidence_follows_the_materialized_projection_path() {
4476        struct TestDirectory(PathBuf);
4477        impl Drop for TestDirectory {
4478            fn drop(&mut self) {
4479                let _ = std::fs::remove_dir_all(&self.0);
4480            }
4481        }
4482
4483        let root = TestDirectory(std::env::temp_dir().join(format!(
4484            "intentional-init-{}-{}",
4485            std::process::id(),
4486            std::time::SystemTime::now()
4487                .duration_since(std::time::UNIX_EPOCH)
4488                .expect("system time")
4489                .as_nanos()
4490        )));
4491        for directory in ["base", "duplicates/alpha", "duplicates/beta"] {
4492            std::fs::create_dir_all(root.0.join(directory)).expect("create fixture directory");
4493        }
4494        std::fs::write(
4495            root.0.join("base/package.json"),
4496            r#"{"name":"sample-base","version":"1.0.0"}"#,
4497        )
4498        .expect("write base manifest");
4499        std::fs::write(
4500            root.0.join("duplicates/alpha/package.json"),
4501            r#"{"name":"sample-duplicate","version":"1.0.0"}"#,
4502        )
4503        .expect("write first duplicate");
4504        std::fs::write(
4505            root.0.join("duplicates/beta/package.json"),
4506            r#"{"name":"sample-duplicate","version":"1.0.0","dependencies":{"sample-base":"^1.0.0"}}"#,
4507        )
4508        .expect("write second duplicate");
4509        let mut config = Config::from_yaml(
4510            r#"contract: contract-1
4511release-units:
4512  sample-base:
4513    path: base
4514    projections:
4515      - { adapter: npm, file: package.json, mode: committed }
4516    tags:
4517      primary: { role: primary, template: '{id}@{version}' }
4518  sample-duplicate:
4519    path: .
4520    projections:
4521      - { adapter: npm, file: duplicates/alpha/package.json, mode: committed }
4522      - { adapter: npm, file: duplicates/beta/package.json, mode: committed }
4523    tags:
4524      primary: { role: primary, template: '{id}@{version}' }
4525"#,
4526        )
4527        .expect("fixture config");
4528
4529        let mut edges = derive_npm_dependencies(&root.0, &mut config).expect("dependency evidence");
4530        assert_eq!(edges.len(), 1);
4531        assert_eq!(edges[0].manifest, Path::new("duplicates/beta/package.json"));
4532        assert_eq!(
4533            config.release_units["sample-duplicate"].depends_on,
4534            vec!["sample-base"]
4535        );
4536
4537        config
4538            .release_units
4539            .get_mut("sample-duplicate")
4540            .expect("duplicate unit")
4541            .projections
4542            .retain(|projection| projection.file != "duplicates/beta/package.json");
4543        retain_materialized_npm_dependencies(&mut config, &mut edges);
4544        assert!(edges.is_empty());
4545        assert!(config.release_units["sample-duplicate"]
4546            .depends_on
4547            .is_empty());
4548
4549        let duplicate = config
4550            .release_units
4551            .get_mut("sample-duplicate")
4552            .expect("duplicate unit");
4553        duplicate.projections.clear();
4554        duplicate.depends_on = vec!["sample-base".to_owned()];
4555        retain_materialized_npm_dependencies(&mut config, &mut edges);
4556        assert!(config.release_units["sample-duplicate"]
4557            .depends_on
4558            .is_empty());
4559    }
4560
4561    #[test]
4562    fn candidate_identity_and_detector_results_are_stable() {
4563        let first = candidate(
4564            "examples/first.json",
4565            Some(CandidateResolution::Independent {
4566                release_unit: "sample-unit".to_owned(),
4567            }),
4568        );
4569        let second = candidate("examples/second.json", Some(CandidateResolution::Excluded));
4570        assert_eq!(
4571            first.id,
4572            DiscoveryCandidate::stable_id("sample-detector", Path::new("examples/first.json"))
4573                .expect("same identity")
4574        );
4575        assert_ne!(first.id, second.id);
4576        assert_ne!(
4577            first.id,
4578            DiscoveryCandidate::stable_id("other-detector", Path::new("examples/first.json"))
4579                .expect("different detector")
4580        );
4581        assert!(DiscoveryCandidate::stable_id(
4582            "-invalid-detector",
4583            Path::new("examples/first.json")
4584        )
4585        .is_err());
4586
4587        let result = DetectorResult {
4588            detector: "sample-detector".to_owned(),
4589            candidates: vec![second.clone(), first.clone()],
4590        };
4591        let ordered = result.into_candidates().expect("detector result valid");
4592        assert!(ordered[0].path < ordered[1].path);
4593
4594        let mut tampered = first.clone();
4595        tampered.id = format!("candidate:{}", "0".repeat(64));
4596        assert!(candidate_plan(vec![tampered])
4597            .validate()
4598            .expect_err("tampered candidate id rejected")
4599            .to_string()
4600            .contains("does not match detector"));
4601
4602        let foreign = DetectorResult {
4603            detector: "other-detector".to_owned(),
4604            candidates: vec![first.clone()],
4605        };
4606        assert!(foreign
4607            .validate()
4608            .expect_err("foreign detector candidate rejected")
4609            .to_string()
4610            .contains("returned candidate owned by"));
4611
4612        let yaml = candidate_plan(vec![first, second])
4613            .to_yaml()
4614            .expect("candidate plan serializes");
4615        assert!(yaml.contains("kind: independent"));
4616        assert!(yaml.contains("kind: excluded"));
4617        assert!(yaml.contains("raw-version:"));
4618        assert!(yaml.contains("diagnostics:"));
4619    }
4620
4621    #[test]
4622    fn changesets_projection_dedup_rejects_adapter_or_mode_conflicts() {
4623        for (adapter, mode) in [
4624            (Adapter::Cargo, ProjectionMode::Committed),
4625            (Adapter::Npm, ProjectionMode::Injected),
4626        ] {
4627            let mut config = candidate_plan(Vec::new()).inferred_config;
4628            let release_unit = config
4629                .release_units
4630                .get_mut("configured")
4631                .expect("configured release unit");
4632            release_unit.path = PathBuf::from("examples");
4633            release_unit.projections = vec![Projection {
4634                adapter: Adapter::Npm,
4635                file: ProjectionFile::unit_relative(Path::new("sample.json")),
4636                mode: ProjectionMode::Committed,
4637                pointer: None,
4638            }];
4639            let mut candidate = candidate(
4640                "examples/sample.json",
4641                Some(CandidateResolution::Projection {
4642                    release_unit: "configured".to_owned(),
4643                    target_candidate: None,
4644                }),
4645            );
4646            let suggestion = candidate
4647                .projection
4648                .as_mut()
4649                .expect("projection suggestion");
4650            suggestion.adapter = adapter;
4651            suggestion.mode = mode;
4652            let mut discovery = Discovery {
4653                config,
4654                ..Discovery::default()
4655            };
4656
4657            let error = apply_changesets_candidate_resolutions(&mut discovery, &[candidate])
4658                .expect_err("same target with different semantics rejected");
4659            assert!(error
4660                .to_string()
4661                .contains("conflicts with existing projection"));
4662        }
4663    }
4664
4665    #[test]
4666    fn resolved_candidates_reject_genuine_version_conflicts() {
4667        let mut first = candidate(
4668            "examples/first.json",
4669            Some(CandidateResolution::Projection {
4670                release_unit: "configured".to_owned(),
4671                target_candidate: None,
4672            }),
4673        );
4674        first.raw_version.as_mut().expect("first version").value = "1.0.0".to_owned();
4675        let mut second = candidate(
4676            "examples/second.json",
4677            Some(CandidateResolution::Projection {
4678                release_unit: "configured".to_owned(),
4679                target_candidate: None,
4680            }),
4681        );
4682        second.raw_version.as_mut().expect("second version").value = "2.0.0".to_owned();
4683        let mut discovery = Discovery {
4684            config: candidate_plan(Vec::new()).inferred_config,
4685            ..Discovery::default()
4686        };
4687
4688        let error = recompute_resolved_versions(&mut discovery, &[first, second])
4689            .expect_err("resolved version conflict");
4690        assert!(error
4691            .to_string()
4692            .contains("disagree on current version: 1.0.0, 2.0.0"));
4693    }
4694
4695    #[test]
4696    fn changesets_projection_rejects_an_unrelated_existing_owner() {
4697        let config = Config::from_yaml(
4698            r#"contract: contract-1
4699release-units:
4700  alpha:
4701    path: examples
4702    tags:
4703      primary: { role: primary, template: '{id}@{version}' }
4704  gamma:
4705    path: examples
4706    projections:
4707      - adapter: json
4708        file: shared.json
4709        mode: committed
4710        pointer: /version
4711    tags:
4712      primary: { role: primary, template: 'gamma@{version}' }
4713"#,
4714        )
4715        .expect("unrelated-owner config");
4716        let mut candidate = candidate(
4717            "examples/shared.json",
4718            Some(CandidateResolution::Projection {
4719                release_unit: "alpha".to_owned(),
4720                target_candidate: None,
4721            }),
4722        );
4723        candidate.projection = Some(CandidateProjectionSuggestion {
4724            adapter: Adapter::Json,
4725            path: candidate.path.clone(),
4726            mode: ProjectionMode::Committed,
4727            pointer: Some("/version".to_owned()),
4728        });
4729        let candidate_id = candidate.id.clone();
4730        let mut discovery = Discovery {
4731            config,
4732            ..Discovery::default()
4733        };
4734
4735        let error = apply_changesets_candidate_resolutions(&mut discovery, &[candidate])
4736            .expect_err("unrelated projection ownership rejected");
4737        assert_eq!(
4738            error.to_string(),
4739            format!(
4740                "validation failed: Changesets discovery candidate {candidate_id} projection is already owned by release unit gamma, not alpha"
4741            )
4742        );
4743    }
4744
4745    #[test]
4746    fn rejects_plan_without_required_discovery_candidates() {
4747        let yaml = candidate_plan(Vec::new())
4748            .to_yaml()
4749            .expect("complete plan serializes");
4750        assert!(yaml.contains("discovery-candidates: []"));
4751        let incomplete = yaml.replace("discovery-candidates: []\n", "");
4752        let error = serde_yaml::from_str::<InitPlan>(&incomplete)
4753            .expect_err("missing discovery candidate collection rejected");
4754        assert!(error
4755            .to_string()
4756            .contains("missing field `discovery-candidates`"));
4757    }
4758
4759    #[test]
4760    fn empty_inferred_config_obeys_its_published_schema_shape() {
4761        let mut plan = candidate_plan(vec![candidate("examples/sample.json", None)]);
4762        plan.inferred_config = Config::default();
4763        plan.validate().expect("schema-compatible empty config");
4764
4765        plan.inferred_config
4766            .discovery
4767            .excluded_paths
4768            .push(ExcludedPathReceipt {
4769                detector: "sample-detector".to_owned(),
4770                path: PathBuf::from("examples/sample.json"),
4771                evidence_digest: DIGEST.to_owned(),
4772            });
4773        assert!(plan
4774            .validate()
4775            .expect_err("discovery is forbidden on an empty inferred config")
4776            .to_string()
4777            .contains("may only contain schema, contract, settings, and release-units"));
4778
4779        plan.inferred_config.discovery.excluded_paths.clear();
4780        plan.inferred_config.schema = Some("https://example.invalid/config.yml".to_owned());
4781        assert!(plan
4782            .validate()
4783            .expect_err("foreign schema is forbidden on an empty inferred config")
4784            .to_string()
4785            .contains("schema must be"));
4786    }
4787
4788    #[test]
4789    fn candidate_tag_templates_use_the_shared_contract() {
4790        for template in ["v{version}", "{version}-{version}", "{id}-{id}@{version}"] {
4791            let mut invalid = candidate("examples/template.json", None);
4792            invalid.tag.as_mut().expect("tag suggestion").template = template.to_owned();
4793            assert!(candidate_plan(vec![invalid])
4794                .validate()
4795                .expect_err("invalid candidate tag template rejected")
4796                .to_string()
4797                .contains("tag template"));
4798        }
4799    }
4800
4801    #[test]
4802    fn published_init_schema_carries_candidate_resolution_variants() {
4803        let schema: serde_yaml::Value =
4804            serde_yaml::from_str(include_str!("../../../schemas/init-plan.yml"))
4805                .expect("initialization-plan schema parses");
4806        assert!(schema["required"]
4807            .as_sequence()
4808            .expect("required plan fields")
4809            .iter()
4810            .any(|field| field.as_str() == Some("discovery-candidates")));
4811        let variants = schema["$defs"]["candidate-resolution"]["oneOf"]
4812            .as_sequence()
4813            .expect("candidate resolution variants");
4814        assert_eq!(variants.len(), 3);
4815        let kinds = variants
4816            .iter()
4817            .map(|variant| {
4818                variant["properties"]["kind"]["const"]
4819                    .as_str()
4820                    .expect("resolution kind")
4821            })
4822            .collect::<BTreeSet<_>>();
4823        assert_eq!(
4824            kinds,
4825            BTreeSet::from(["excluded", "independent", "projection"])
4826        );
4827        assert_eq!(
4828            schema["$defs"]["discovery-candidate"]["properties"]["tag"]["properties"]["template"]
4829                ["pattern"]
4830                .as_str(),
4831            Some(
4832                r"^(?![\s\S]*v\{version\})(?![\s\S]*\{version\}[\s\S]*\{version\})(?![\s\S]*\{id\}[\s\S]*\{id\})(?=[\s\S]*\{version\})[\s\S]*$"
4833            )
4834        );
4835    }
4836
4837    #[test]
4838    fn accepts_configured_and_same_plan_projection_targets() {
4839        let configured = candidate(
4840            "examples/configured.json",
4841            Some(CandidateResolution::Projection {
4842                release_unit: "configured".to_owned(),
4843                target_candidate: None,
4844            }),
4845        );
4846        let independent = candidate(
4847            "examples/independent.json",
4848            Some(CandidateResolution::Independent {
4849                release_unit: "planned".to_owned(),
4850            }),
4851        );
4852        let projection = candidate(
4853            "examples/projection.json",
4854            Some(CandidateResolution::Projection {
4855                release_unit: "planned".to_owned(),
4856                target_candidate: Some(independent.id.clone()),
4857            }),
4858        );
4859        candidate_plan(vec![configured, independent, projection])
4860            .validate()
4861            .expect("both projection target kinds accepted");
4862    }
4863
4864    #[test]
4865    fn changesets_materializes_any_unrepresented_candidate_projection_once() {
4866        let config = candidate_plan(Vec::new()).inferred_config;
4867        let mut generic = candidate(
4868            "configured/metadata.json",
4869            Some(CandidateResolution::Projection {
4870                release_unit: "configured".to_owned(),
4871                target_candidate: None,
4872            }),
4873        );
4874        generic.projection = Some(CandidateProjectionSuggestion {
4875            adapter: Adapter::Json,
4876            path: generic.path.clone(),
4877            mode: ProjectionMode::Committed,
4878            pointer: Some("/version".to_owned()),
4879        });
4880
4881        let mut discovery = Discovery {
4882            config,
4883            ..Discovery::default()
4884        };
4885        apply_changesets_candidate_resolutions(&mut discovery, std::slice::from_ref(&generic))
4886            .expect("generic projection materialized");
4887        assert_eq!(
4888            discovery.config.release_units["configured"]
4889                .projections
4890                .len(),
4891            1
4892        );
4893        assert_eq!(
4894            discovery.config.release_units["configured"].projections[0]
4895                .pointer
4896                .as_deref(),
4897            Some("/version")
4898        );
4899
4900        apply_changesets_candidate_resolutions(&mut discovery, std::slice::from_ref(&generic))
4901            .expect("represented projection not duplicated");
4902        assert_eq!(
4903            discovery.config.release_units["configured"]
4904                .projections
4905                .len(),
4906            1
4907        );
4908    }
4909
4910    #[test]
4911    fn rejects_duplicate_creators_and_absent_projection_targets() {
4912        let first = candidate(
4913            "examples/first.json",
4914            Some(CandidateResolution::Independent {
4915                release_unit: "duplicate".to_owned(),
4916            }),
4917        );
4918        let second = candidate(
4919            "examples/second.json",
4920            Some(CandidateResolution::Independent {
4921                release_unit: "duplicate".to_owned(),
4922            }),
4923        );
4924        assert!(candidate_plan(vec![first, second])
4925            .validate()
4926            .expect_err("duplicate creators rejected")
4927            .to_string()
4928            .contains("duplicate creator"));
4929
4930        let absent = candidate(
4931            "examples/absent.json",
4932            Some(CandidateResolution::Projection {
4933                release_unit: "missing".to_owned(),
4934                target_candidate: None,
4935            }),
4936        );
4937        assert!(candidate_plan(vec![absent])
4938            .validate()
4939            .expect_err("absent configured target rejected")
4940            .to_string()
4941            .contains("absent configured release unit"));
4942    }
4943
4944    #[test]
4945    fn rejects_projection_cycles_and_release_unit_mismatches() {
4946        let mut first = candidate("examples/first.json", None);
4947        let mut second = candidate("examples/second.json", None);
4948        first.resolution = Some(CandidateResolution::Projection {
4949            release_unit: "planned".to_owned(),
4950            target_candidate: Some(second.id.clone()),
4951        });
4952        second.resolution = Some(CandidateResolution::Projection {
4953            release_unit: "planned".to_owned(),
4954            target_candidate: Some(first.id.clone()),
4955        });
4956        assert!(candidate_plan(vec![first, second])
4957            .validate()
4958            .expect_err("candidate cycle rejected")
4959            .to_string()
4960            .contains("projection cycle"));
4961
4962        let independent = candidate(
4963            "examples/independent.json",
4964            Some(CandidateResolution::Independent {
4965                release_unit: "planned".to_owned(),
4966            }),
4967        );
4968        let mismatched = candidate(
4969            "examples/mismatched.json",
4970            Some(CandidateResolution::Projection {
4971                release_unit: "other".to_owned(),
4972                target_candidate: Some(independent.id.clone()),
4973            }),
4974        );
4975        assert!(candidate_plan(vec![independent, mismatched])
4976            .validate()
4977            .expect_err("mismatched target rejected")
4978            .to_string()
4979            .contains("target"));
4980
4981        let independent = candidate(
4982            "examples/independent.json",
4983            Some(CandidateResolution::Independent {
4984                release_unit: "planned".to_owned(),
4985            }),
4986        );
4987        let intermediate = candidate(
4988            "examples/intermediate.json",
4989            Some(CandidateResolution::Projection {
4990                release_unit: "planned".to_owned(),
4991                target_candidate: Some(independent.id.clone()),
4992            }),
4993        );
4994        let chained = candidate(
4995            "examples/chained.json",
4996            Some(CandidateResolution::Projection {
4997                release_unit: "planned".to_owned(),
4998                target_candidate: Some(intermediate.id.clone()),
4999            }),
5000        );
5001        assert!(candidate_plan(vec![independent, intermediate, chained])
5002            .validate()
5003            .expect_err("projection chain rejected")
5004            .to_string()
5005            .contains("not an independent creator"));
5006    }
5007}