Skip to main content

intentional_core/
init.rs

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