1use serde::Serialize;
17
18use crate::cli::status::StatusArgs;
19use crate::diagnostic::{Diagnostic, Reason};
20use crate::digest::Digest;
21use crate::error::RkError;
22use crate::landing::invariants::{self, InvariantFailure};
23use crate::landing::manifest::{self, Alignment, Manifest};
24use crate::landing::{self, Kind};
25use crate::output::Output;
26use crate::profile::RegistryAnswer;
27use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseMode};
28use crate::projection::{Candidate, Projection, ProjectionInput, TargetEvidence};
29use crate::stage::CapabilityNote;
30use crate::{assess, embedded, registry};
31
32#[derive(Debug, Serialize)]
34struct Drift {
35 rendered: usize,
37 seeded: usize,
39}
40
41#[derive(Debug, Serialize)]
43struct StalePin {
44 tool: String,
46 landed: String,
48 available: String,
50}
51
52#[derive(Debug, Serialize)]
58struct Warning {
59 code: &'static str,
61 reason: String,
63}
64
65#[derive(Debug, Clone, Serialize)]
70struct SetupProof {
71 state: &'static str,
73 #[serde(skip_serializing_if = "Vec::is_empty")]
75 changed: Vec<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 reason: Option<&'static str>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 detail: Option<String>,
82}
83
84const SETUP_PROOF_REMEDIATION: &str = "run rk setup check --target ., then rk setup checkpoint --target ., and commit .release-kit/setup-proof.json; a step this forge cannot run goes in setup.excluded_steps with its reason, which keeps the proof complete";
87
88impl SetupProof {
89 fn of(standing: &crate::setup::proof::Standing) -> Self {
90 use crate::setup::proof::Standing;
91 let mut proof = Self {
92 state: standing.word(),
93 changed: Vec::new(),
94 reason: None,
95 detail: None,
96 };
97 match standing {
98 Standing::Stale { differences, .. } => proof.changed.clone_from(differences),
99 Standing::Invalid { reason, detail } => {
100 proof.reason = Some(reason);
101 proof.detail = Some(detail.clone());
102 }
103 Standing::Absent | Standing::Compatible(_) => {}
104 }
105 proof
106 }
107
108 fn failure(&self) -> Option<InvariantFailure> {
111 let (code, reason) = match self.state {
112 "absent" => (
113 "setup-proof-absent",
114 "no .release-kit/setup-proof.json is committed, so nothing proves the forge, the registry, and the bot meet the setup contract".to_owned(),
115 ),
116 "stale" => (
117 "setup-proof-stale",
118 format!(
119 "the committed setup proof was written for another setup contract; changed: {}",
120 self.changed.join(", ")
121 ),
122 ),
123 "invalid" => (
124 "setup-proof-invalid",
125 format!(
126 "the committed setup proof cannot be read: {}: {}",
127 self.reason.unwrap_or("invalid"),
128 self.detail.as_deref().unwrap_or("")
129 ),
130 ),
131 _ => return None,
132 };
133 Some(InvariantFailure {
134 code,
135 destination: crate::setup::proof::PROOF_PATH.to_owned(),
136 reason,
137 remediation: SETUP_PROOF_REMEDIATION,
138 })
139 }
140}
141
142fn setup_proof_of(
146 target: &camino::Utf8PathBuf,
147 manifest: &Manifest,
148) -> Result<Option<SetupProof>, RkError> {
149 if manifest.profile.release.mode != ReleaseMode::Automatic || manifest.profile.forge.is_none() {
150 return Ok(None);
151 }
152 Ok(Some(SetupProof::of(&crate::setup::proof::offline(target)?)))
153}
154
155const CODE_SCANNING_LICENCE: &str = "code-scanning-licence";
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
163#[serde(rename_all = "kebab-case")]
164pub enum Classification {
165 Setup,
167 Migration,
169 NeedsDecision,
171 Adopt,
173 Upgrade,
175 Drift,
177 Current,
179}
180
181impl Classification {
182 #[must_use]
184 pub const fn as_str(self) -> &'static str {
185 match self {
186 Self::Setup => "setup",
187 Self::Migration => "migration",
188 Self::NeedsDecision => "needs-decision",
189 Self::Adopt => "adopt",
190 Self::Upgrade => "upgrade",
191 Self::Drift => "drift",
192 Self::Current => "current",
193 }
194 }
195}
196
197const fn unrecorded(class: assess::Unrecorded) -> Classification {
200 match class {
201 assess::Unrecorded::Adopt => Classification::Adopt,
202 assess::Unrecorded::Assessed(assess::Classification::Greenfield) => Classification::Setup,
203 assess::Unrecorded::Assessed(assess::Classification::Brownfield) => {
204 Classification::Migration
205 }
206 assess::Unrecorded::Assessed(assess::Classification::NeedsDecision) => {
207 Classification::NeedsDecision
208 }
209 }
210}
211
212const fn recorded(alignment: Alignment, clean: bool) -> Option<Classification> {
216 match alignment {
217 Alignment::TargetNewer => None,
218 Alignment::BinaryNewer => Some(Classification::Upgrade),
219 Alignment::Aligned if clean => Some(Classification::Current),
220 Alignment::Aligned => Some(Classification::Drift),
221 }
222}
223
224#[derive(Debug, Clone, Serialize)]
226struct SentinelAt {
227 file: String,
229 line: usize,
231 text: String,
233}
234
235#[derive(Debug, Serialize)]
237struct LegacyPath {
238 path: String,
240 kind: &'static str,
242 active: bool,
244}
245
246fn legacy_state() -> Vec<LegacyPath> {
255 let Some(root) = crate::applog::state_root() else {
256 return Vec::new();
257 };
258 let mut found = Vec::new();
259 if let Ok(entries) = std::fs::read_dir(root.join(LEGACY_PLANS)) {
260 let mut plans: Vec<std::path::PathBuf> = entries
261 .flatten()
262 .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
263 .map(|entry| entry.path())
264 .filter(|path| path.is_dir())
265 .collect();
266 plans.sort();
267 for plan in plans {
268 let active = std::fs::read(plan.join("plan.json"))
269 .ok()
270 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
271 .is_none_or(|document| document["readiness"].as_str() == Some("ready"));
272 found.push(LegacyPath {
273 path: plan.display().to_string(),
274 kind: "plan",
275 active,
276 });
277 }
278 }
279 let cache = root.join(LEGACY_RELEASE_CACHE);
280 if cache.is_dir() {
281 found.push(LegacyPath {
282 path: cache.display().to_string(),
283 kind: "release-cache",
284 active: false,
285 });
286 }
287 found
288}
289
290const LEGACY_PLANS: &str = "plans";
292
293const LEGACY_RELEASE_CACHE: &str = "release";
295
296const SCHEMA: &str = "rk.status/15";
298
299#[derive(Debug, serde::Serialize)]
301struct ConfigState {
302 state: &'static str,
303 pending: Vec<String>,
304}
305
306fn config_state(config: Option<&crate::config::Config>, record: Option<&Manifest>) -> ConfigState {
307 let pending = config
308 .zip(record)
309 .map_or_else(Vec::new, |(config, record)| {
310 crate::config::pending(config, record)
311 });
312 ConfigState {
313 state: if config.is_none() {
314 "absent"
315 } else if pending.is_empty() {
316 "aligned"
317 } else {
318 "pending"
319 },
320 pending,
321 }
322}
323
324#[derive(Debug, Serialize)]
326struct Report {
327 schema: &'static str,
329 landed: bool,
331 classification: Option<Classification>,
334 git_work_tree: bool,
336 config: ConfigState,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 profile: Option<ProfileSnapshot>,
340 #[serde(skip_serializing_if = "Option::is_none")]
342 git: Option<GitWorkflow>,
343 #[serde(skip_serializing_if = "Option::is_none")]
345 capabilities: Option<CapabilityRequests>,
346 #[serde(skip_serializing_if = "Option::is_none")]
349 selection: Option<Vec<CapabilityNote>>,
350 #[serde(skip_serializing_if = "Option::is_none")]
351 rk_version: Option<String>,
352 #[serde(skip_serializing_if = "Option::is_none")]
353 binary_version: Option<&'static str>,
354 #[serde(skip_serializing_if = "Option::is_none")]
355 alignment: Option<Alignment>,
356 #[serde(skip_serializing_if = "Option::is_none")]
357 drift: Option<Drift>,
358 #[serde(skip_serializing_if = "Option::is_none")]
360 missing: Option<Vec<String>>,
361 #[serde(skip_serializing_if = "Option::is_none")]
362 stale_pins: Option<Vec<StalePin>>,
363 #[serde(skip_serializing_if = "Option::is_none")]
365 sentinels: Option<usize>,
366 #[serde(skip_serializing_if = "Option::is_none")]
368 sentinel_locations: Option<Vec<SentinelAt>>,
369 #[serde(skip_serializing_if = "Option::is_none")]
373 record_drift: Option<usize>,
374 #[serde(skip_serializing_if = "Option::is_none")]
377 invariant_failures: Option<Vec<InvariantFailure>>,
378 #[serde(skip_serializing_if = "Option::is_none")]
382 warnings: Option<Vec<Warning>>,
383 #[serde(skip_serializing_if = "Option::is_none")]
386 setup_proof: Option<SetupProof>,
387 #[serde(skip_serializing_if = "Option::is_none")]
393 pending: Option<usize>,
394 #[serde(skip_serializing_if = "Option::is_none")]
397 accepted_drift: Option<Vec<AcceptedNote>>,
398 #[serde(skip_serializing_if = "Option::is_none")]
401 stale_accepted_drift: Option<Vec<String>>,
402 #[serde(skip_serializing_if = "Option::is_none")]
404 violations: Option<Vec<String>>,
405 legacy_state: Vec<LegacyPath>,
407 #[serde(skip_serializing_if = "Option::is_none")]
409 migration: Option<OpenMigration>,
410}
411
412#[derive(Debug, Serialize)]
414struct OpenMigration {
415 id: String,
417 #[serde(skip_serializing_if = "Option::is_none")]
419 workspace: Option<String>,
420 #[serde(skip_serializing_if = "Option::is_none")]
423 unfinished: Option<usize>,
424}
425
426fn open_migration(
431 target: &camino::Utf8Path,
432 config: Option<&crate::config::Config>,
433) -> Option<OpenMigration> {
434 let id = config.and_then(|config| config.migration.clone())?;
435 let workspace = crate::migrate::open_workspace(target).ok().flatten();
436 let unfinished = workspace
437 .as_ref()
438 .filter(|workspace| workspace.join(crate::migrate::REGISTRY_FILE).is_file())
439 .and_then(|workspace| crate::migrate::registry::load(workspace).ok())
440 .map(|registry| {
441 registry
442 .sources
443 .iter()
444 .filter(|source| !source.status.finished())
445 .count()
446 });
447 Some(OpenMigration {
448 id,
449 workspace: workspace.map(|workspace| workspace.to_string()),
450 unfinished,
451 })
452}
453
454fn render_migration(out: Output, migration: Option<&OpenMigration>) {
456 if let Some(migration) = migration {
457 out.result_line(format!(
458 "migration {} is open; rk migrate status reads it",
459 migration.id
460 ));
461 }
462}
463
464fn report_absent(
465 out: Output,
466 args: &StatusArgs,
467 config: Option<&crate::config::Config>,
468 git_work_tree: bool,
469) -> Result<(), RkError> {
470 let migration = open_migration(&args.target, config);
471 let classification = match assess::standing(&args.target, config)? {
472 assess::Standing::Migrating => Classification::Migration,
473 assess::Standing::Unrecorded(class) => unrecorded(class),
474 };
475 let legacy = legacy_state();
476 out.result_line(format!("config: {}", config_state(config, None).state));
477 out.result_line(format!("no landing at {}", args.target));
478 out.result_line(format!("classification: {}", classification.as_str()));
479 render_migration(out, migration.as_ref());
480 render_context(out, git_work_tree, &legacy);
481 out.next(&[
482 format!(
483 "rk profile --target {} reports what a landing would select",
484 args.target
485 ),
486 format!("rk init --target {} lands the workflow", args.target),
487 format!(
488 "rk adopt --target {} records a landing made before the receipt existed",
489 args.target
490 ),
491 format!(
492 "rk stage --target {} stages this binary's candidate; the rk-setup skill carries the best-effort migration",
493 args.target
494 ),
495 ]);
496 out.emit(&Report {
497 schema: SCHEMA,
498 landed: false,
499 classification: Some(classification),
500 git_work_tree,
501 config: config_state(config, None),
502 profile: None,
503 git: None,
504 capabilities: None,
505 selection: None,
506 rk_version: None,
507 binary_version: None,
508 alignment: None,
509 drift: None,
510 missing: None,
511 stale_pins: None,
512 sentinels: None,
513 sentinel_locations: None,
514 record_drift: None,
515 invariant_failures: None,
516 warnings: None,
517 setup_proof: None,
518 pending: None,
519 accepted_drift: None,
520 stale_accepted_drift: None,
521 violations: args.check.then(|| vec!["no landing".to_owned()]),
522 legacy_state: legacy,
523 migration,
524 })?;
525 if args.check {
526 return Err(RkError::check_failed(
527 Diagnostic::new(
528 Reason::StateDrift,
529 format!("no landing at {}, and --check requires one", args.target),
530 )
531 .expected("a target carrying .release-kit/manifest.json")
532 .action("rk init lands the workflow; rk adopt records an existing landing; rk stage and the rk-setup skill carry the migration"),
533 ));
534 }
535 Ok(())
536}
537
538#[derive(Debug, Clone, Serialize)]
540struct AcceptedNote {
541 destination: String,
543 reason: String,
545}
546
547struct Observed {
549 drift_rendered: Vec<String>,
550 drift_digests: std::collections::BTreeMap<String, String>,
553 drift_reasons: std::collections::BTreeMap<String, String>,
556 drift_accepted: Vec<AcceptedNote>,
558 accepted_stale: Vec<String>,
560 drift_seeded: Vec<String>,
561 parameter_drift: Vec<String>,
564 record_drift: Vec<String>,
569 missing: Vec<String>,
570 stale: Vec<StalePin>,
571 sentinels: Vec<(String, usize, String)>,
572 invariants: Vec<InvariantFailure>,
573 warnings: Vec<Warning>,
575 setup_proof: Option<SetupProof>,
576 incompatible: Vec<String>,
582 pending: Option<Vec<String>>,
586 selection: Option<Vec<CapabilityNote>>,
588}
589
590#[allow(
599 clippy::too_many_lines,
600 reason = "one pass reads the target, judges it, and emits the one report every reader takes"
601)]
602pub fn run(args: &StatusArgs) -> Result<(), RkError> {
603 let out = Output::new(args.json);
604 if !args.target.is_dir() {
605 return Err(RkError::missing(
606 Diagnostic::new(
607 Reason::TargetNotFound,
608 format!("target {} is not a directory", args.target),
609 )
610 .expected("an existing repository to report on"),
611 ));
612 }
613 let config = crate::config::load(args.target.as_std_path())?;
614 let git_work_tree = assess::git_work_tree(&args.target)?;
615 let Some(manifest) = manifest::load(&args.target)? else {
616 return report_absent(out, args, config.as_ref(), git_work_tree);
617 };
618
619 let migration = open_migration(&args.target, config.as_ref());
620 let accepted = config
621 .as_ref()
622 .map(|config| config.landing.accepted_drift.clone())
623 .unwrap_or_default();
624 let stated = Stated::of(config.as_ref(), &manifest);
625 let config = config_state(config.as_ref(), Some(&manifest));
626 out.result_line(format!("config: {}", config.state));
627 for key in &config.pending {
628 out.result_line(format!(
629 "config pending: {key}; rk upgrade --apply takes it up"
630 ));
631 }
632 let mut observed = observe(args, &manifest, &stated)?;
633 accept(&mut observed, &manifest, &accepted)?;
634 observed.setup_proof = setup_proof_of(&args.target, &manifest)?;
635 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
636 render_human(out, args, &manifest, alignment, &observed);
637
638 let violations = violations_of(&observed);
639 let classification = if migration.is_some() && alignment != Alignment::TargetNewer {
643 Some(Classification::Migration)
644 } else {
645 recorded(alignment, violations.is_empty())
646 };
647 out.result_line(format!(
648 "classification: {}",
649 classification.map_or(
650 "none: the record is newer than this binary",
651 Classification::as_str
652 )
653 ));
654 render_migration(out, migration.as_ref());
655 let legacy = legacy_state();
656 render_context(out, git_work_tree, &legacy);
657 out.emit(&Report {
658 schema: SCHEMA,
659 landed: true,
660 classification,
661 git_work_tree,
662 config,
663 profile: Some(manifest.profile.clone()),
664 git: Some(manifest.git.clone()),
665 capabilities: Some(manifest.capabilities.clone()),
666 selection: observed.selection.clone(),
667 rk_version: Some(manifest.rk_version),
668 binary_version: Some(env!("CARGO_PKG_VERSION")),
669 alignment: Some(alignment),
670 drift: Some(Drift {
671 rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
672 seeded: observed.drift_seeded.len(),
673 }),
674 record_drift: Some(observed.record_drift.len()),
675 missing: Some(observed.missing.clone()),
676 stale_pins: Some(observed.stale),
677 sentinels: Some(observed.sentinels.len()),
678 sentinel_locations: Some(
679 observed
680 .sentinels
681 .iter()
682 .map(|(file, line, text)| SentinelAt {
683 file: file.clone(),
684 line: *line,
685 text: text.clone(),
686 })
687 .collect(),
688 ),
689 invariant_failures: Some(observed.invariants),
690 warnings: Some(observed.warnings),
691 setup_proof: observed.setup_proof.clone(),
692 pending: observed.pending.as_ref().map(Vec::len),
693 accepted_drift: Some(observed.drift_accepted.clone()),
694 stale_accepted_drift: Some(observed.accepted_stale.clone()),
695 violations: args.check.then(|| violations.clone()),
696 legacy_state: legacy,
697 migration,
698 })?;
699
700 if args.check && !violations.is_empty() {
701 return Err(RkError::check_failed(
702 Diagnostic::new(
703 Reason::StateDrift,
704 format!(
705 "the landing is not clean: {} violation{}",
706 violations.len(),
707 if violations.len() == 1 { "" } else { "s" }
708 ),
709 )
710 .expected(
711 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
712 ),
713 ));
714 }
715 Ok(())
716}
717
718fn render_context(out: Output, git_work_tree: bool, legacy: &[LegacyPath]) {
722 if !git_work_tree {
723 out.result_line("not a non-bare Git work tree: the method lands files a work tree commits");
724 }
725 for entry in legacy {
726 out.result_line(format!(
727 "LEGACY {} ({}, {})",
728 entry.path,
729 entry.kind,
730 if entry.active { "active" } else { "inactive" }
731 ));
732 }
733}
734
735fn accept(
745 observed: &mut Observed,
746 manifest: &Manifest,
747 accepted: &std::collections::BTreeMap<String, crate::config::AcceptedDrift>,
748) -> Result<(), RkError> {
749 for (destination, entry) in accepted {
750 let Some(file) = manifest.file(destination) else {
751 return Err(crate::config::invalid(format!(
752 "landing.accepted_drift names {destination}, which the receipt does not record"
753 )));
754 };
755 if file.kind != Kind::Rendered || file.placement != manifest::Placement::Whole {
756 return Err(crate::config::invalid(format!(
757 "landing.accepted_drift names {destination}, which is not a whole rendered file: only such a file has bytes release-kit owns whole"
758 )));
759 }
760 if !observed.drift_rendered.contains(destination) {
761 if !observed.missing.contains(destination) {
762 observed.accepted_stale.push(destination.clone());
763 }
764 continue;
765 }
766 if observed.drift_digests.get(destination) == Some(&entry.sha256) {
767 observed.drift_rendered.retain(|path| path != destination);
768 observed.drift_accepted.push(AcceptedNote {
769 destination: destination.clone(),
770 reason: entry.reason.clone(),
771 });
772 } else {
773 observed
774 .drift_reasons
775 .insert(destination.clone(), entry.reason.clone());
776 }
777 }
778 Ok(())
779}
780
781fn violations_of(observed: &Observed) -> Vec<String> {
785 observed
786 .drift_rendered
787 .iter()
788 .map(|path| {
789 let digest = observed.drift_digests.get(path).map_or("", String::as_str);
790 observed.drift_reasons.get(path).map_or_else(
791 || format!("rendered drift: {path}; its sha256 is {digest}"),
792 |reason| {
793 format!(
794 "rendered drift: {path}, edited past its accepted drift ({reason}); its sha256 is now {digest}"
795 )
796 },
797 )
798 })
799 .chain(
800 observed
801 .parameter_drift
802 .iter()
803 .map(|path| format!("parameter drift: {path}")),
804 )
805 .chain(
806 observed
807 .record_drift
808 .iter()
809 .map(|reason| format!("record drift: {reason}")),
810 )
811 .chain(
812 observed
813 .missing
814 .iter()
815 .map(|path| format!("missing: {path}")),
816 )
817 .chain(
818 observed
819 .sentinels
820 .iter()
821 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
822 )
823 .chain(
824 observed
825 .invariants
826 .iter()
827 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
828 )
829 .chain(
830 observed
831 .setup_proof
832 .as_ref()
833 .and_then(SetupProof::failure)
834 .map(|failure| format!("setup proof: {}", failure.code)),
835 )
836 .collect()
837}
838
839struct Stated {
842 registry: Option<RegistryAnswer>,
844 required_check: String,
846}
847
848impl Stated {
849 fn of(config: Option<&crate::config::Config>, manifest: &Manifest) -> Self {
850 Self {
851 registry: config
852 .and_then(|config| config.profile.release.registry)
853 .or(manifest.profile.release.registry),
854 required_check: config.map_or_else(
857 || manifest.parameters.required_check.clone(),
858 |config| config.setup.required_check.clone(),
859 ),
860 }
861 }
862}
863
864#[allow(
867 clippy::too_many_lines,
868 reason = "one pass over the record and the disk answers every comparison the report states"
869)]
870fn observe(args: &StatusArgs, manifest: &Manifest, stated: &Stated) -> Result<Observed, RkError> {
871 let mut observed = Observed {
872 drift_rendered: Vec::new(),
873 drift_digests: std::collections::BTreeMap::new(),
874 drift_reasons: std::collections::BTreeMap::new(),
875 drift_accepted: Vec::new(),
876 accepted_stale: Vec::new(),
877 drift_seeded: Vec::new(),
878 parameter_drift: Vec::new(),
879 record_drift: Vec::new(),
880 missing: Vec::new(),
881 stale: Vec::new(),
882 sentinels: Vec::new(),
883 invariants: Vec::new(),
884 warnings: Vec::new(),
885 setup_proof: None,
886 incompatible: Vec::new(),
887 pending: None,
888 selection: None,
889 };
890 let aligned =
896 manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION")) == Alignment::Aligned;
897 let projected = match project(args, manifest) {
898 Ok(projection) => Some(projection),
899 Err(err) if aligned => return Err(err),
900 Err(_) => None,
901 };
902 for file in &manifest.files {
903 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
904 observed.missing.push(file.destination.clone());
905 continue;
906 };
907 if Digest::of(&bytes) != file.sha256 {
908 match file.kind {
909 Kind::Rendered => {
910 observed.drift_rendered.push(file.destination.clone());
911 observed
912 .drift_digests
913 .insert(file.destination.clone(), Digest::of(&bytes).to_string());
914 }
915 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
916 Kind::State => {}
917 }
918 }
919 observed.invariants.extend(invariants::failures(
920 manifest.profile.release.driver.as_deref().unwrap_or(""),
921 manifest.profile.forge.as_deref().unwrap_or(""),
922 &file.destination,
923 &bytes,
924 stated.registry,
925 ));
926 let text = String::from_utf8_lossy(&bytes);
927 for (idx, line) in text.lines().enumerate() {
928 if line.contains(embedded::SENTINEL) {
929 observed.sentinels.push((
930 file.destination.clone(),
931 idx + 1,
932 line.trim().to_owned(),
933 ));
934 }
935 }
936 let defective = projected.as_ref().is_some_and(|projection| {
941 projection
942 .collisions
943 .iter()
944 .any(|collision| collision.destination == file.destination)
945 });
946 if defective && !observed.drift_rendered.contains(&file.destination) {
947 observed.drift_rendered.push(file.destination.clone());
948 }
949 }
950 observed.invariants.extend(invariants::target_failures(
955 manifest.profile.release.driver.as_deref().unwrap_or(""),
956 manifest.profile.forge.as_deref().unwrap_or(""),
957 &args.target,
958 ));
959 if manifest.profile.release.driver.as_deref() == Some("rust")
962 && manifest.profile.forge.as_deref() == Some("github")
963 {
964 let dev_run = manifest
965 .parameters
966 .dev_environment
967 .as_ref()
968 .map_or("", |answer| answer.run.as_str());
969 let gate = invariants::dist_targets_built(&args.target, &stated.required_check, dev_run);
970 observed.invariants.extend(gate.failures);
971 observed.warnings.extend(
972 gate.limitations
973 .into_iter()
974 .map(|(code, reason)| Warning { code, reason }),
975 );
976 }
977 observed.selection = projected.as_ref().map(|projection| {
978 projection
979 .capabilities
980 .iter()
981 .map(|selection| CapabilityNote::of(selection, projection))
982 .collect()
983 });
984 if let Some(reason) = projected
985 .as_ref()
986 .and_then(|projection| projection.licence_refusal.clone())
987 {
988 observed.warnings.push(Warning {
989 code: CODE_SCANNING_LICENCE,
990 reason,
991 });
992 }
993 if let Some(projection) = projected.as_ref() {
998 observed.incompatible.clone_from(&projection.record_defects);
999 observed
1000 .record_drift
1001 .extend(projection.record_defects.iter().cloned());
1002 }
1003 if aligned && let Some(projection) = projected.as_ref() {
1004 observe_parameter_drift(manifest, projection, &mut observed);
1005 observe_record_set(manifest, projection, &mut observed.record_drift);
1006 }
1007 observed.pending = projected
1012 .as_ref()
1013 .map(|projection| pending_of(manifest, &projection.candidates));
1014 for (tool, landed) in &manifest.pins {
1018 if let Some(available) = registry::version_of(tool)
1019 && manifest::version_is_newer(&available, landed)
1020 {
1021 observed.stale.push(StalePin {
1022 tool: tool.clone(),
1023 landed: landed.clone(),
1024 available,
1025 });
1026 }
1027 }
1028 Ok(observed)
1029}
1030
1031fn observe_parameter_drift(manifest: &Manifest, projection: &Projection, observed: &mut Observed) {
1043 for candidate in &projection.candidates {
1044 if candidate.kind != Kind::Rendered {
1045 continue;
1046 }
1047 let Some(record) = manifest.file(&candidate.destination) else {
1048 continue;
1049 };
1050 if observed.drift_rendered.contains(&candidate.destination)
1051 || observed.missing.contains(&candidate.destination)
1052 {
1053 continue;
1054 }
1055 if Digest::of(recorded_form(candidate)) != record.sha256 {
1056 observed
1057 .parameter_drift
1058 .push(format!("{} (parameters)", candidate.destination));
1059 }
1060 }
1061}
1062
1063fn project(args: &StatusArgs, manifest: &Manifest) -> Result<Projection, RkError> {
1067 let evidence = TargetEvidence::gather(&args.target, Some(manifest))?;
1068 Projection::compute(&ProjectionInput {
1069 params: landing::Params::from_record(manifest),
1070 evidence,
1071 })
1072}
1073
1074fn recorded_form(candidate: &Candidate) -> &[u8] {
1077 candidate.region.as_deref().unwrap_or(&candidate.bytes)
1078}
1079
1080fn pending_of(manifest: &Manifest, projected: &[Candidate]) -> Vec<String> {
1089 let mut pending = Vec::new();
1090 for entry in projected {
1091 let changed = manifest.file(&entry.destination).is_none_or(|record| {
1092 record.kind != entry.kind
1093 || (entry.kind == Kind::Rendered
1094 && record.sha256 != Digest::of(recorded_form(entry)))
1095 });
1096 if changed {
1097 pending.push(entry.destination.clone());
1098 }
1099 }
1100 for file in &manifest.files {
1101 if !projected
1102 .iter()
1103 .any(|entry| entry.destination == file.destination)
1104 {
1105 pending.push(file.destination.clone());
1106 }
1107 }
1108 pending.sort();
1109 pending.dedup();
1110 pending
1111}
1112
1113fn observe_record_set(
1125 manifest: &Manifest,
1126 projection: &Projection,
1127 record_drift: &mut Vec<String>,
1128) {
1129 for entry in &projection.candidates {
1130 if manifest.file(&entry.destination).is_none() {
1131 record_drift.push(format!(
1132 "the recorded parameters project {}, which the receipt does not name",
1133 entry.destination
1134 ));
1135 }
1136 }
1137 for file in &manifest.files {
1138 let produced = projection
1139 .candidates
1140 .iter()
1141 .any(|entry| entry.destination == file.destination)
1142 || projection
1143 .omissions
1144 .iter()
1145 .any(|omission| omission.destination == file.destination);
1146 if !produced {
1147 record_drift.push(format!(
1148 "the receipt names {}, which the recorded parameters do not project",
1149 file.destination
1150 ));
1151 }
1152 }
1153}
1154
1155#[allow(
1157 clippy::too_many_lines,
1158 reason = "one pass prints every reportable condition in the order the report states them"
1159)]
1160fn render_human(
1161 out: Output,
1162 args: &StatusArgs,
1163 manifest: &Manifest,
1164 alignment: Alignment,
1165 observed: &Observed,
1166) {
1167 out.result_line(format!(
1168 "release-kit {} at {}: {}",
1169 manifest.rk_version,
1170 args.target,
1171 crate::commands::profile::describe(
1172 &manifest.profile,
1173 &manifest.git,
1174 &manifest.capabilities,
1175 &manifest.parameters.repo
1176 )
1177 ));
1178 for note in observed.selection.iter().flatten() {
1182 if note.status != "selected" {
1183 out.result_line(format!(
1184 "capability {}: {}{}",
1185 note.id,
1186 note.status,
1187 note.reason
1188 .as_deref()
1189 .map_or_else(String::new, |reason| format!(" ({reason})"))
1190 ));
1191 }
1192 }
1193 if alignment == Alignment::TargetNewer {
1194 out.result_line(format!(
1195 "binary {} is older than this landing; install the matching rk",
1196 env!("CARGO_PKG_VERSION")
1197 ));
1198 }
1199 match observed.pending.as_deref() {
1200 None => out.result_line(format!(
1201 "this binary carries no projection for the recorded {} release on {}, so what an upgrade would change is unknown",
1202 manifest
1203 .profile
1204 .release
1205 .driver
1206 .as_deref()
1207 .unwrap_or("release-less"),
1208 manifest.profile.forge.as_deref().unwrap_or("no forge")
1209 )),
1210 Some(paths) => {
1211 for path in paths {
1212 out.result_line(format!("PENDING {path} (this binary would change it)"));
1213 }
1214 }
1215 }
1216 for path in &observed.drift_rendered {
1217 let digest = observed.drift_digests.get(path).map_or("", String::as_str);
1218 out.result_line(observed.drift_reasons.get(path).map_or_else(
1219 || {
1220 format!(
1221 "DRIFT {path} (rendered, release-kit-owned): sha256 {digest}; a deliberate edit is accepted under landing.accepted_drift with its reason and this digest"
1222 )
1223 },
1224 |reason| {
1225 format!(
1226 "DRIFT {path} (rendered, edited past its accepted drift: {reason}; sha256 now {digest})"
1227 )
1228 },
1229 ));
1230 }
1231 for note in &observed.drift_accepted {
1232 out.result_line(format!(
1233 "ACCEPTED {} (rendered, accepted drift: {})",
1234 note.destination, note.reason
1235 ));
1236 }
1237 for path in &observed.accepted_stale {
1238 out.result_line(format!(
1239 "STALE landing.accepted_drift entry for {path}: the file matches the receipt, so the entry can go"
1240 ));
1241 }
1242 for path in &observed.parameter_drift {
1243 out.result_line(format!(
1244 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
1245 ));
1246 }
1247 for reason in &observed.record_drift {
1248 out.result_line(format!("DRIFT record: {reason}"));
1249 }
1250 for path in &observed.drift_seeded {
1251 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
1252 }
1253 for path in &observed.missing {
1254 out.result_line(format!("MISSING {path}"));
1255 }
1256 for pin in &observed.stale {
1257 out.result_line(format!(
1258 "STALE {} {} landed, {} in this binary",
1259 pin.tool, pin.landed, pin.available
1260 ));
1261 }
1262 for (path, line, text) in &observed.sentinels {
1263 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
1264 }
1265 for failure in &observed.invariants {
1266 out.result_line(format!(
1267 "INVARIANT {} ({}): {}",
1268 failure.destination, failure.code, failure.reason
1269 ));
1270 }
1271 for warning in &observed.warnings {
1272 out.result_line(format!("WARNING ({}): {}", warning.code, warning.reason));
1273 }
1274 let proof_failure = observed.setup_proof.as_ref().and_then(SetupProof::failure);
1275 if let Some(proof) = &observed.setup_proof {
1276 out.result_line(format!("setup proof: {}", proof.state));
1277 }
1278 if let Some(failure) = &proof_failure {
1279 out.result_line(format!(
1280 "SETUP-PROOF ({}): {}",
1281 failure.code, failure.reason
1282 ));
1283 }
1284 let mut next = Vec::new();
1285 for failure in observed.invariants.iter().chain(&proof_failure) {
1286 next.push(format!("{}: {}", failure.destination, failure.remediation));
1287 }
1288 if !observed.incompatible.is_empty() {
1292 next.push(format!(
1293 "rk upgrade --release-mode none --target {} retires the release intent this target cannot run; name a driver and forge this release carries to keep one",
1294 args.target
1295 ));
1296 }
1297 if observed.record_drift.len() > observed.incompatible.len() {
1298 next.push(format!(
1299 "rk upgrade --target {} rewrites the receipt from its parameters",
1300 args.target
1301 ));
1302 }
1303 if observed
1304 .pending
1305 .as_deref()
1306 .is_none_or(|paths| !paths.is_empty())
1307 {
1308 next.push(format!(
1309 "rk upgrade --target {} takes this landing to {}",
1310 args.target,
1311 env!("CARGO_PKG_VERSION")
1312 ));
1313 }
1314 next.push(format!(
1315 "rk status --check --target {} exits 1 on a violation",
1316 args.target
1317 ));
1318 if manifest.profile.release.mode != ReleaseMode::Automatic {
1319 next.retain(|line| !line.contains("rk method operate"));
1320 }
1321 out.next(&next);
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326 use super::{
1327 Classification, Drift, InvariantFailure, LegacyPath, OpenMigration, Report, SentinelAt,
1328 StalePin,
1329 };
1330 use crate::assess::{self, Facts, OwnLanding, Unrecorded};
1331 use crate::landing::CheckoutMode;
1332 use crate::landing::Integration;
1333 use crate::landing::manifest::Alignment;
1334 use crate::profile::{
1335 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
1336 };
1337
1338 #[test]
1341 fn accepted_drift_does_not_silence_an_invariant() {
1342 let destination = ".gitlab-ci.yml".to_owned();
1343 let digest = "a".repeat(64);
1344 let mut observed = super::Observed {
1345 drift_rendered: vec![destination.clone()],
1346 drift_digests: std::iter::once((destination.clone(), digest.clone())).collect(),
1347 drift_reasons: std::collections::BTreeMap::new(),
1348 drift_accepted: Vec::new(),
1349 accepted_stale: Vec::new(),
1350 drift_seeded: Vec::new(),
1351 parameter_drift: Vec::new(),
1352 record_drift: Vec::new(),
1353 missing: Vec::new(),
1354 stale: Vec::new(),
1355 sentinels: Vec::new(),
1356 invariants: vec![crate::landing::invariants::InvariantFailure {
1357 code: "an-invariant",
1358 destination: destination.clone(),
1359 reason: "the accepted bytes break it".to_owned(),
1360 remediation: "restore it",
1361 }],
1362 warnings: Vec::new(),
1363 setup_proof: None,
1364 incompatible: Vec::new(),
1365 pending: None,
1366 selection: None,
1367 };
1368 let manifest: crate::landing::manifest::Manifest = serde_json::from_str(&format!(
1369 r#"{{"schema_version":12,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{{"technologies":["bash"],"forge":"gitlab","release":{{"mode":"automatic","driver":"bash","style":"trunk","line_prefix":"release/"}}}},"git":{{"trunk":"master","checkout_mode":"linked-worktree","integration":"local","merge_form":"squash"}},"capabilities":{{"nix_packaging":false,"reporting_policy":true,"scorecard":false,"workflow_guards":false}},"parameters":{{"repo":"acme/widget"}},"files":[{{"destination":".gitlab-ci.yml","kind":"rendered","sha256":"{}"}}],"pins":{{}}}}"#,
1370 crate::digest::Digest::of(b"candidate")
1371 ))
1372 .expect("the record parses");
1373 let accepted = std::iter::once((
1374 destination,
1375 crate::config::AcceptedDrift {
1376 reason: "deliberate".to_owned(),
1377 sha256: digest,
1378 },
1379 ))
1380 .collect();
1381 super::accept(&mut observed, &manifest, &accepted).expect("accepts");
1382 assert!(observed.drift_rendered.is_empty());
1383 let violations = super::violations_of(&observed);
1384 assert!(
1385 violations.iter().any(|line| line.contains("an-invariant")),
1386 "{violations:?}"
1387 );
1388 }
1389
1390 #[test]
1393 #[allow(
1394 clippy::too_many_lines,
1395 reason = "the snapshot states every field of both forms in one place"
1396 )]
1397 fn the_status_report_schema_snapshot_holds() {
1398 let landed = Report {
1399 schema: super::SCHEMA,
1400 landed: true,
1401 classification: Some(Classification::Upgrade),
1402 git_work_tree: true,
1403 config: super::ConfigState {
1404 state: "pending",
1405 pending: vec!["profile.release.style".into()],
1406 },
1407 profile: Some(ProfileSnapshot {
1408 technologies: vec!["rust".into()],
1409 forge: Some("github".into()),
1410 release: ReleaseIntent {
1411 mode: ReleaseMode::Automatic,
1412 driver: Some("rust".into()),
1413 style: Some(crate::landing::Style::Trunk),
1414 line_prefix: Some("release/".into()),
1415 registry: None,
1416 build: None,
1417 },
1418 }),
1419 git: Some(GitWorkflow {
1420 trunk: "master".into(),
1421 checkout_mode: CheckoutMode::LinkedWorktree,
1422 integration: Integration::Local,
1423 merge_form: crate::landing::MergeForm::Squash,
1424 }),
1425 capabilities: Some(CapabilityRequests {
1426 nix_packaging: true,
1427 reporting_policy: true,
1428 scorecard: false,
1429 code_scanning: Some(crate::landing::Provider::Semgrep),
1430 workflow_guards: Some(false),
1431 }),
1432 selection: Some(vec![]),
1433 rk_version: Some("0.1.0".into()),
1434 binary_version: Some("0.2.0"),
1435 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
1436 drift: Some(Drift {
1437 rendered: 0,
1438 seeded: 1,
1439 }),
1440 missing: Some(vec![]),
1441 stale_pins: Some(vec![StalePin {
1442 tool: "release-plz".into(),
1443 landed: "0.3.160".into(),
1444 available: "0.3.170".into(),
1445 }]),
1446 sentinels: Some(1),
1447 sentinel_locations: Some(vec![SentinelAt {
1448 file: "release-plz.toml".into(),
1449 line: 18,
1450 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
1451 }]),
1452 record_drift: Some(0),
1453 invariant_failures: Some(vec![InvariantFailure {
1454 code: "attestations-disabled",
1455 destination: "dist-workspace.toml".into(),
1456 reason: "github-attestations is not effectively true".into(),
1457 remediation: "set github-attestations = true in [dist]",
1458 }]),
1459 warnings: Some(vec![super::Warning {
1460 code: super::CODE_SCANNING_LICENCE,
1461 reason: "the target's license, LicenseRef-proprietary, does not state a codebase this release recognizes as open source".into(),
1462 }]),
1463 setup_proof: Some(super::SetupProof {
1464 state: "stale",
1465 changed: vec!["target.required_check".into()],
1466 reason: None,
1467 detail: None,
1468 }),
1469 pending: Some(2),
1470 accepted_drift: Some(vec![super::AcceptedNote {
1471 destination: ".gitlab-ci.yml".into(),
1472 reason: "the image carries a tool".into(),
1473 }]),
1474 stale_accepted_drift: Some(vec![]),
1475 violations: None,
1476 legacy_state: vec![LegacyPath {
1477 path: "/state/release-kit/plans/0123456789abcdef".into(),
1478 kind: "plan",
1479 active: true,
1480 }],
1481 migration: Some(OpenMigration {
1482 id: "20261009T140205Z".into(),
1483 workspace: Some(
1484 "/state/release-kit/migrations/widget-0123456789ab/20261009T140205Z".into(),
1485 ),
1486 unfinished: Some(2),
1487 }),
1488 };
1489 assert_eq!(
1490 serde_json::to_string(&landed).expect("a report serializes"),
1491 r##"{"schema":"rk.status/15","landed":true,"classification":"upgrade","git_work_tree":true,"config":{"state":"pending","pending":["profile.release.style"]},"profile":{"technologies":["rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}},"git":{"trunk":"master","checkout_mode":"linked-worktree","integration":"local","merge_form":"squash"},"capabilities":{"nix_packaging":true,"reporting_policy":true,"scorecard":false,"code_scanning":"semgrep","workflow_guards":false},"selection":[],"rk_version":"0.1.0","binary_version":"0.2.0","alignment":"binary-newer","drift":{"rendered":0,"seeded":1},"missing":[],"stale_pins":[{"tool":"release-plz","landed":"0.3.160","available":"0.3.170"}],"sentinels":1,"sentinel_locations":[{"file":"release-plz.toml","line":18,"text":"# TODO(release-kit): keep false for a binary-only crate"}],"record_drift":0,"invariant_failures":[{"code":"attestations-disabled","destination":"dist-workspace.toml","reason":"github-attestations is not effectively true","remediation":"set github-attestations = true in [dist]"}],"warnings":[{"code":"code-scanning-licence","reason":"the target's license, LicenseRef-proprietary, does not state a codebase this release recognizes as open source"}],"setup_proof":{"state":"stale","changed":["target.required_check"]},"pending":2,"accepted_drift":[{"destination":".gitlab-ci.yml","reason":"the image carries a tool"}],"stale_accepted_drift":[],"legacy_state":[{"path":"/state/release-kit/plans/0123456789abcdef","kind":"plan","active":true}],"migration":{"id":"20261009T140205Z","workspace":"/state/release-kit/migrations/widget-0123456789ab/20261009T140205Z","unfinished":2}}"##
1492 );
1493 let absent = Report {
1494 landed: false,
1495 classification: Some(Classification::Setup),
1496 git_work_tree: false,
1497 config: super::ConfigState {
1498 state: "absent",
1499 pending: vec![],
1500 },
1501 profile: None,
1502 git: None,
1503 capabilities: None,
1504 selection: None,
1505 rk_version: None,
1506 binary_version: None,
1507 alignment: None,
1508 drift: None,
1509 missing: None,
1510 stale_pins: None,
1511 sentinels: None,
1512 sentinel_locations: None,
1513 record_drift: None,
1514 invariant_failures: None,
1515 warnings: None,
1516 setup_proof: None,
1517 pending: None,
1518 accepted_drift: None,
1519 stale_accepted_drift: None,
1520 violations: None,
1521 legacy_state: vec![],
1522 migration: None,
1523 ..landed
1524 };
1525 assert_eq!(
1526 serde_json::to_string(&absent).expect("a report serializes"),
1527 r#"{"schema":"rk.status/15","landed":false,"classification":"setup","git_work_tree":false,"config":{"state":"absent","pending":[]},"legacy_state":[]}"#,
1528 "an absent landing reports the fields a caller can branch on"
1529 );
1530 let newer = Report {
1531 classification: None,
1532 ..absent
1533 };
1534 assert!(
1535 serde_json::to_string(&newer)
1536 .expect("a report serializes")
1537 .contains(r#""classification":null"#),
1538 "no classification is stated as null, never omitted"
1539 );
1540 }
1541
1542 fn facts() -> Facts {
1543 Facts {
1544 technologies: Vec::new(),
1545 forge: None,
1546 repo: None,
1547 release_markers: Vec::new(),
1548 collisions: Vec::new(),
1549 git: true,
1550 tags: 0,
1551 long_lived_branches: Vec::new(),
1552 }
1553 }
1554
1555 #[test]
1560 fn every_classification_has_its_rule() {
1561 let none = OwnLanding::default();
1562 let classify = |facts: &Facts, own: &OwnLanding| {
1563 super::unrecorded(assess::classify_unrecorded(facts, own))
1564 };
1565 assert_eq!(classify(&facts(), &none), Classification::Setup);
1566 let marked = Facts {
1567 release_markers: vec![".goreleaser.yaml".into()],
1568 ..facts()
1569 };
1570 assert_eq!(classify(&marked, &none), Classification::Migration);
1571 let tagged = Facts { tags: 3, ..facts() };
1572 assert_eq!(classify(&tagged, &none), Classification::NeedsDecision);
1573 let own = OwnLanding {
1576 regions: vec!["AGENTS.md".into()],
1577 rendered: Vec::new(),
1578 };
1579 let colliding = Facts {
1580 collisions: vec!["AGENTS.md".into()],
1581 release_markers: vec!["CHANGELOG.md".into()],
1582 ..facts()
1583 };
1584 assert_eq!(classify(&colliding, &own), Classification::Adopt);
1585 assert_eq!(
1586 assess::classify_unrecorded(&colliding, &own),
1587 Unrecorded::Adopt
1588 );
1589 assert_eq!(
1590 super::recorded(Alignment::BinaryNewer, false),
1591 Some(Classification::Upgrade)
1592 );
1593 assert_eq!(
1594 super::recorded(Alignment::Aligned, false),
1595 Some(Classification::Drift)
1596 );
1597 assert_eq!(
1598 super::recorded(Alignment::Aligned, true),
1599 Some(Classification::Current)
1600 );
1601 assert_eq!(super::recorded(Alignment::TargetNewer, true), None);
1602 for (class, word) in [
1603 (Classification::Setup, "setup"),
1604 (Classification::Migration, "migration"),
1605 (Classification::NeedsDecision, "needs-decision"),
1606 (Classification::Adopt, "adopt"),
1607 (Classification::Upgrade, "upgrade"),
1608 (Classification::Drift, "drift"),
1609 (Classification::Current, "current"),
1610 ] {
1611 assert_eq!(class.as_str(), word);
1612 assert_eq!(
1613 serde_json::to_string(&class).expect("serializes"),
1614 format!("\"{word}\"")
1615 );
1616 }
1617 }
1618}