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 NESTED_HOOK_CONFIGURATION: &str = "nested-hook-configuration";
158
159const CODE_SCANNING_LICENCE: &str = "code-scanning-licence";
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
167#[serde(rename_all = "kebab-case")]
168pub enum Classification {
169 Setup,
171 Migration,
173 NeedsDecision,
175 Adopt,
177 Upgrade,
179 Drift,
181 Current,
183}
184
185impl Classification {
186 #[must_use]
188 pub const fn as_str(self) -> &'static str {
189 match self {
190 Self::Setup => "setup",
191 Self::Migration => "migration",
192 Self::NeedsDecision => "needs-decision",
193 Self::Adopt => "adopt",
194 Self::Upgrade => "upgrade",
195 Self::Drift => "drift",
196 Self::Current => "current",
197 }
198 }
199}
200
201const fn unrecorded(class: assess::Unrecorded) -> Classification {
204 match class {
205 assess::Unrecorded::Adopt => Classification::Adopt,
206 assess::Unrecorded::Assessed(assess::Classification::Greenfield) => Classification::Setup,
207 assess::Unrecorded::Assessed(assess::Classification::Brownfield) => {
208 Classification::Migration
209 }
210 assess::Unrecorded::Assessed(assess::Classification::NeedsDecision) => {
211 Classification::NeedsDecision
212 }
213 }
214}
215
216const fn recorded(alignment: Alignment, clean: bool) -> Option<Classification> {
220 match alignment {
221 Alignment::TargetNewer => None,
222 Alignment::BinaryNewer => Some(Classification::Upgrade),
223 Alignment::Aligned if clean => Some(Classification::Current),
224 Alignment::Aligned => Some(Classification::Drift),
225 }
226}
227
228#[derive(Debug, Clone, Serialize)]
230struct SentinelAt {
231 file: String,
233 line: usize,
235 text: String,
237}
238
239#[derive(Debug, Serialize)]
241struct LegacyPath {
242 path: String,
244 kind: &'static str,
246 active: bool,
248}
249
250fn legacy_state() -> Vec<LegacyPath> {
259 let Some(root) = crate::applog::state_root() else {
260 return Vec::new();
261 };
262 let mut found = Vec::new();
263 if let Ok(entries) = std::fs::read_dir(root.join(LEGACY_PLANS)) {
264 let mut plans: Vec<std::path::PathBuf> = entries
265 .flatten()
266 .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
267 .map(|entry| entry.path())
268 .filter(|path| path.is_dir())
269 .collect();
270 plans.sort();
271 for plan in plans {
272 let active = std::fs::read(plan.join("plan.json"))
273 .ok()
274 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
275 .is_none_or(|document| document["readiness"].as_str() == Some("ready"));
276 found.push(LegacyPath {
277 path: plan.display().to_string(),
278 kind: "plan",
279 active,
280 });
281 }
282 }
283 let cache = root.join(LEGACY_RELEASE_CACHE);
284 if cache.is_dir() {
285 found.push(LegacyPath {
286 path: cache.display().to_string(),
287 kind: "release-cache",
288 active: false,
289 });
290 }
291 found
292}
293
294const LEGACY_PLANS: &str = "plans";
296
297const LEGACY_RELEASE_CACHE: &str = "release";
299
300const SCHEMA: &str = "rk.status/15";
302
303#[derive(Debug, serde::Serialize)]
305struct ConfigState {
306 state: &'static str,
307 pending: Vec<String>,
308}
309
310fn config_state(config: Option<&crate::config::Config>, record: Option<&Manifest>) -> ConfigState {
311 let pending = config
312 .zip(record)
313 .map_or_else(Vec::new, |(config, record)| {
314 crate::config::pending(config, record)
315 });
316 ConfigState {
317 state: if config.is_none() {
318 "absent"
319 } else if pending.is_empty() {
320 "aligned"
321 } else {
322 "pending"
323 },
324 pending,
325 }
326}
327
328#[derive(Debug, Serialize)]
330struct Report {
331 schema: &'static str,
333 landed: bool,
335 classification: Option<Classification>,
338 git_work_tree: bool,
340 config: ConfigState,
341 #[serde(skip_serializing_if = "Option::is_none")]
343 profile: Option<ProfileSnapshot>,
344 #[serde(skip_serializing_if = "Option::is_none")]
346 git: Option<GitWorkflow>,
347 #[serde(skip_serializing_if = "Option::is_none")]
349 capabilities: Option<CapabilityRequests>,
350 #[serde(skip_serializing_if = "Option::is_none")]
353 selection: Option<Vec<CapabilityNote>>,
354 #[serde(skip_serializing_if = "Option::is_none")]
355 rk_version: Option<String>,
356 #[serde(skip_serializing_if = "Option::is_none")]
357 binary_version: Option<&'static str>,
358 #[serde(skip_serializing_if = "Option::is_none")]
359 alignment: Option<Alignment>,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 drift: Option<Drift>,
362 #[serde(skip_serializing_if = "Option::is_none")]
364 missing: Option<Vec<String>>,
365 #[serde(skip_serializing_if = "Option::is_none")]
366 stale_pins: Option<Vec<StalePin>>,
367 #[serde(skip_serializing_if = "Option::is_none")]
369 sentinels: Option<usize>,
370 #[serde(skip_serializing_if = "Option::is_none")]
372 sentinel_locations: Option<Vec<SentinelAt>>,
373 #[serde(skip_serializing_if = "Option::is_none")]
377 record_drift: Option<usize>,
378 #[serde(skip_serializing_if = "Option::is_none")]
381 invariant_failures: Option<Vec<InvariantFailure>>,
382 #[serde(skip_serializing_if = "Option::is_none")]
386 warnings: Option<Vec<Warning>>,
387 #[serde(skip_serializing_if = "Option::is_none")]
390 setup_proof: Option<SetupProof>,
391 #[serde(skip_serializing_if = "Option::is_none")]
397 pending: Option<usize>,
398 #[serde(skip_serializing_if = "Option::is_none")]
401 accepted_drift: Option<Vec<AcceptedNote>>,
402 #[serde(skip_serializing_if = "Option::is_none")]
405 stale_accepted_drift: Option<Vec<String>>,
406 #[serde(skip_serializing_if = "Option::is_none")]
408 violations: Option<Vec<String>>,
409 legacy_state: Vec<LegacyPath>,
411 #[serde(skip_serializing_if = "Option::is_none")]
413 migration: Option<OpenMigration>,
414}
415
416#[derive(Debug, Serialize)]
418struct OpenMigration {
419 id: String,
421 #[serde(skip_serializing_if = "Option::is_none")]
423 workspace: Option<String>,
424 #[serde(skip_serializing_if = "Option::is_none")]
427 unfinished: Option<usize>,
428}
429
430fn open_migration(
435 target: &camino::Utf8Path,
436 config: Option<&crate::config::Config>,
437) -> Option<OpenMigration> {
438 let id = config.and_then(|config| config.migration.clone())?;
439 let workspace = crate::migrate::open_workspace(target).ok().flatten();
440 let unfinished = workspace
441 .as_ref()
442 .filter(|workspace| workspace.join(crate::migrate::REGISTRY_FILE).is_file())
443 .and_then(|workspace| crate::migrate::registry::load(workspace).ok())
444 .map(|registry| {
445 registry
446 .sources
447 .iter()
448 .filter(|source| !source.status.finished())
449 .count()
450 });
451 Some(OpenMigration {
452 id,
453 workspace: workspace.map(|workspace| workspace.to_string()),
454 unfinished,
455 })
456}
457
458fn render_migration(out: Output, migration: Option<&OpenMigration>) {
460 if let Some(migration) = migration {
461 out.result_line(format!(
462 "migration {} is open; rk migrate status reads it",
463 migration.id
464 ));
465 }
466}
467
468fn report_absent(
469 out: Output,
470 args: &StatusArgs,
471 config: Option<&crate::config::Config>,
472 git_work_tree: bool,
473) -> Result<(), RkError> {
474 let migration = open_migration(&args.target, config);
475 let classification = match assess::standing(&args.target, config)? {
476 assess::Standing::Migrating => Classification::Migration,
477 assess::Standing::Unrecorded(class) => unrecorded(class),
478 };
479 let legacy = legacy_state();
480 out.result_line(format!("config: {}", config_state(config, None).state));
481 out.result_line(format!("no landing at {}", args.target));
482 out.result_line(format!("classification: {}", classification.as_str()));
483 render_migration(out, migration.as_ref());
484 render_context(out, git_work_tree, &legacy);
485 out.next(&[
486 format!(
487 "rk profile --target {} reports what a landing would select",
488 args.target
489 ),
490 format!("rk init --target {} lands the workflow", args.target),
491 format!(
492 "rk adopt --target {} records a landing made before the receipt existed",
493 args.target
494 ),
495 format!(
496 "rk stage --target {} stages this binary's candidate; the rk-setup skill carries the best-effort migration",
497 args.target
498 ),
499 ]);
500 out.emit(&Report {
501 schema: SCHEMA,
502 landed: false,
503 classification: Some(classification),
504 git_work_tree,
505 config: config_state(config, None),
506 profile: None,
507 git: None,
508 capabilities: None,
509 selection: None,
510 rk_version: None,
511 binary_version: None,
512 alignment: None,
513 drift: None,
514 missing: None,
515 stale_pins: None,
516 sentinels: None,
517 sentinel_locations: None,
518 record_drift: None,
519 invariant_failures: None,
520 warnings: None,
521 setup_proof: None,
522 pending: None,
523 accepted_drift: None,
524 stale_accepted_drift: None,
525 violations: args.check.then(|| vec!["no landing".to_owned()]),
526 legacy_state: legacy,
527 migration,
528 })?;
529 if args.check {
530 return Err(RkError::check_failed(
531 Diagnostic::new(
532 Reason::StateDrift,
533 format!("no landing at {}, and --check requires one", args.target),
534 )
535 .expected("a target carrying .release-kit/manifest.json")
536 .action("rk init lands the workflow; rk adopt records an existing landing; rk stage and the rk-setup skill carry the migration"),
537 ));
538 }
539 Ok(())
540}
541
542#[derive(Debug, Clone, Serialize)]
544struct AcceptedNote {
545 destination: String,
547 reason: String,
549}
550
551struct Observed {
553 drift_rendered: Vec<String>,
554 drift_digests: std::collections::BTreeMap<String, String>,
557 drift_reasons: std::collections::BTreeMap<String, String>,
560 drift_accepted: Vec<AcceptedNote>,
562 accepted_stale: Vec<String>,
564 drift_seeded: Vec<String>,
565 parameter_drift: Vec<String>,
568 record_drift: Vec<String>,
573 missing: Vec<String>,
574 stale: Vec<StalePin>,
575 sentinels: Vec<(String, usize, String)>,
576 invariants: Vec<InvariantFailure>,
577 warnings: Vec<Warning>,
579 setup_proof: Option<SetupProof>,
580 incompatible: Vec<String>,
586 pending: Option<Vec<String>>,
590 selection: Option<Vec<CapabilityNote>>,
592}
593
594#[allow(
603 clippy::too_many_lines,
604 reason = "one pass reads the target, judges it, and emits the one report every reader takes"
605)]
606pub fn run(args: &StatusArgs) -> Result<(), RkError> {
607 crate::requirements::require("rk status", &[crate::requirements::Tool::Git])?;
608 let out = Output::new(args.json);
609 if !args.target.is_dir() {
610 return Err(RkError::missing(
611 Diagnostic::new(
612 Reason::TargetNotFound,
613 format!("target {} is not a directory", args.target),
614 )
615 .expected("an existing repository to report on"),
616 ));
617 }
618 let config = crate::config::load(args.target.as_std_path())?;
619 let git_work_tree = assess::git_work_tree(&args.target)?;
620 let Some(manifest) = manifest::load(&args.target)? else {
621 return report_absent(out, args, config.as_ref(), git_work_tree);
622 };
623 if manifest
628 .profile
629 .release
630 .build
631 .as_ref()
632 .is_some_and(|build| build.command.is_some())
633 {
634 return Err(crate::profile::retired_build_command());
635 }
636
637 let migration = open_migration(&args.target, config.as_ref());
638 let accepted = config
639 .as_ref()
640 .map(|config| config.landing.accepted_drift.clone())
641 .unwrap_or_default();
642 let stated = Stated::of(config.as_ref(), &manifest);
643 let config = config_state(config.as_ref(), Some(&manifest));
644 out.result_line(format!("config: {}", config.state));
645 for key in &config.pending {
646 out.result_line(format!(
647 "config pending: {key}; rk upgrade --apply takes it up"
648 ));
649 }
650 let mut observed = observe(args, &manifest, &stated)?;
651 accept(&mut observed, &manifest, &accepted)?;
652 observed.setup_proof = setup_proof_of(&args.target, &manifest)?;
653 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
654 render_human(out, args, &manifest, alignment, &observed);
655
656 let violations = violations_of(&observed);
657 let classification = if migration.is_some() && alignment != Alignment::TargetNewer {
661 Some(Classification::Migration)
662 } else {
663 recorded(alignment, violations.is_empty())
664 };
665 out.result_line(format!(
666 "classification: {}",
667 classification.map_or(
668 "none: the record is newer than this binary",
669 Classification::as_str
670 )
671 ));
672 render_migration(out, migration.as_ref());
673 let legacy = legacy_state();
674 render_context(out, git_work_tree, &legacy);
675 out.emit(&Report {
676 schema: SCHEMA,
677 landed: true,
678 classification,
679 git_work_tree,
680 config,
681 profile: Some(manifest.profile.clone()),
682 git: Some(manifest.git.clone()),
683 capabilities: Some(manifest.capabilities.clone()),
684 selection: observed.selection.clone(),
685 rk_version: Some(manifest.rk_version),
686 binary_version: Some(env!("CARGO_PKG_VERSION")),
687 alignment: Some(alignment),
688 drift: Some(Drift {
689 rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
690 seeded: observed.drift_seeded.len(),
691 }),
692 record_drift: Some(observed.record_drift.len()),
693 missing: Some(observed.missing.clone()),
694 stale_pins: Some(observed.stale),
695 sentinels: Some(observed.sentinels.len()),
696 sentinel_locations: Some(
697 observed
698 .sentinels
699 .iter()
700 .map(|(file, line, text)| SentinelAt {
701 file: file.clone(),
702 line: *line,
703 text: text.clone(),
704 })
705 .collect(),
706 ),
707 invariant_failures: Some(observed.invariants),
708 warnings: Some(observed.warnings),
709 setup_proof: observed.setup_proof.clone(),
710 pending: observed.pending.as_ref().map(Vec::len),
711 accepted_drift: Some(observed.drift_accepted.clone()),
712 stale_accepted_drift: Some(observed.accepted_stale.clone()),
713 violations: args.check.then(|| violations.clone()),
714 legacy_state: legacy,
715 migration,
716 })?;
717
718 if args.check && !violations.is_empty() {
719 return Err(RkError::check_failed(
720 Diagnostic::new(
721 Reason::StateDrift,
722 format!(
723 "the landing is not clean: {} violation{}",
724 violations.len(),
725 if violations.len() == 1 { "" } else { "s" }
726 ),
727 )
728 .expected(
729 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
730 ),
731 ));
732 }
733 Ok(())
734}
735
736fn render_context(out: Output, git_work_tree: bool, legacy: &[LegacyPath]) {
740 if !git_work_tree {
741 out.result_line("not a non-bare Git work tree: the method lands files a work tree commits");
742 }
743 for entry in legacy {
744 out.result_line(format!(
745 "LEGACY {} ({}, {})",
746 entry.path,
747 entry.kind,
748 if entry.active { "active" } else { "inactive" }
749 ));
750 }
751}
752
753fn accept(
763 observed: &mut Observed,
764 manifest: &Manifest,
765 accepted: &std::collections::BTreeMap<String, crate::config::AcceptedDrift>,
766) -> Result<(), RkError> {
767 for (destination, entry) in accepted {
768 let Some(file) = manifest.file(destination) else {
769 return Err(crate::config::invalid(format!(
770 "landing.accepted_drift names {destination}, which the receipt does not record"
771 )));
772 };
773 if file.kind != Kind::Rendered || file.placement != manifest::Placement::Whole {
774 return Err(crate::config::invalid(format!(
775 "landing.accepted_drift names {destination}, which is not a whole rendered file: only such a file has bytes release-kit owns whole"
776 )));
777 }
778 if !observed.drift_rendered.contains(destination) {
779 if !observed.missing.contains(destination) {
780 observed.accepted_stale.push(destination.clone());
781 }
782 continue;
783 }
784 if observed.drift_digests.get(destination) == Some(&entry.sha256) {
785 observed.drift_rendered.retain(|path| path != destination);
786 observed.drift_accepted.push(AcceptedNote {
787 destination: destination.clone(),
788 reason: entry.reason.clone(),
789 });
790 } else {
791 observed
792 .drift_reasons
793 .insert(destination.clone(), entry.reason.clone());
794 }
795 }
796 Ok(())
797}
798
799fn violations_of(observed: &Observed) -> Vec<String> {
803 observed
804 .drift_rendered
805 .iter()
806 .map(|path| {
807 let digest = observed.drift_digests.get(path).map_or("", String::as_str);
808 observed.drift_reasons.get(path).map_or_else(
809 || format!("rendered drift: {path}; its sha256 is {digest}"),
810 |reason| {
811 format!(
812 "rendered drift: {path}, edited past its accepted drift ({reason}); its sha256 is now {digest}"
813 )
814 },
815 )
816 })
817 .chain(
818 observed
819 .parameter_drift
820 .iter()
821 .map(|path| format!("parameter drift: {path}")),
822 )
823 .chain(
824 observed
825 .record_drift
826 .iter()
827 .map(|reason| format!("record drift: {reason}")),
828 )
829 .chain(
830 observed
831 .missing
832 .iter()
833 .map(|path| format!("missing: {path}")),
834 )
835 .chain(
836 observed
837 .sentinels
838 .iter()
839 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
840 )
841 .chain(
842 observed
843 .invariants
844 .iter()
845 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
846 )
847 .chain(
848 observed
849 .setup_proof
850 .as_ref()
851 .and_then(SetupProof::failure)
852 .map(|failure| format!("setup proof: {}", failure.code)),
853 )
854 .collect()
855}
856
857struct Stated {
860 registry: Option<RegistryAnswer>,
862 required_check: String,
864}
865
866impl Stated {
867 fn of(config: Option<&crate::config::Config>, manifest: &Manifest) -> Self {
868 Self {
869 registry: config
870 .and_then(|config| config.profile.release.registry)
871 .or(manifest.profile.release.registry),
872 required_check: config.map_or_else(
875 || manifest.parameters.required_check.clone(),
876 |config| config.setup.required_check.clone(),
877 ),
878 }
879 }
880}
881
882#[allow(
885 clippy::too_many_lines,
886 reason = "one pass over the record and the disk answers every comparison the report states"
887)]
888fn observe(args: &StatusArgs, manifest: &Manifest, stated: &Stated) -> Result<Observed, RkError> {
889 let mut observed = Observed {
890 drift_rendered: Vec::new(),
891 drift_digests: std::collections::BTreeMap::new(),
892 drift_reasons: std::collections::BTreeMap::new(),
893 drift_accepted: Vec::new(),
894 accepted_stale: Vec::new(),
895 drift_seeded: Vec::new(),
896 parameter_drift: Vec::new(),
897 record_drift: Vec::new(),
898 missing: Vec::new(),
899 stale: Vec::new(),
900 sentinels: Vec::new(),
901 invariants: Vec::new(),
902 warnings: Vec::new(),
903 setup_proof: None,
904 incompatible: Vec::new(),
905 pending: None,
906 selection: None,
907 };
908 let aligned =
914 manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION")) == Alignment::Aligned;
915 let projected = match project(args, manifest) {
916 Ok(projection) => Some(projection),
917 Err(err) if aligned => return Err(err),
918 Err(_) => None,
919 };
920 for file in &manifest.files {
921 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
922 observed.missing.push(file.destination.clone());
923 continue;
924 };
925 if Digest::of(&bytes) != file.sha256 {
926 match file.kind {
927 Kind::Rendered => {
928 observed.drift_rendered.push(file.destination.clone());
929 observed
930 .drift_digests
931 .insert(file.destination.clone(), Digest::of(&bytes).to_string());
932 }
933 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
934 Kind::State => {}
935 }
936 }
937 observed.invariants.extend(invariants::failures(
938 manifest.profile.release.driver.as_deref().unwrap_or(""),
939 manifest.profile.forge.as_deref().unwrap_or(""),
940 &file.destination,
941 &bytes,
942 stated.registry,
943 ));
944 let text = String::from_utf8_lossy(&bytes);
945 for (idx, line) in text.lines().enumerate() {
946 if line.contains(embedded::SENTINEL) {
947 observed.sentinels.push((
948 file.destination.clone(),
949 idx + 1,
950 line.trim().to_owned(),
951 ));
952 }
953 }
954 let defective = projected.as_ref().is_some_and(|projection| {
959 projection
960 .collisions
961 .iter()
962 .any(|collision| collision.destination == file.destination)
963 });
964 if defective && !observed.drift_rendered.contains(&file.destination) {
965 observed.drift_rendered.push(file.destination.clone());
966 }
967 }
968 observed.invariants.extend(invariants::target_failures(
973 manifest.profile.release.driver.as_deref().unwrap_or(""),
974 manifest.profile.forge.as_deref().unwrap_or(""),
975 &args.target,
976 ));
977 if manifest.profile.release.driver.as_deref() == Some("rust")
980 && manifest.profile.forge.as_deref() == Some("github")
981 {
982 let dev_run = manifest
983 .parameters
984 .dev_environment
985 .as_ref()
986 .map_or("", |answer| answer.run.as_str());
987 let gate = invariants::dist_targets_built(&args.target, &stated.required_check, dev_run);
988 observed.invariants.extend(gate.failures);
989 observed.warnings.extend(
990 gate.limitations
991 .into_iter()
992 .map(|(code, reason)| Warning { code, reason }),
993 );
994 }
995 observed.selection = projected.as_ref().map(|projection| {
996 projection
997 .capabilities
998 .iter()
999 .map(|selection| CapabilityNote::of(selection, projection))
1000 .collect()
1001 });
1002 if let Some(reason) = projected
1003 .as_ref()
1004 .and_then(|projection| projection.licence_refusal.clone())
1005 {
1006 observed.warnings.push(Warning {
1007 code: CODE_SCANNING_LICENCE,
1008 reason,
1009 });
1010 }
1011 for path in crate::hooks::unexcluded_nested_configs(args.target.as_std_path()) {
1014 observed.warnings.push(Warning {
1015 code: NESTED_HOOK_CONFIGURATION,
1016 reason: format!(
1017 "{path} is a hook configuration below the root, which prek runs as a project of its own; where it is no hook configuration of this project, list its directory in a root .prekignore"
1018 ),
1019 });
1020 }
1021 if let Some(projection) = projected.as_ref() {
1026 observed.incompatible.clone_from(&projection.record_defects);
1027 observed
1028 .record_drift
1029 .extend(projection.record_defects.iter().cloned());
1030 }
1031 if aligned && let Some(projection) = projected.as_ref() {
1032 observe_parameter_drift(manifest, projection, &mut observed);
1033 observe_record_set(manifest, projection, &mut observed.record_drift);
1034 }
1035 observed.pending = projected
1040 .as_ref()
1041 .map(|projection| pending_of(manifest, &projection.candidates));
1042 for (tool, landed) in &manifest.pins {
1046 if let Some(available) = registry::version_of(tool)
1047 && manifest::version_is_newer(&available, landed)
1048 {
1049 observed.stale.push(StalePin {
1050 tool: tool.clone(),
1051 landed: landed.clone(),
1052 available,
1053 });
1054 }
1055 }
1056 Ok(observed)
1057}
1058
1059fn observe_parameter_drift(manifest: &Manifest, projection: &Projection, observed: &mut Observed) {
1071 for candidate in &projection.candidates {
1072 if candidate.kind != Kind::Rendered {
1073 continue;
1074 }
1075 let Some(record) = manifest.file(&candidate.destination) else {
1076 continue;
1077 };
1078 if observed.drift_rendered.contains(&candidate.destination)
1079 || observed.missing.contains(&candidate.destination)
1080 {
1081 continue;
1082 }
1083 if Digest::of(recorded_form(candidate)) != record.sha256 {
1084 observed
1085 .parameter_drift
1086 .push(format!("{} (parameters)", candidate.destination));
1087 }
1088 }
1089}
1090
1091fn project(args: &StatusArgs, manifest: &Manifest) -> Result<Projection, RkError> {
1095 let evidence = TargetEvidence::gather(&args.target, Some(manifest))?;
1096 Projection::compute(&ProjectionInput {
1097 params: landing::Params::from_record(manifest),
1098 evidence,
1099 })
1100}
1101
1102fn recorded_form(candidate: &Candidate) -> &[u8] {
1105 candidate.region.as_deref().unwrap_or(&candidate.bytes)
1106}
1107
1108fn pending_of(manifest: &Manifest, projected: &[Candidate]) -> Vec<String> {
1117 let mut pending = Vec::new();
1118 for entry in projected {
1119 let changed = manifest.file(&entry.destination).is_none_or(|record| {
1120 record.kind != entry.kind
1121 || (entry.kind == Kind::Rendered
1122 && record.sha256 != Digest::of(recorded_form(entry)))
1123 });
1124 if changed {
1125 pending.push(entry.destination.clone());
1126 }
1127 }
1128 for file in &manifest.files {
1129 if !projected
1130 .iter()
1131 .any(|entry| entry.destination == file.destination)
1132 {
1133 pending.push(file.destination.clone());
1134 }
1135 }
1136 pending.sort();
1137 pending.dedup();
1138 pending
1139}
1140
1141fn observe_record_set(
1153 manifest: &Manifest,
1154 projection: &Projection,
1155 record_drift: &mut Vec<String>,
1156) {
1157 for entry in &projection.candidates {
1158 if manifest.file(&entry.destination).is_none() {
1159 record_drift.push(format!(
1160 "the recorded parameters project {}, which the receipt does not name",
1161 entry.destination
1162 ));
1163 }
1164 }
1165 for file in &manifest.files {
1166 let produced = projection
1167 .candidates
1168 .iter()
1169 .any(|entry| entry.destination == file.destination)
1170 || projection
1171 .omissions
1172 .iter()
1173 .any(|omission| omission.destination == file.destination);
1174 if !produced {
1175 record_drift.push(format!(
1176 "the receipt names {}, which the recorded parameters do not project",
1177 file.destination
1178 ));
1179 }
1180 }
1181}
1182
1183#[allow(
1185 clippy::too_many_lines,
1186 reason = "one pass prints every reportable condition in the order the report states them"
1187)]
1188fn render_human(
1189 out: Output,
1190 args: &StatusArgs,
1191 manifest: &Manifest,
1192 alignment: Alignment,
1193 observed: &Observed,
1194) {
1195 out.result_line(format!(
1196 "release-kit {} at {}: {}",
1197 manifest.rk_version,
1198 args.target,
1199 crate::commands::profile::describe(
1200 &manifest.profile,
1201 &manifest.git,
1202 &manifest.capabilities,
1203 &manifest.parameters.repo
1204 )
1205 ));
1206 for note in observed.selection.iter().flatten() {
1210 if note.status != "selected" {
1211 out.result_line(format!(
1212 "capability {}: {}{}",
1213 note.id,
1214 note.status,
1215 note.reason
1216 .as_deref()
1217 .map_or_else(String::new, |reason| format!(" ({reason})"))
1218 ));
1219 }
1220 }
1221 if alignment == Alignment::TargetNewer {
1222 out.result_line(format!(
1223 "binary {} is older than this landing; install the matching rk",
1224 env!("CARGO_PKG_VERSION")
1225 ));
1226 }
1227 match observed.pending.as_deref() {
1228 None => out.result_line(format!(
1229 "this binary carries no projection for the recorded {} release on {}, so what an upgrade would change is unknown",
1230 manifest
1231 .profile
1232 .release
1233 .driver
1234 .as_deref()
1235 .unwrap_or("release-less"),
1236 manifest.profile.forge.as_deref().unwrap_or("no forge")
1237 )),
1238 Some(paths) => {
1239 for path in paths {
1240 out.result_line(format!("PENDING {path} (this binary would change it)"));
1241 }
1242 }
1243 }
1244 for path in &observed.drift_rendered {
1245 let digest = observed.drift_digests.get(path).map_or("", String::as_str);
1246 out.result_line(observed.drift_reasons.get(path).map_or_else(
1247 || {
1248 format!(
1249 "DRIFT {path} (rendered, release-kit-owned): sha256 {digest}; a deliberate edit is accepted under landing.accepted_drift with its reason and this digest"
1250 )
1251 },
1252 |reason| {
1253 format!(
1254 "DRIFT {path} (rendered, edited past its accepted drift: {reason}; sha256 now {digest})"
1255 )
1256 },
1257 ));
1258 }
1259 for note in &observed.drift_accepted {
1260 out.result_line(format!(
1261 "ACCEPTED {} (rendered, accepted drift: {})",
1262 note.destination, note.reason
1263 ));
1264 }
1265 for path in &observed.accepted_stale {
1266 out.result_line(format!(
1267 "STALE landing.accepted_drift entry for {path}: the file matches the receipt, so the entry can go"
1268 ));
1269 }
1270 for path in &observed.parameter_drift {
1271 out.result_line(format!(
1272 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
1273 ));
1274 }
1275 for reason in &observed.record_drift {
1276 out.result_line(format!("DRIFT record: {reason}"));
1277 }
1278 for path in &observed.drift_seeded {
1279 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
1280 }
1281 for path in &observed.missing {
1282 out.result_line(format!("MISSING {path}"));
1283 }
1284 for pin in &observed.stale {
1285 out.result_line(format!(
1286 "STALE {} {} landed, {} in this binary",
1287 pin.tool, pin.landed, pin.available
1288 ));
1289 }
1290 for (path, line, text) in &observed.sentinels {
1291 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
1292 }
1293 for failure in &observed.invariants {
1294 out.result_line(format!(
1295 "INVARIANT {} ({}): {}",
1296 failure.destination, failure.code, failure.reason
1297 ));
1298 }
1299 for warning in &observed.warnings {
1300 out.result_line(format!("WARNING ({}): {}", warning.code, warning.reason));
1301 }
1302 let proof_failure = observed.setup_proof.as_ref().and_then(SetupProof::failure);
1303 if let Some(proof) = &observed.setup_proof {
1304 out.result_line(format!("setup proof: {}", proof.state));
1305 }
1306 if let Some(failure) = &proof_failure {
1307 out.result_line(format!(
1308 "SETUP-PROOF ({}): {}",
1309 failure.code, failure.reason
1310 ));
1311 }
1312 let mut next = Vec::new();
1313 for failure in observed.invariants.iter().chain(&proof_failure) {
1314 next.push(format!("{}: {}", failure.destination, failure.remediation));
1315 }
1316 if !observed.incompatible.is_empty() {
1320 next.push(format!(
1321 "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",
1322 args.target
1323 ));
1324 }
1325 if observed.record_drift.len() > observed.incompatible.len() {
1326 next.push(format!(
1327 "rk upgrade --target {} rewrites the receipt from its parameters",
1328 args.target
1329 ));
1330 }
1331 if observed
1332 .pending
1333 .as_deref()
1334 .is_none_or(|paths| !paths.is_empty())
1335 {
1336 next.push(format!(
1337 "rk upgrade --target {} takes this landing to {}",
1338 args.target,
1339 env!("CARGO_PKG_VERSION")
1340 ));
1341 }
1342 next.push(format!(
1343 "rk status --check --target {} exits 1 on a violation",
1344 args.target
1345 ));
1346 if manifest.profile.release.mode != ReleaseMode::Automatic {
1347 next.retain(|line| !line.contains("rk method operate"));
1348 }
1349 out.next(&next);
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354 use super::{
1355 Classification, Drift, InvariantFailure, LegacyPath, OpenMigration, Report, SentinelAt,
1356 StalePin,
1357 };
1358 use crate::assess::{self, Facts, OwnLanding, Unrecorded};
1359 use crate::landing::CheckoutMode;
1360 use crate::landing::Integration;
1361 use crate::landing::manifest::Alignment;
1362 use crate::profile::{
1363 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
1364 };
1365
1366 #[test]
1369 fn accepted_drift_does_not_silence_an_invariant() {
1370 let destination = ".gitlab-ci.yml".to_owned();
1371 let digest = "a".repeat(64);
1372 let mut observed = super::Observed {
1373 drift_rendered: vec![destination.clone()],
1374 drift_digests: std::iter::once((destination.clone(), digest.clone())).collect(),
1375 drift_reasons: std::collections::BTreeMap::new(),
1376 drift_accepted: Vec::new(),
1377 accepted_stale: Vec::new(),
1378 drift_seeded: Vec::new(),
1379 parameter_drift: Vec::new(),
1380 record_drift: Vec::new(),
1381 missing: Vec::new(),
1382 stale: Vec::new(),
1383 sentinels: Vec::new(),
1384 invariants: vec![crate::landing::invariants::InvariantFailure {
1385 code: "an-invariant",
1386 destination: destination.clone(),
1387 reason: "the accepted bytes break it".to_owned(),
1388 remediation: "restore it",
1389 }],
1390 warnings: Vec::new(),
1391 setup_proof: None,
1392 incompatible: Vec::new(),
1393 pending: None,
1394 selection: None,
1395 };
1396 let manifest: crate::landing::manifest::Manifest = serde_json::from_str(&format!(
1397 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":{{}}}}"#,
1398 crate::digest::Digest::of(b"candidate")
1399 ))
1400 .expect("the record parses");
1401 let accepted = std::iter::once((
1402 destination,
1403 crate::config::AcceptedDrift {
1404 reason: "deliberate".to_owned(),
1405 sha256: digest,
1406 },
1407 ))
1408 .collect();
1409 super::accept(&mut observed, &manifest, &accepted).expect("accepts");
1410 assert!(observed.drift_rendered.is_empty());
1411 let violations = super::violations_of(&observed);
1412 assert!(
1413 violations.iter().any(|line| line.contains("an-invariant")),
1414 "{violations:?}"
1415 );
1416 }
1417
1418 #[test]
1421 #[allow(
1422 clippy::too_many_lines,
1423 reason = "the snapshot states every field of both forms in one place"
1424 )]
1425 fn the_status_report_schema_snapshot_holds() {
1426 let landed = Report {
1427 schema: super::SCHEMA,
1428 landed: true,
1429 classification: Some(Classification::Upgrade),
1430 git_work_tree: true,
1431 config: super::ConfigState {
1432 state: "pending",
1433 pending: vec!["profile.release.style".into()],
1434 },
1435 profile: Some(ProfileSnapshot {
1436 technologies: vec!["rust".into()],
1437 forge: Some("github".into()),
1438 release: ReleaseIntent {
1439 mode: ReleaseMode::Automatic,
1440 driver: Some("rust".into()),
1441 style: Some(crate::landing::Style::Trunk),
1442 line_prefix: Some("release/".into()),
1443 registry: None,
1444 build: None,
1445 },
1446 }),
1447 git: Some(GitWorkflow {
1448 trunk: "master".into(),
1449 checkout_mode: CheckoutMode::LinkedWorktree,
1450 integration: Integration::Local,
1451 merge_form: crate::landing::MergeForm::Squash,
1452 }),
1453 capabilities: Some(CapabilityRequests {
1454 nix_packaging: true,
1455 reporting_policy: true,
1456 scorecard: false,
1457 code_scanning: Some(crate::landing::Provider::Semgrep),
1458 workflow_guards: Some(false),
1459 }),
1460 selection: Some(vec![]),
1461 rk_version: Some("0.1.0".into()),
1462 binary_version: Some("0.2.0"),
1463 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
1464 drift: Some(Drift {
1465 rendered: 0,
1466 seeded: 1,
1467 }),
1468 missing: Some(vec![]),
1469 stale_pins: Some(vec![StalePin {
1470 tool: "release-plz".into(),
1471 landed: "0.3.160".into(),
1472 available: "0.3.170".into(),
1473 }]),
1474 sentinels: Some(1),
1475 sentinel_locations: Some(vec![SentinelAt {
1476 file: "release-plz.toml".into(),
1477 line: 18,
1478 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
1479 }]),
1480 record_drift: Some(0),
1481 invariant_failures: Some(vec![InvariantFailure {
1482 code: "attestations-disabled",
1483 destination: "dist-workspace.toml".into(),
1484 reason: "github-attestations is not effectively true".into(),
1485 remediation: "set github-attestations = true in [dist]",
1486 }]),
1487 warnings: Some(vec![super::Warning {
1488 code: super::CODE_SCANNING_LICENCE,
1489 reason: "the target's license, LicenseRef-proprietary, does not state a codebase this release recognizes as open source".into(),
1490 }]),
1491 setup_proof: Some(super::SetupProof {
1492 state: "stale",
1493 changed: vec!["target.required_check".into()],
1494 reason: None,
1495 detail: None,
1496 }),
1497 pending: Some(2),
1498 accepted_drift: Some(vec![super::AcceptedNote {
1499 destination: ".gitlab-ci.yml".into(),
1500 reason: "the image carries a tool".into(),
1501 }]),
1502 stale_accepted_drift: Some(vec![]),
1503 violations: None,
1504 legacy_state: vec![LegacyPath {
1505 path: "/state/release-kit/plans/0123456789abcdef".into(),
1506 kind: "plan",
1507 active: true,
1508 }],
1509 migration: Some(OpenMigration {
1510 id: "20261009T140205Z".into(),
1511 workspace: Some(
1512 "/state/release-kit/migrations/widget-0123456789ab/20261009T140205Z".into(),
1513 ),
1514 unfinished: Some(2),
1515 }),
1516 };
1517 assert_eq!(
1518 serde_json::to_string(&landed).expect("a report serializes"),
1519 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}}"##
1520 );
1521 let absent = Report {
1522 landed: false,
1523 classification: Some(Classification::Setup),
1524 git_work_tree: false,
1525 config: super::ConfigState {
1526 state: "absent",
1527 pending: vec![],
1528 },
1529 profile: None,
1530 git: None,
1531 capabilities: None,
1532 selection: None,
1533 rk_version: None,
1534 binary_version: None,
1535 alignment: None,
1536 drift: None,
1537 missing: None,
1538 stale_pins: None,
1539 sentinels: None,
1540 sentinel_locations: None,
1541 record_drift: None,
1542 invariant_failures: None,
1543 warnings: None,
1544 setup_proof: None,
1545 pending: None,
1546 accepted_drift: None,
1547 stale_accepted_drift: None,
1548 violations: None,
1549 legacy_state: vec![],
1550 migration: None,
1551 ..landed
1552 };
1553 assert_eq!(
1554 serde_json::to_string(&absent).expect("a report serializes"),
1555 r#"{"schema":"rk.status/15","landed":false,"classification":"setup","git_work_tree":false,"config":{"state":"absent","pending":[]},"legacy_state":[]}"#,
1556 "an absent landing reports the fields a caller can branch on"
1557 );
1558 let newer = Report {
1559 classification: None,
1560 ..absent
1561 };
1562 assert!(
1563 serde_json::to_string(&newer)
1564 .expect("a report serializes")
1565 .contains(r#""classification":null"#),
1566 "no classification is stated as null, never omitted"
1567 );
1568 }
1569
1570 fn facts() -> Facts {
1571 Facts {
1572 technologies: Vec::new(),
1573 forge: None,
1574 repo: None,
1575 release_markers: Vec::new(),
1576 collisions: Vec::new(),
1577 git: true,
1578 tags: 0,
1579 long_lived_branches: Vec::new(),
1580 }
1581 }
1582
1583 #[test]
1588 fn every_classification_has_its_rule() {
1589 let none = OwnLanding::default();
1590 let classify = |facts: &Facts, own: &OwnLanding| {
1591 super::unrecorded(assess::classify_unrecorded(facts, own))
1592 };
1593 assert_eq!(classify(&facts(), &none), Classification::Setup);
1594 let marked = Facts {
1595 release_markers: vec![".goreleaser.yaml".into()],
1596 ..facts()
1597 };
1598 assert_eq!(classify(&marked, &none), Classification::Migration);
1599 let tagged = Facts { tags: 3, ..facts() };
1600 assert_eq!(classify(&tagged, &none), Classification::NeedsDecision);
1601 let own = OwnLanding {
1604 regions: vec!["AGENTS.md".into()],
1605 rendered: Vec::new(),
1606 };
1607 let colliding = Facts {
1608 collisions: vec!["AGENTS.md".into()],
1609 release_markers: vec!["CHANGELOG.md".into()],
1610 ..facts()
1611 };
1612 assert_eq!(classify(&colliding, &own), Classification::Adopt);
1613 assert_eq!(
1614 assess::classify_unrecorded(&colliding, &own),
1615 Unrecorded::Adopt
1616 );
1617 assert_eq!(
1618 super::recorded(Alignment::BinaryNewer, false),
1619 Some(Classification::Upgrade)
1620 );
1621 assert_eq!(
1622 super::recorded(Alignment::Aligned, false),
1623 Some(Classification::Drift)
1624 );
1625 assert_eq!(
1626 super::recorded(Alignment::Aligned, true),
1627 Some(Classification::Current)
1628 );
1629 assert_eq!(super::recorded(Alignment::TargetNewer, true), None);
1630 for (class, word) in [
1631 (Classification::Setup, "setup"),
1632 (Classification::Migration, "migration"),
1633 (Classification::NeedsDecision, "needs-decision"),
1634 (Classification::Adopt, "adopt"),
1635 (Classification::Upgrade, "upgrade"),
1636 (Classification::Drift, "drift"),
1637 (Classification::Current, "current"),
1638 ] {
1639 assert_eq!(class.as_str(), word);
1640 assert_eq!(
1641 serde_json::to_string(&class).expect("serializes"),
1642 format!("\"{word}\"")
1643 );
1644 }
1645 }
1646}