Skip to main content

release_kit/
landing.rs

1//! The target-side landing model: parameter resolution, what a target
2//! currently holds, and the direct writes.
3//!
4//! Every landable file has a declared kind, `rendered` files release-kit
5//! owns and may rewrite, `seeded` files the target tunes, `state` files
6//! the release automation maintains, and a `rendered` file's bytes are a
7//! deterministic function of the embedded sources plus the landing
8//! parameters, so a later command can compare what is on disk against
9//! what would be written.
10//!
11//! The pure pieces of that model, the kind table, the token rendering,
12//! the block templating, the splice and marker judgments, the capability
13//! selection, and the Nix crate-shape judgment, have one implementation
14//! in [`crate::projection`] and are re-exported here under their old
15//! names. [`Params`], the resolved input every projection takes, lives in
16//! [`crate::profile`] and is re-exported here. What lives in this file is
17//! the readers of a target's recorded destinations and the submodules
18//! that lock, write, and record.
19pub mod apply;
20pub mod invariants;
21pub mod lock;
22pub mod manifest;
23
24use camino::Utf8Path;
25
26pub use crate::projection::{
27    AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
28    CODE_SCANNING_DESTINATIONS, CODE_SCANNING_TECHS, GLOSSARY_DESTINATION, HOOK_TYPES_LINE,
29    HOOKS_BEGIN, HOOKS_DESTINATION, HOOKS_END, Kind, LINE_PREFIX_RE_TOKEN, LINE_PREFIX_TOKEN,
30    NIX_DESTINATIONS, NIX_WITHHOLDABLE, OWNER_TOKEN, REPO_PLACEHOLDER, REPO_TOKEN, SCOPE_SHAPE,
31    SCOPE_SHAPE_TOKEN, SCORECARD_DESTINATIONS, SECURITY_SPANS, STYLE_TOKEN, TRUNK_BRANCH_TOKEN,
32    authored, block_markers, destinations, extract_block, hooks_marker_defect, kind_of,
33    marker_defect, render, scope_is_shaped, splice_hooks_block, splice_marked_block, substitute,
34};
35pub use manifest::{CheckoutMode, Integration, MergeForm, Provider, Style};
36use serde::Serialize;
37
38use crate::diagnostic::{Diagnostic, Reason};
39use crate::error::RkError;
40
41pub use crate::profile::{Inputs, Params, Purpose};
42
43/// One destination a landing withholds, with why.
44#[derive(Debug, Clone, Serialize)]
45pub struct Withheld {
46    /// The destination that stays out.
47    pub path: String,
48    /// The reason, stated once per destination so a machine reader needs
49    /// no join.
50    pub reason: String,
51}
52
53/// The bytes a recorded destination currently holds, by the placement
54/// its name implies.
55///
56/// The marked block for `AGENTS.md` and `.pre-commit-config.yaml`, the
57/// whole file otherwise. `None` means the file — or the block — is
58/// absent.
59///
60/// # Errors
61///
62/// Any read failure other than the file being absent.
63pub fn read_recorded(target: &Utf8Path, destination: &str) -> std::io::Result<Option<Vec<u8>>> {
64    let path = target.join(destination);
65    let bytes = match std::fs::read(&path) {
66        Ok(bytes) => bytes,
67        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
68        Err(e) => return Err(e),
69    };
70    if let Some((begin, end)) = block_markers(destination) {
71        let text = String::from_utf8_lossy(&bytes);
72        Ok(extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec()))
73    } else {
74        Ok(Some(bytes))
75    }
76}
77
78/// What one detection pass resolved for a forge verb, with the override
79/// flags applied: a forge this binary drives, and the project path.
80#[derive(Debug)]
81pub struct Resolved {
82    /// The forge whose adapter applies.
83    pub forge: String,
84    /// The project path, where a flag or the remote names one.
85    pub repo: Option<String>,
86}
87
88/// Resolve the forge and the repository a forge verb acts on, in one
89/// pass: the flags override, and the `origin` remote answers otherwise.
90///
91/// An unrecognized host refuses rather than defaulting: a forge call
92/// against the wrong API is a half-run setup that looks done.
93///
94/// # Errors
95///
96/// Returns [`RkError::Usage`] for an unknown `--forge` value, and a
97/// refusal naming the override when no forge resolves.
98pub fn resolve(
99    target: &Utf8Path,
100    forge_flag: Option<&str>,
101    repo_flag: Option<&str>,
102) -> Result<Resolved, RkError> {
103    let forge_flag = forge_flag
104        .map(|name| {
105            crate::detect::Forge::parse(name).ok_or_else(|| {
106                RkError::Usage(format!(
107                    "unknown forge '{name}'; the forges are: github, gitlab"
108                ))
109            })
110        })
111        .transpose()?;
112    let detected = crate::detect::detect(target.as_std_path());
113    let forge = forge_flag
114        .or(detected.forge)
115        .map(|forge| forge.as_str().to_owned())
116        .ok_or_else(|| {
117            let message = detected.host.map_or_else(
118                || "no forge detected: the target has no origin remote".to_owned(),
119                |host| format!("no forge detected: the host {host} is not recognized"),
120            );
121            RkError::refusal(
122                Diagnostic::new(Reason::ForgeUndetected, message)
123                    .expected("a github.com or gitlab remote, or --forge")
124                    .action("pass --forge <github|gitlab>"),
125            )
126        })?;
127    Ok(Resolved {
128        forge,
129        repo: repo_flag.map(str::to_owned).or(detected.repo),
130    })
131}
132
133/// The refusal a verb answers when it needs the `repo` parameter and
134/// neither a flag nor the remote supplies one.
135#[must_use]
136pub fn repo_unresolved() -> RkError {
137    RkError::missing(
138        Diagnostic::new(
139            Reason::ForgeUndetected,
140            "no repository detected: the target has no origin remote",
141        )
142        .expected("an origin remote naming the project")
143        .action("pass --repo <path>"),
144    )
145}
146
147/// Hold every accepted-drift entry to its whole domain.
148///
149/// The domain is a whole rendered file the projection selects and the
150/// receipt records, because an entry accepts a deviation from bytes a
151/// landing wrote. Every verb that resolves the declaration calls this
152/// before it writes.
153///
154/// # Errors
155/// A `config-invalid` refusal naming the destination and the fault.
156///
157/// SATISFIES accepted-drift:an-accepted-drift-names-its-reason-and-its-bytes
158pub fn accepted_drift_faults(
159    projection: &crate::projection::Projection,
160    recorded: Option<&manifest::Manifest>,
161    config: Option<&crate::config::Config>,
162) -> Result<(), crate::error::RkError> {
163    let Some(config) = config else {
164        return Ok(());
165    };
166    for destination in config.landing.accepted_drift.keys() {
167        if let Some(fault) = projection.accepted_drift_fault(destination) {
168            return Err(crate::config::invalid(fault));
169        }
170        let recorded_whole = recorded
171            .and_then(|record| record.file(destination))
172            .is_some_and(|file| {
173                file.kind == Kind::Rendered && file.placement == manifest::Placement::Whole
174            });
175        if !recorded_whole {
176            return Err(crate::config::invalid(format!(
177                "landing.accepted_drift names {destination}, which no receipt records as a whole rendered file; an entry accepts a deviation from bytes a landing wrote"
178            )));
179        }
180    }
181    Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186    use super::{
187        AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, BRANCH_GRAMMAR,
188        CheckoutMode, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
189        HOOKS_END, Integration, Kind, Provider, SCOPE_SHAPE, Style, extract_block, kind_of, render,
190        splice_hooks_block, splice_marked_block,
191    };
192    use crate::embedded;
193    use crate::profile::{
194        CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
195    };
196    use crate::projection::{self, Projection, ProjectionInput, TargetEvidence};
197
198    /// Every pairing of the two Git workflow axes, which are orthogonal.
199    const MODE_PAIRS: [(CheckoutMode, Integration); 4] = [
200        (CheckoutMode::MainWorktree, Integration::Forge),
201        (CheckoutMode::LinkedWorktree, Integration::Forge),
202        (CheckoutMode::MainWorktree, Integration::Local),
203        (CheckoutMode::LinkedWorktree, Integration::Local),
204    ];
205
206    /// The candidate destinations for `params` over a target that holds
207    /// nothing, in destination order.
208    fn destinations(params: &super::Params) -> Vec<String> {
209        Projection::compute(&ProjectionInput {
210            params: params.clone(),
211            evidence: TargetEvidence {
212                crate_shape: projection::CrateShape {
213                    cargo_toml: Some(
214                        "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned(),
215                    ),
216                    cargo_lock: true,
217                    main_rs: true,
218                },
219                ..TargetEvidence::default()
220            },
221        })
222        .expect("the pair projects")
223        .candidates
224        .into_iter()
225        .map(|candidate| candidate.destination)
226        .collect()
227    }
228
229    fn routing_block(workflow_guards: bool) -> String {
230        projection::routing_block(workflow_guards).expect("the binary embeds the block")
231    }
232
233    fn hooks_block(mode: CheckoutMode) -> String {
234        projection::hooks_block(mode, Integration::Forge, true)
235            .expect("the binary embeds the block")
236    }
237
238    fn hooks_block_for(mode: CheckoutMode, integration: Integration) -> String {
239        projection::hooks_block(mode, integration, true).expect("the binary embeds the block")
240    }
241
242    fn unguarded_hooks_block(mode: CheckoutMode, integration: Integration) -> String {
243        projection::hooks_block(mode, integration, false).expect("the binary embeds the block")
244    }
245
246    fn glossary_block() -> String {
247        projection::glossary_block().expect("the binary embeds the block")
248    }
249
250    /// The splice returns the document's bytes; every assertion below
251    /// reads them back as text, which every fixture here is.
252    fn spliced(existing: Option<&str>, block: &str) -> String {
253        String::from_utf8(splice_marked_block(existing.map(str::as_bytes), block))
254            .expect("the fixtures are text")
255    }
256
257    #[test]
258    fn private_reporting_path_tokens_are_reproducible() {
259        for repo in [
260            "acme/widget",
261            "acme/group/widget",
262            "acme/OWNER-RK_STYLE-RK_SCOPE_SHAPE",
263        ] {
264            assert_eq!(
265                super::render(
266                    b"RK_REPO RK_REPO OWNER RK_STYLE RK_SCOPE_SHAPE",
267                    &super::Params::for_test(repo, Some(super::Style::Trunk))
268                ),
269                format!("{repo} {repo} acme trunk {}", super::SCOPE_SHAPE).as_bytes()
270            );
271        }
272        assert_eq!(super::kind_of("SECURITY.md"), Some(super::Kind::Rendered));
273    }
274
275    /// Both forge policies carry exactly one ordered pair of every
276    /// security marker. The span renderer treats anything else as a
277    /// source defect and leaves the bytes alone, so this test is what
278    /// keeps a defect out of a release rather than out of one landing.
279    #[test]
280    fn each_forge_policy_carries_one_ordered_pair_of_every_span() {
281        for forge in ["github", "gitlab"] {
282            let bytes = embedded::SNIPPETS
283                .get_file(format!("_shared/{forge}/SECURITY.md"))
284                .expect("the policy ships")
285                .contents();
286            let text = String::from_utf8_lossy(bytes);
287            for (begin, end) in super::SECURITY_SPANS {
288                let begin = String::from_utf8_lossy(begin);
289                let end = String::from_utf8_lossy(end);
290                assert_eq!(text.matches(begin.as_ref()).count(), 1, "{forge} {begin}");
291                assert_eq!(text.matches(end.as_ref()).count(), 1, "{forge} {end}");
292                assert!(
293                    text.find(begin.as_ref()) < text.find(end.as_ref()),
294                    "{forge}: {begin} must precede {end}"
295                );
296            }
297            // The scope span is empty and closes the channel paragraph, so
298            // stripping it leaves the authored bytes and filling it opens
299            // one paragraph right after the channel's.
300            let (begin, end) = super::SECURITY_SPANS[3];
301            let span = format!(
302                "{}{}\n\n",
303                String::from_utf8_lossy(begin),
304                String::from_utf8_lossy(end)
305            );
306            let at = text
307                .find(&span)
308                .expect("the scope span is empty and ends a paragraph");
309            let channel = text[..at]
310                .rfind("\n\n")
311                .expect("a paragraph opens before it");
312            assert!(
313                text[channel..at].contains("RK_SECURITY_CONTACT_END"),
314                "{forge}: the scope span closes the channel paragraph"
315            );
316        }
317    }
318
319    /// The default answers reproduce each forge's authored policy exactly,
320    /// markers removed and each forge's own wording kept; an answered one
321    /// states it; and a contact spelling a token name lands literally,
322    /// because the spans resolve after every substitution.
323    #[test]
324    fn the_security_spans_render_per_answer() {
325        for forge in ["github", "gitlab"] {
326            let bytes = embedded::SNIPPETS
327                .get_file(format!("_shared/{forge}/SECURITY.md"))
328                .expect("the policy ships")
329                .contents();
330            let authored = String::from_utf8_lossy(bytes);
331            let stripped = {
332                let mut text = authored.clone().into_owned();
333                for (begin, end) in super::SECURITY_SPANS {
334                    text = text.replace(&String::from_utf8_lossy(begin).into_owned(), "");
335                    text = text.replace(&String::from_utf8_lossy(end).into_owned(), "");
336                }
337                text
338            };
339            let mut default =
340                super::Params::for_test_security("", crate::config::RESPONSE_DEFAULT, "");
341            default.set_pair_for_test("rust", forge);
342            let rendered = String::from_utf8(render(bytes, &default)).expect("text");
343            assert_eq!(
344                rendered,
345                stripped.replace("RK_REPO", "acme/widget"),
346                "{forge}: the default answers must reproduce the authored policy"
347            );
348            assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
349
350            let scope = "This wraps the RK_REPO CLI. Report a flaw in it upstream.\nThis policy covers the wrapper.";
351            let mut answered = super::Params::for_test_security(
352                "OWNER RK_REPO <team@acme.example>",
353                "14 days",
354                scope,
355            );
356            answered.set_pair_for_test("rust", forge);
357            let rendered = String::from_utf8(render(bytes, &answered)).expect("text");
358            assert!(
359                rendered.contains("OWNER RK_REPO <team@acme.example>"),
360                "{forge}: a contact spelling a token name lands literally: {rendered}"
361            );
362            assert!(
363                rendered.contains("Maintainers acknowledge a report within 14 days."),
364                "{forge}: {rendered}"
365            );
366            assert!(
367                rendered.contains("This policy commits to no disclosure deadline."),
368                "{forge}: {rendered}"
369            );
370            assert!(
371                !rendered.contains("best-effort basis"),
372                "{forge}: a stated window replaces the best-effort sentence: {rendered}"
373            );
374            assert!(
375                !rendered.contains("no response or disclosure deadline"),
376                "{forge}: a stated window contradicts the response disclaimer: {rendered}"
377            );
378            // The scope lands literally, as its own paragraph right after
379            // the channel paragraph, and the next authored paragraph keeps
380            // its place.
381            let paragraphs: Vec<&str> = rendered.split("\n\n").collect();
382            let at = paragraphs
383                .iter()
384                .position(|paragraph| *paragraph == scope)
385                .unwrap_or_else(|| panic!("{forge}: the scope is one paragraph: {rendered}"));
386            assert!(
387                paragraphs[at - 1].contains("team@acme.example"),
388                "{forge}: the scope follows the channel paragraph: {rendered}"
389            );
390            assert!(!rendered.contains("RK_SECURITY"), "{forge}: {rendered}");
391        }
392    }
393
394    /// A defective span leaves the bytes alone rather than producing a
395    /// half-written sentence: the source test above is what catches one.
396    #[test]
397    fn a_defective_span_renders_unchanged() {
398        let (begin, end) = super::SECURITY_SPANS[0];
399        let begin = String::from_utf8_lossy(begin).into_owned();
400        let end = String::from_utf8_lossy(end).into_owned();
401        let params = super::Params::for_test_security("team@acme.example", "1 day", "");
402        for baseline in [
403            format!("contact {begin}a maintainer\n"),
404            format!("contact a maintainer{end}\n"),
405            format!("contact {end}a maintainer{begin}\n"),
406            "contact a maintainer\n".to_owned(),
407        ] {
408            assert_eq!(
409                render(baseline.as_bytes(), &params),
410                baseline.as_bytes(),
411                "{baseline}"
412            );
413        }
414    }
415
416    /// Every snippet destination has a declared kind: a new landable file
417    /// without a classification fails here, not at a landing. The shared
418    /// zone's files are enumerated the same way.
419    #[test]
420    fn the_kind_table_closes_over_every_snippet() {
421        for tech_dir in embedded::SNIPPETS.dirs() {
422            for pair_dir in tech_dir.dirs() {
423                let prefix = format!("{}/", pair_dir.path().to_string_lossy());
424                for (path, _) in embedded::walk(pair_dir) {
425                    let destination = path.strip_prefix(&prefix).unwrap_or(&path);
426                    assert!(
427                        kind_of(destination).is_some(),
428                        "{destination}: no declared kind"
429                    );
430                }
431            }
432        }
433        for block in BLOCK_DESTINATIONS {
434            assert_eq!(kind_of(block), Some(Kind::Rendered), "{block}");
435        }
436        assert_eq!(kind_of("something-else.txt"), None);
437    }
438
439    /// Substitution is total and derives from the repo parameter's first
440    /// segment, so a nested GitLab project path still yields its root
441    /// namespace. The scope shape rests on no parameter, so it renders
442    /// under every landing.
443    #[test]
444    fn rendering_substitutes_every_owner_occurrence() {
445        let baseline = b"if: repository_owner == 'OWNER'\n# OWNER again: OWNER\n";
446        let rendered = render(baseline, &super::Params::for_test("acme/sub/widget", None));
447        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
448        assert_eq!(text, "if: repository_owner == 'acme'\n# acme again: acme\n");
449
450        let baseline = b"match (RK_SCOPE_SHAPE)\n";
451        let rendered = render(baseline, &super::Params::for_test("acme/widget", None));
452        let text = String::from_utf8(rendered).expect("rendered bytes stay text");
453        assert_eq!(text, format!("match ({SCOPE_SHAPE})\n"));
454    }
455
456    /// The one scope shape is a bracket expression an extended regular
457    /// expression takes verbatim: lowercase, and with the `-` last, where
458    /// it stands for itself rather than opening a range.
459    #[test]
460    fn the_scope_shape_drops_into_the_title_check() {
461        assert_eq!(SCOPE_SHAPE, "[a-z0-9._/-]+");
462        assert!(
463            !SCOPE_SHAPE.contains('\''),
464            "the title checks single-quote it"
465        );
466    }
467
468    /// The predicate `rk message --check` calls and the pattern the title
469    /// checks render admit exactly the same characters. The pattern is
470    /// expanded here from its own text, so editing one owner without the
471    /// other fails: the desk and the forge judge one language.
472    #[test]
473    fn the_scope_predicate_and_the_rendered_pattern_agree() {
474        let body = SCOPE_SHAPE
475            .strip_prefix('[')
476            .and_then(|rest| rest.strip_suffix("]+"))
477            .expect("the shape is one bracket expression, repeated");
478        let chars: Vec<char> = body.chars().collect();
479        let mut admitted = std::collections::BTreeSet::new();
480        let mut at = 0;
481        while at < chars.len() {
482            // A `-` with a neighbour on each side opens a range; last, it
483            // stands for itself, which is why the shape ends with it.
484            if at + 2 < chars.len() && chars[at + 1] == '-' {
485                for c in chars[at]..=chars[at + 2] {
486                    admitted.insert(c);
487                }
488                at += 3;
489            } else {
490                admitted.insert(chars[at]);
491                at += 1;
492            }
493        }
494        for byte in 0..=127u8 {
495            let c = char::from(byte);
496            assert_eq!(
497                super::scope_is_shaped(&c.to_string()),
498                admitted.contains(&c),
499                "the predicate and {SCOPE_SHAPE} disagree on {c:?}"
500            );
501        }
502        assert!(super::scope_is_shaped("guides/release"));
503        assert!(!super::scope_is_shaped(""), "a scope is never empty");
504        assert!(!super::scope_is_shaped("Specs Ugly"));
505    }
506
507    /// The forge's own capabilities land with every automatic pair on that
508    /// forge: the title gate and the reporting policy, and the shared zone
509    /// is never a technology.
510    #[test]
511    fn the_shared_zone_composes_into_the_pair() {
512        let mut github = super::Params::for_test("acme/widget", Some(Style::Trunk));
513        github.set_pair_for_test("rust", "github");
514        let github = destinations(&github);
515        assert!(
516            github.contains(&".github/workflows/pr-title.yml".to_owned()),
517            "the shared title check lands with the pair"
518        );
519        assert!(github.contains(&"SECURITY.md".to_owned()));
520        let mut gitlab = super::Params::for_test("acme/widget", Some(Style::Trunk));
521        gitlab.set_pair_for_test("rust", "gitlab");
522        let gitlab = destinations(&gitlab);
523        assert!(
524            gitlab.contains(&".gitlab/ci/mr-title.yml".to_owned()),
525            "the shared title job lands with the pair"
526        );
527        assert!(
528            !crate::profile::catalog::known_drivers()
529                .iter()
530                .any(|driver| driver.starts_with('_')),
531            "the shared zone is no driver"
532        );
533    }
534
535    /// A loaded record reaches the projection unchanged, including old
536    /// records' absent style and the two checkout modes.
537    #[test]
538    #[allow(
539        clippy::too_many_lines,
540        reason = "one sweep builds a whole record for every combination it round-trips"
541    )]
542    fn params_from_a_record_round_trips() {
543        use super::{Params, manifest};
544        let dir = tempfile::tempdir().expect("a scratch target exists");
545        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
546        let prefix = Some(crate::config::LINE_PREFIX_DEFAULT.to_owned());
547        for tech in ["rust", "bash"] {
548            for forge in ["github", "gitlab"] {
549                for (checkout_mode, integration) in MODE_PAIRS {
550                    for style in [None, Some(Style::Trunk), Some(Style::Lines)] {
551                        for ((nix, scorecard), code_scanning) in [
552                            ((false, false), None),
553                            ((false, true), Some(Provider::Semgrep)),
554                            ((true, false), Some(Provider::CodeQl)),
555                            ((true, true), None),
556                        ] {
557                            let record = manifest::Manifest {
558                                schema_version: manifest::SCHEMA_VERSION,
559                                rk_version: "0.1.0".to_owned(),
560                                origin: "init".to_owned(),
561                                landed_at: "2026-08-29T00:00:00Z".to_owned(),
562                                profile: ProfileSnapshot {
563                                    technologies: vec![tech.to_owned()],
564                                    forge: Some(forge.to_owned()),
565                                    release: ReleaseIntent {
566                                        mode: ReleaseMode::Automatic,
567                                        driver: Some(tech.to_owned()),
568                                        style,
569                                        line_prefix: prefix.clone(),
570                                        registry: None,
571                                        build: None,
572                                    },
573                                },
574                                git: GitWorkflow {
575                                    trunk: crate::config::TRUNK_DEFAULT.to_owned(),
576                                    checkout_mode,
577                                    integration,
578                                    merge_form: crate::landing::MergeForm::Squash,
579                                },
580                                capabilities: CapabilityRequests {
581                                    nix_packaging: nix,
582                                    reporting_policy: true,
583                                    scorecard,
584                                    code_scanning,
585                                    workflow_guards: Some(true),
586                                },
587                                parameters: manifest::Parameters {
588                                    repo: "acme/team/widget".to_owned(),
589                                    security_contact: String::new(),
590                                    security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
591                                    security_scope: String::new(),
592                                    required_check: "gate".to_owned(),
593                                    required_workflow: "ci".to_owned(),
594                                    dev_environment: None,
595                                },
596                                files: Vec::new(),
597                                pins: std::collections::BTreeMap::new(),
598                            };
599                            manifest::write(target, &record).expect("the record writes");
600                            let loaded = manifest::load(target)
601                                .expect("the record loads")
602                                .expect("the record exists");
603                            let params = Params::from_record(&loaded);
604                            assert_eq!(params.driver(), Some(tech));
605                            assert_eq!(params.forge(), Some(forge));
606                            assert_eq!(params.repo(), "acme/team/widget");
607                            assert_eq!(params.integration(), integration);
608                            assert_eq!(params.style(), style);
609                            assert_eq!(params.nix_packaging(), nix);
610                            assert_eq!(params.scorecard(), scorecard);
611                            assert_eq!(params.code_scanning(), code_scanning);
612                            // The loaded record and the same answers given
613                            // directly project the same candidate tree.
614                            let mut direct = super::Params::for_test("acme/team/widget", style);
615                            direct.set_pair_for_test(tech, forge);
616                            direct.set_checkout_mode_for_test(checkout_mode);
617                            direct.set_integration_for_test(integration);
618                            direct.set_nix_for_test(nix);
619                            direct.set_scorecard_for_test(scorecard);
620                            direct.set_code_scanning_for_test(code_scanning);
621                            assert_eq!(params, direct);
622                            let projected = destinations(&params);
623                            for block in
624                                [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION]
625                            {
626                                assert!(projected.contains(&block.to_owned()), "{block}");
627                            }
628                            for destination in super::NIX_DESTINATIONS {
629                                assert_eq!(
630                                    projected.contains(&destination.to_owned()),
631                                    nix && tech == "rust",
632                                    "{tech} {forge} nix={nix}: {destination}"
633                                );
634                            }
635                            // The Scorecard workflow ships in the shared
636                            // GitHub zone alone, so the request reaches
637                            // every binding and no GitLab landing.
638                            for destination in super::SCORECARD_DESTINATIONS {
639                                assert_eq!(
640                                    projected.contains(&destination.to_owned()),
641                                    scorecard && forge == "github",
642                                    "{tech} {forge} scorecard={scorecard}: {destination}"
643                                );
644                            }
645                        }
646                    }
647                }
648            }
649        }
650    }
651
652    fn resolved_test_params(
653        tech: &str,
654        resolved: &super::Resolved,
655        checkout_mode: CheckoutMode,
656        style: Option<Style>,
657        nix: bool,
658        scorecard: bool,
659        code_scanning: Option<Provider>,
660    ) -> Result<super::Params, crate::error::RkError> {
661        let technologies = vec![tech.to_owned()];
662        super::Params::resolve(
663            camino::Utf8Path::new("."),
664            &super::Inputs {
665                technologies: &technologies,
666                forge: Some(&resolved.forge),
667                repo: resolved.repo.as_deref(),
668                release_mode: Some(ReleaseMode::Automatic),
669                release_driver: Some(tech),
670                style,
671                // GitLab refuses both answers, because it names no check.
672                required_check: (resolved.forge == "github").then_some("gate"),
673                required_workflow: (resolved.forge == "github").then_some("ci"),
674                build_command: None,
675                build_packages: None,
676                trunk: None,
677                checkout_mode: Some(checkout_mode),
678                integration: None,
679                merge_form: None,
680                nix: Some(nix),
681                reporting_policy: None,
682                scorecard: Some(scorecard),
683                code_scanning: Some(code_scanning),
684                workflow_guards: Some(true),
685            },
686            None,
687            None,
688            super::Purpose::Init,
689        )
690    }
691
692    /// A rendered projection carries no unsubstituted token and no
693    /// mechanical sentinel; the one judgment sentinel stays in its seeded
694    /// file.
695    #[test]
696    fn a_projection_renders_owned_files_and_keeps_seeded_judgment() {
697        let params = resolved_test_params(
698            "rust",
699            &super::Resolved {
700                forge: "github".to_owned(),
701                repo: Some("acme/widget".to_owned()),
702            },
703            CheckoutMode::MainWorktree,
704            Some(Style::Trunk),
705            false,
706            false,
707            None,
708        )
709        .expect("the parameters resolve");
710        let entries = Projection::compute(&ProjectionInput {
711            params: params.clone(),
712            evidence: TargetEvidence::default(),
713        })
714        .expect("the pair projects")
715        .candidates;
716        let workflow = entries
717            .iter()
718            .find(|entry| entry.destination.ends_with("release-plz.yml"))
719            .expect("the workflow projects");
720        assert_eq!(workflow.kind, Kind::Rendered);
721        let text = String::from_utf8_lossy(&workflow.bytes);
722        assert!(!text.contains("OWNER"), "an owner token survived rendering");
723        assert!(text.contains("'acme'"));
724        assert!(!text.contains("TODO(release-kit)"));
725        let title = entries
726            .iter()
727            .find(|entry| entry.destination.ends_with("pr-title.yml"))
728            .expect("the title check projects");
729        let text = String::from_utf8_lossy(&title.bytes);
730        assert!(text.contains(SCOPE_SHAPE), "{text}");
731        assert!(
732            !text.contains("RK_SCOPE_SHAPE"),
733            "a scope token survived: {text}"
734        );
735        let seeded = entries
736            .iter()
737            .find(|entry| entry.destination == "release-plz.toml")
738            .expect("the seeded file projects");
739        assert_eq!(seeded.kind, Kind::Seeded);
740        let authored = embedded::SNIPPETS
741            .get_file("rust/github/release-plz.toml")
742            .expect("the seed ships")
743            .contents();
744        assert_eq!(
745            seeded.bytes,
746            crate::projection::registry_lines(authored, &params),
747            "a seeded file lands as authored, with the lines of the unanswered registry"
748        );
749        assert!(
750            !String::from_utf8_lossy(&seeded.bytes).contains("RK_IF_"),
751            "no registry marker survives in a seed"
752        );
753        assert!(String::from_utf8_lossy(&seeded.bytes).contains("TODO(release-kit)"));
754        for block in BLOCK_DESTINATIONS {
755            let entry = entries
756                .iter()
757                .find(|entry| entry.destination == block)
758                .expect("every block is part of the projection");
759            let text = String::from_utf8_lossy(&entry.bytes);
760            assert!(
761                !text.contains("RK_SCOPE_SHAPE"),
762                "{block} kept a token: {text}"
763            );
764        }
765    }
766
767    /// The Nix destinations project only under the opt-in: off, none of
768    /// them appears; on, the rust pairs carry them — the gitlab pair too,
769    /// minus the workflow, which is a forge file the gitlab pair does
770    /// not ship — and a pair without them projects the smaller product.
771    #[test]
772    fn the_nix_destinations_project_only_under_the_opt_in() {
773        use super::NIX_DESTINATIONS;
774        let paths = |nix: bool, forge: &str| -> Vec<String> {
775            destinations(
776                &resolved_test_params(
777                    "rust",
778                    &super::Resolved {
779                        forge: forge.to_owned(),
780                        repo: Some("acme/widget".to_owned()),
781                    },
782                    CheckoutMode::LinkedWorktree,
783                    Some(Style::Trunk),
784                    nix,
785                    false,
786                    None,
787                )
788                .expect("the parameters resolve"),
789            )
790        };
791        let off = paths(false, "github");
792        for destination in NIX_DESTINATIONS {
793            assert!(!off.contains(&destination.to_owned()), "{destination}");
794        }
795        let on = paths(true, "github");
796        for destination in ["nix/package.nix", "flake.nix", "flake.lock"] {
797            assert!(on.contains(&destination.to_owned()), "{destination}");
798        }
799        // The capability lands no workflow, so both forges land the same
800        // set: a job proving the build holds a merge only inside the
801        // workflow the required check needs, and that one is the
802        // target's own.
803        let gitlab = paths(true, "gitlab");
804        assert!(gitlab.contains(&"nix/package.nix".to_owned()));
805        assert!(
806            !on.iter()
807                .chain(gitlab.iter())
808                .any(|destination| destination.contains("nix.yml"))
809        );
810        let bash = destinations(
811            &resolved_test_params(
812                "bash",
813                &super::Resolved {
814                    forge: "github".to_owned(),
815                    repo: Some("acme/widget".to_owned()),
816                },
817                CheckoutMode::LinkedWorktree,
818                Some(Style::Trunk),
819                true,
820                false,
821                None,
822            )
823            .expect("the parameters resolve"),
824        );
825        assert!(
826            bash.iter()
827                .all(|destination| !NIX_DESTINATIONS.contains(&destination.as_str()))
828        );
829    }
830
831    /// The github and gitlab copies of the forge-independent Nix seeds
832    /// stay byte-identical: the loader composes exactly two layers and has
833    /// no technology-wide zone, so the duplication is deliberate and this
834    /// parity test is what keeps it honest.
835    #[test]
836    fn the_nix_seeds_are_identical_across_forge_pairs() {
837        for name in ["nix/package.nix", "flake.nix", "flake.lock"] {
838            let github = embedded::SNIPPETS
839                .get_file(format!("rust/github/{name}"))
840                .expect("the github copy ships")
841                .contents();
842            let gitlab = embedded::SNIPPETS
843                .get_file(format!("rust/gitlab/{name}"))
844                .expect("the gitlab copy ships")
845                .contents();
846            assert_eq!(github, gitlab, "{name} diverged between the pairs");
847        }
848    }
849
850    /// The withhold judgment: a flake pair of the target's own withholds
851    /// the pair and the workflow while the package expression lands, a
852    /// crate shape the seed does not support withholds everything, and a
853    /// clean single-crate target withholds nothing.
854    #[test]
855    fn the_nix_withhold_judgment_covers_the_three_shapes() {
856        use super::NIX_DESTINATIONS;
857        let dir = tempfile::tempdir().expect("a scratch target exists");
858        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
859        let project = |nix: bool| {
860            let params = resolved_test_params(
861                "rust",
862                &super::Resolved {
863                    forge: "github".to_owned(),
864                    repo: Some("acme/widget".to_owned()),
865                },
866                CheckoutMode::LinkedWorktree,
867                Some(Style::Trunk),
868                nix,
869                false,
870                None,
871            )
872            .expect("the parameters resolve");
873            let evidence = TargetEvidence::gather(target, None).expect("the evidence reads");
874            Projection::compute(&ProjectionInput { params, evidence }).expect("the pair projects")
875        };
876        let withheld = |projection: &Projection| -> Vec<String> {
877            projection
878                .omissions
879                .iter()
880                .map(|omission| omission.destination.clone())
881                .collect()
882        };
883        let landed = |projection: &Projection, destination: &str| {
884            projection
885                .candidates
886                .iter()
887                .any(|candidate| candidate.destination == destination)
888        };
889
890        // No Cargo.toml: the whole capability is withheld by name.
891        let all = project(true);
892        assert_eq!(
893            withheld(&all),
894            ["flake.lock", "flake.nix", "nix/package.nix"]
895        );
896        assert!(
897            all.candidates
898                .iter()
899                .all(|entry| !NIX_DESTINATIONS.contains(&entry.destination.as_str()))
900        );
901
902        // A single crate with its own flake: the seed pair is withheld,
903        // and the package expression still lands.
904        std::fs::write(
905            target.join("Cargo.toml"),
906            "[package]\nname = \"widget\"\nversion = \"0.1.0\"\n",
907        )
908        .expect("the crate manifest writes");
909        std::fs::write(target.join("Cargo.lock"), "version = 4\n").expect("the lock writes");
910        std::fs::create_dir_all(target.join("src")).expect("the src dir exists");
911        std::fs::write(target.join("src/main.rs"), "fn main() {}\n").expect("the main writes");
912        std::fs::write(target.join("flake.nix"), "{ }\n").expect("the flake writes");
913        let all = project(true);
914        assert_eq!(withheld(&all), ["flake.lock", "flake.nix"]);
915        assert!(landed(&all, "nix/package.nix"));
916
917        // A clean single crate: nothing is withheld.
918        std::fs::remove_file(target.join("flake.nix")).expect("the flake removes");
919        let all = project(true);
920        assert!(all.omissions.is_empty());
921        assert!(landed(&all, "flake.nix"));
922
923        // Off, the judgment does not even look.
924        let all = project(false);
925        assert!(all.omissions.is_empty());
926        assert!(!landed(&all, "flake.nix"));
927    }
928
929    /// The glossary takes the same three shapes the routing block does,
930    /// and the marker pair it shares with `AGENTS.md` is what makes one
931    /// splice serve both.
932    #[test]
933    fn the_glossary_splices_into_every_shape() {
934        let owned = glossary_block();
935        let block = owned.as_str();
936
937        let fresh = spliced(None, block);
938        assert_eq!(fresh, format!("{block}\n"));
939        assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
940
941        let own = "# Glossary\n\n- `spike` — a throwaway branch.\n";
942        let appended = spliced(Some(own), block);
943        assert!(appended.starts_with(own));
944        assert_eq!(
945            extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
946            Some(block)
947        );
948
949        let stale = appended.replace("full-implement", "do-everything");
950        let refreshed = spliced(Some(&stale), block);
951        assert_eq!(
952            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
953            Some(block)
954        );
955        assert_eq!(
956            refreshed.matches("BEGIN release-kit").count(),
957            1,
958            "a re-splice must replace, not accumulate"
959        );
960    }
961
962    /// Every line the target wrote below the end marker survives a
963    /// re-splice byte for byte: the block owns its marked lines and the
964    /// document belongs to the target.
965    #[test]
966    fn the_glossary_leaves_the_targets_region_alone() {
967        let owned = glossary_block();
968        let block = owned.as_str();
969        let below = "\n## Our own terms\n\n- `spike` — a throwaway branch, never merged.\n";
970        let landed = format!("{block}\n{below}");
971
972        let refreshed = spliced(Some(&landed), block);
973        assert!(
974            refreshed.ends_with(below),
975            "the target's own region changed: {refreshed}"
976        );
977        assert_eq!(
978            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
979            Some(block)
980        );
981    }
982
983    /// Appending keeps the document whole: trailing spaces, blank lines,
984    /// and a missing final newline are the target's bytes, and a block
985    /// that owns its marked lines alone rewrites none of them.
986    #[test]
987    fn an_append_rewrites_no_byte_the_target_wrote() {
988        let owned = glossary_block();
989        let block = owned.as_str();
990        for own in [
991            "# Glossary\n\n- `spike` — throwaway.   \n\n\n",
992            "# Glossary\n\n- `spike` — throwaway.",
993            "# Glossary\r\n\r\n- `spike` — throwaway.\r\n",
994        ] {
995            let appended = spliced(Some(own), block);
996            assert!(
997                appended.starts_with(own),
998                "the target's bytes changed: {appended:?}"
999            );
1000            assert_eq!(
1001                extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1002                Some(block),
1003                "{appended:?}"
1004            );
1005            let marker = appended.find(BLOCK_BEGIN).expect("the block landed");
1006            assert!(
1007                appended[..marker].ends_with('\n'),
1008                "the block must open its own line: {appended:?}"
1009            );
1010        }
1011    }
1012
1013    /// A document the target wrote is bytes, not text. A splice that
1014    /// decoded it would replace an invalid sequence with U+FFFD and
1015    /// rewrite a byte outside the markers, which the rule forbids.
1016    #[test]
1017    fn a_splice_decodes_no_byte_the_target_wrote() {
1018        let owned = glossary_block();
1019        let block = owned.as_str();
1020
1021        // Appending: the invalid byte sits in the target's own document.
1022        let own = b"# Glossary\n\ncaf\xe9\n";
1023        let appended = splice_marked_block(Some(own), block);
1024        assert!(
1025            appended.starts_with(own),
1026            "the target's bytes changed: {appended:?}"
1027        );
1028        assert!(!appended.contains(&0xEF), "a replacement character landed");
1029
1030        // Replacing: the invalid byte sits below the end marker.
1031        let mut landed = Vec::new();
1032        landed.extend_from_slice(block.replace("full-implement", "do-everything").as_bytes());
1033        landed.extend_from_slice(b"\n\ncaf\xe9\n");
1034        let refreshed = splice_marked_block(Some(&landed), block);
1035        assert!(
1036            refreshed.ends_with(b"\n\ncaf\xe9\n"),
1037            "the target's region below the markers changed: {refreshed:?}"
1038        );
1039        assert!(refreshed.starts_with(block.as_bytes()), "{refreshed:?}");
1040    }
1041
1042    /// The glossary carries no parameter, so the same bytes land in
1043    /// every target: no token survives it and no mode changes it.
1044    #[test]
1045    fn the_glossary_block_carries_no_parameter() {
1046        let block = glossary_block();
1047        assert!(block.starts_with(BLOCK_BEGIN), "{block}");
1048        assert!(block.ends_with(BLOCK_END), "{block}");
1049        assert!(!block.contains("RK_"), "a token survived: {block}");
1050        assert!(!block.contains("OWNER"), "an owner token survived: {block}");
1051        for term in [
1052            "implement-and-request",
1053            "implement-and-merge",
1054            "full-implement",
1055        ] {
1056            assert!(block.contains(term), "{term} is missing from {block}");
1057        }
1058        assert!(
1059            routing_block(true).contains(GLOSSARY_DESTINATION),
1060            "the routing block must name the destination it indexes"
1061        );
1062        assert!(
1063            !routing_block(false).contains(GLOSSARY_DESTINATION),
1064            "a target without the workflow guards lands no glossary to route to"
1065        );
1066    }
1067
1068    #[test]
1069    fn the_block_splices_into_every_agents_shape() {
1070        let owned = routing_block(false);
1071        let block = owned.as_str();
1072        let fresh = spliced(None, block);
1073        assert_eq!(fresh, format!("{block}\n"));
1074        assert_eq!(extract_block(&fresh, BLOCK_BEGIN, BLOCK_END), Some(block));
1075
1076        let appended = spliced(Some("# My project\n\nOwn rules.\n"), block);
1077        assert!(appended.starts_with("# My project\n\nOwn rules.\n\n<!-- BEGIN release-kit -->"));
1078        assert_eq!(
1079            extract_block(&appended, BLOCK_BEGIN, BLOCK_END),
1080            Some(block)
1081        );
1082
1083        let stale = appended.replace("Never author a tag", "Do author a tag");
1084        let refreshed = spliced(Some(&stale), block);
1085        assert_eq!(
1086            extract_block(&refreshed, BLOCK_BEGIN, BLOCK_END),
1087            Some(block)
1088        );
1089        assert!(refreshed.starts_with("# My project"));
1090        assert_eq!(
1091            refreshed.matches("BEGIN release-kit").count(),
1092            1,
1093            "a re-splice must replace, not accumulate"
1094        );
1095    }
1096
1097    /// The hook block lands under `repos:` in every honest shape and
1098    /// refuses the one dishonest shape by name.
1099    #[test]
1100    fn the_hook_block_splices_under_repos() {
1101        let owned = hooks_block(CheckoutMode::MainWorktree);
1102        let block = owned.as_str();
1103        let fresh = splice_hooks_block(None, block).expect("a fresh file splices");
1104        assert!(fresh.starts_with(HOOK_TYPES_LINE));
1105        assert!(fresh.contains("\nrepos:\n# BEGIN release-kit\n"));
1106        assert_eq!(extract_block(&fresh, HOOKS_BEGIN, HOOKS_END), Some(block));
1107
1108        let own =
1109            "repos:\n  - repo: https://example.com/own\n    rev: v1\n    hooks:\n      - id: own\n";
1110        let spliced = splice_hooks_block(Some(own), block).expect("an unmarked file splices");
1111        assert!(spliced.starts_with("repos:\n# BEGIN release-kit\n"));
1112        assert!(spliced.contains("- id: own"), "the target's hooks survive");
1113        assert!(
1114            !spliced.contains(HOOK_TYPES_LINE),
1115            "an existing file's top level is the skills' duty, not the splice's"
1116        );
1117
1118        let stale = spliced.replace("--force-scope", "--no-scope");
1119        let refreshed = splice_hooks_block(Some(&stale), block).expect("a marked file re-splices");
1120        assert_eq!(
1121            extract_block(&refreshed, HOOKS_BEGIN, HOOKS_END),
1122            Some(block)
1123        );
1124        assert_eq!(refreshed.matches(HOOKS_BEGIN).count(), 1);
1125
1126        let err = splice_hooks_block(Some("minimum_pre_commit_version: '3.2.0'\n"), block)
1127            .expect_err("no repos: line refuses");
1128        assert!(err.contains("repos:"), "{err}");
1129
1130        // The hooks between the markers execute, so ownership is exactly
1131        // one well-formed block: a duplicate or an unmatched marker
1132        // refuses rather than leaving a stale block active.
1133        let doubled = format!("repos:\n{block}\n{block}\n");
1134        let err = splice_hooks_block(Some(&doubled), block).expect_err("a second block refuses");
1135        assert!(err.contains("one block"), "{err}");
1136        let unmatched = "repos:\n# BEGIN release-kit\n  - repo: local\n";
1137        let err =
1138            splice_hooks_block(Some(unmatched), block).expect_err("an unmatched marker refuses");
1139        assert!(err.contains("unmatched"), "{err}");
1140    }
1141
1142    /// Both modes of both blocks: the guard entry and the skip pair exist
1143    /// exactly in the worktree mode, one orientation line differs in the
1144    /// routing block, the rest is byte-identical, no mode token survives
1145    /// substitution, and the rendered grammar is [`BRANCH_GRAMMAR`], the
1146    /// one owner.
1147    #[test]
1148    fn the_blocks_render_per_mode_and_carry_the_one_grammar() {
1149        let worktree_hooks = hooks_block(CheckoutMode::LinkedWorktree);
1150        let branches_hooks = hooks_block(CheckoutMode::MainWorktree);
1151        assert!(worktree_hooks.contains("- id: rk-worktree-location"));
1152        assert!(
1153            worktree_hooks.contains("SKIP=no-commit-to-branch,rk-worktree-location"),
1154            "{worktree_hooks}"
1155        );
1156        assert!(!branches_hooks.contains("rk-worktree-location"));
1157        assert!(branches_hooks.contains("SKIP=no-commit-to-branch in"));
1158        for block in [&worktree_hooks, &branches_hooks] {
1159            assert!(block.contains(BRANCH_GRAMMAR), "the grammar has one owner");
1160            for token in [
1161                "RK_BRANCH_GRAMMAR",
1162                "RK_SWEEP_SKIP",
1163                "RK_SWEEP_NOTE",
1164                "RK_WORKTREE_GUARD",
1165                "RK_TRUNK_COMMIT_GUARD",
1166                "RK_TRUNK_PUSH_GUARD",
1167            ] {
1168                assert!(!block.contains(token), "{token} survived: {block}");
1169            }
1170        }
1171        // A hook entry renders as a YAML plain scalar, where a colon
1172        // followed by a space ends the scalar and breaks the whole file
1173        // — the defect dogfood caught in the guard's refusal messages —
1174        // so no entry value may carry one.
1175        for block in [&worktree_hooks, &branches_hooks] {
1176            for line in block.lines() {
1177                if let Some(value) = line.trim_start().strip_prefix("entry: ") {
1178                    assert!(
1179                        !value.contains(": "),
1180                        "an entry value breaks the YAML plain scalar: {line}"
1181                    );
1182                }
1183            }
1184        }
1185        let guard_line = worktree_hooks
1186            .lines()
1187            .position(|line| line.contains("id: rk-worktree-location"))
1188            .expect("the guard entry exists");
1189        let name_line = worktree_hooks
1190            .lines()
1191            .position(|line| line.contains("id: rk-branch-name"))
1192            .expect("the name hook exists");
1193        assert!(
1194            guard_line > name_line,
1195            "the guard lands directly after rk-branch-name"
1196        );
1197    }
1198
1199    /// The routing block carries what the release process needs in every
1200    /// capability state, and the workflow guards add only their two
1201    /// policy fragments. No state carries a line that tells the project
1202    /// how to integrate, start work, or lay out its working copies.
1203    #[test]
1204    fn the_routing_block_adds_only_the_policy_lines_under_the_guards() {
1205        let guarded = routing_block(true);
1206        let plain = routing_block(false);
1207        for block in [&guarded, &plain] {
1208            for token in ["RK_AGENT_POLICY", "RK_COMMIT_POLICY"] {
1209                assert!(!block.contains(token), "{token} survived: {block}");
1210            }
1211            for kept in [
1212                "runs the release-kit convention",
1213                "MUST be a scoped Conventional Commit",
1214                "Never author a tag",
1215                "Run `rk status` before changing",
1216                "`rk method --list`",
1217            ] {
1218                assert!(block.contains(kept), "{kept}: {block}");
1219            }
1220            for gone in [
1221                "`rk integrate <branch>`",
1222                "squash-merged pull request",
1223                "`rk issue start <issue>`",
1224                "`rk worktree add <branch>`",
1225                "Branches are worked in the main checkout",
1226            ] {
1227                assert!(!block.contains(gone), "{gone}: {block}");
1228            }
1229        }
1230        assert!(guarded.contains("guides and never drives"), "{guarded}");
1231        assert!(guarded.contains("`GLOSSARY.md`"), "{guarded}");
1232        assert!(guarded.contains("Every commit follows the same scoped convention"));
1233        assert!(!plain.contains("guides and never drives"), "{plain}");
1234        assert!(!plain.contains("Every commit follows"), "{plain}");
1235        // The guards insert lines and change none: the plain block is the
1236        // guarded one with the fragments taken out.
1237        let mut guarded_lines = guarded.lines();
1238        for line in plain.lines() {
1239            assert!(
1240                guarded_lines.any(|other| other == line),
1241                "the guards changed a line the plain block carries: {line}"
1242            );
1243        }
1244    }
1245
1246    /// Every word that names a release reason, matched whole: the routing
1247    /// block, the glossary, and the hook block of a release-less target
1248    /// carry none of them.
1249    fn release_words(text: &str) -> Vec<String> {
1250        text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))
1251            .filter(|word| {
1252                matches!(
1253                    *word,
1254                    "version" | "versions" | "changelog" | "changelogs" | "tag" | "tags"
1255                )
1256            })
1257            .map(str::to_owned)
1258            .collect()
1259    }
1260
1261    /// One block shape on the axes the routing block and the glossary read.
1262    fn shape(
1263        workflow_guards: bool,
1264        release: bool,
1265        merge_form: crate::landing::MergeForm,
1266    ) -> projection::BlockShape {
1267        projection::BlockShape {
1268            checkout_mode: CheckoutMode::LinkedWorktree,
1269            integration: Integration::Local,
1270            merge_form,
1271            workflow_guards,
1272            release,
1273        }
1274    }
1275
1276    /// The routing block of a release-less target is the workflow variant:
1277    /// no `## Releases` heading, no version, changelog, or tag reason, a
1278    /// scope preferred rather than required, and the two workflow terms. A
1279    /// release-bearing target keeps every line it carried, and the message
1280    /// source line names the target's own merge form.
1281    ///
1282    /// SATISFIES landing:the-routing-block-bounds-the-agents-initiative
1283    /// SATISFIES project-profile:a-release-less-landing-carries-no-release-text
1284    #[test]
1285    fn workflow_only_routing_block_carries_no_release_text() {
1286        use crate::landing::MergeForm;
1287        for form in [MergeForm::Squash, MergeForm::Rebase] {
1288            for guards in [true, false] {
1289                let workflow = projection::routing_block_of(&shape(guards, false, form))
1290                    .expect("the binary embeds the block");
1291                let at = format!("{form:?} guards {guards}");
1292                assert!(!workflow.contains("## Releases"), "{at}: {workflow}");
1293                assert!(workflow.contains("## Workflow"), "{at}: {workflow}");
1294                assert_eq!(release_words(&workflow), Vec::<String>::new(), "{at}");
1295                assert!(!workflow.contains("rk method recovery"), "{at}");
1296                for token in [
1297                    "RK_AGENT_POLICY",
1298                    "RK_COMMIT_POLICY",
1299                    "RK_COMMIT_SOURCE",
1300                    "RK_COMMIT_SCOPE",
1301                ] {
1302                    assert!(!workflow.contains(token), "{at}: {token} survived");
1303                }
1304                assert!(
1305                    workflow.contains("MUST be a Conventional Commit"),
1306                    "{at}: {workflow}"
1307                );
1308                if guards {
1309                    for kept in [
1310                        "guides and never drives",
1311                        "`implement-and-request` and `implement-and-merge`",
1312                        "A scope is preferred and not required.",
1313                        "names no internal planning artifact and carries no agent attribution",
1314                    ] {
1315                        assert!(workflow.contains(kept), "{at}: {kept}: {workflow}");
1316                    }
1317                    assert!(!workflow.contains("full-implement"), "{at}: {workflow}");
1318                    assert!(!workflow.contains("requires a scope"), "{at}: {workflow}");
1319                }
1320                let releasing = projection::routing_block_of(&shape(guards, true, form))
1321                    .expect("the binary embeds the block");
1322                assert!(releasing.contains("## Releases"), "{at}");
1323                assert!(releasing.contains("Never author a tag"), "{at}");
1324                if guards {
1325                    assert!(releasing.contains("requires a scope on every one"), "{at}");
1326                    assert_eq!(
1327                        releasing.contains("The request's title becomes that commit's message"),
1328                        form == MergeForm::Squash,
1329                        "{at}: the title is the message under squash alone"
1330                    );
1331                    assert_eq!(
1332                        releasing.contains(
1333                            "Every commit of a branch reaches the trunk with its own message"
1334                        ),
1335                        form == MergeForm::Rebase,
1336                        "{at}"
1337                    );
1338                }
1339            }
1340        }
1341        // The release-bearing squash block is the block every target
1342        // already carries, byte for byte.
1343        assert_eq!(
1344            projection::routing_block_of(&shape(true, true, MergeForm::Squash))
1345                .expect("the binary embeds the block"),
1346            routing_block(true)
1347        );
1348    }
1349
1350    /// The glossary of a release-less target names no release term: the
1351    /// two workflow terms stand, and the term that ends in a release does
1352    /// not.
1353    ///
1354    /// SATISFIES project-profile:a-release-less-landing-carries-no-release-text
1355    #[test]
1356    fn workflow_only_glossary_carries_no_release_term() {
1357        let workflow =
1358            projection::glossary_block_of(&shape(true, false, crate::landing::MergeForm::Squash))
1359                .expect("the binary embeds the block");
1360        assert!(workflow.contains("`implement-and-request`"), "{workflow}");
1361        assert!(workflow.contains("`implement-and-merge`"), "{workflow}");
1362        assert!(!workflow.contains("full-implement"), "{workflow}");
1363        assert!(!workflow.contains("release,"), "{workflow}");
1364        assert!(!workflow.contains("before the release"), "{workflow}");
1365        assert_eq!(release_words(&workflow), Vec::<String>::new());
1366        assert!(workflow.starts_with(BLOCK_BEGIN), "{workflow}");
1367        assert!(workflow.ends_with(BLOCK_END), "{workflow}");
1368        assert_eq!(
1369            projection::glossary_block_of(&shape(true, true, crate::landing::MergeForm::Squash))
1370                .expect("the binary embeds the block"),
1371            glossary_block()
1372        );
1373    }
1374
1375    /// The hook block of a release-less target carries no tag guard, and
1376    /// its commit hook accepts a header with no scope; a release-bearing
1377    /// block keeps both, byte for byte as before.
1378    ///
1379    /// SATISFIES landing:a-landed-hook-serves-the-release-convention-alone
1380    #[test]
1381    fn workflow_only_hook_block_drops_the_tag_guard_and_the_forced_scope() {
1382        let authored = projection::embedded_block(projection::PRE_COMMIT_MESSAGE_GUARDS)
1383            .expect("the binary embeds the block");
1384        assert_eq!(
1385            authored.matches(projection::FORCE_SCOPE_ARG).count(),
1386            1,
1387            "the authored commit hook carries the argument the renderer drops: {authored}"
1388        );
1389        for (mode, integration) in MODE_PAIRS {
1390            for guards in [true, false] {
1391                let at = format!("{mode:?} {integration:?} guards {guards}");
1392                let release_less = projection::hooks_block_of(&projection::BlockShape {
1393                    checkout_mode: mode,
1394                    integration,
1395                    merge_form: crate::landing::MergeForm::Squash,
1396                    workflow_guards: guards,
1397                    release: false,
1398                })
1399                .expect("the binary embeds the block");
1400                assert!(!release_less.contains("rk-no-hand-authored-tag"), "{at}");
1401                assert!(!release_less.contains("--force-scope"), "{at}");
1402                assert!(release_less.contains("- id: rk-status-check"), "{at}");
1403                assert!(!release_less.contains("RK_TAG_GUARD"), "{at}");
1404                assert!(!release_less.contains("RK_STATUS_RELEASE_FILES"), "{at}");
1405                assert!(!release_less.contains("release-plz"), "{at}");
1406                assert!(
1407                    release_less.contains("\\.pre-commit-config\\.yaml$)'"),
1408                    "{at}: {release_less}"
1409                );
1410                assert_eq!(release_words(&release_less), Vec::<String>::new(), "{at}");
1411                if guards {
1412                    assert!(release_less.contains("args: [--strict]\n"), "{at}");
1413                }
1414                let releasing = if guards {
1415                    hooks_block_for(mode, integration)
1416                } else {
1417                    unguarded_hooks_block(mode, integration)
1418                };
1419                assert!(releasing.contains("- id: rk-no-hand-authored-tag"), "{at}");
1420                assert!(
1421                    releasing.contains(r"\.pre-commit-config\.yaml$|release-plz\.toml$|dist-workspace\.toml$|release-please-config\.json$|cliff\.toml$|\.release-please-manifest\.json$|VERSION$)'"),
1422                    "{at}: the release-bearing pattern is unchanged: {releasing}"
1423                );
1424                assert_eq!(releasing.contains("--force-scope"), guards, "{at}");
1425            }
1426        }
1427    }
1428
1429    /// Without the workflow guards, the hook block carries the two hooks
1430    /// the release process needs and nothing that shapes how the project
1431    /// works, under either integration mode and either checkout mode.
1432    #[test]
1433    fn the_hook_block_without_the_guards_keeps_the_release_hooks_alone() {
1434        for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1435            for integration in [Integration::Forge, Integration::Local] {
1436                let block = unguarded_hooks_block(mode, integration);
1437                for kept in ["- id: rk-no-hand-authored-tag", "- id: rk-status-check"] {
1438                    assert!(block.contains(kept), "{kept}: {block}");
1439                }
1440                for gone in [
1441                    "conventional-pre-commit",
1442                    "rk-message",
1443                    "rk-branch-name",
1444                    "rk-worktree-location",
1445                    "rk-no-push-to-trunk",
1446                    "no-commit-to-branch",
1447                ] {
1448                    assert!(!block.contains(gone), "{gone}: {block}");
1449                }
1450                assert!(block.contains("A CI sweep needs no SKIP here"), "{block}");
1451                for token in [
1452                    "RK_MESSAGE_GUARDS",
1453                    "RK_BRANCH_NAME_GUARD",
1454                    "RK_WORKTREE_GUARD",
1455                    "RK_TRUNK_COMMIT_GUARD",
1456                    "RK_TRUNK_PUSH_GUARD",
1457                    "RK_SWEEP_NOTE",
1458                ] {
1459                    assert!(!block.contains(token), "{token} survived: {block}");
1460                }
1461                assert_eq!(
1462                    block.contains("--hook-stage manual"),
1463                    integration == Integration::Local,
1464                    "{block}"
1465                );
1466            }
1467        }
1468    }
1469
1470    /// The integration axis decides exactly which trunk guards render,
1471    /// and it decides nothing about the checkout axis.
1472    ///
1473    /// Both columns refuse a hand commit on the trunk. The forge column
1474    /// also refuses every trunk push, because a request is the one way in.
1475    /// The local column refuses the commit at the pre-commit stage alone,
1476    /// and guards the push the operator makes after `rk integrate` against
1477    /// a rewrite and a merge commit, because `rk integrate` moves the trunk
1478    /// with no commit hook. The location guard is integration-blind in
1479    /// both directions, which is the claim the two axes being orthogonal
1480    /// rests on.
1481    ///
1482    /// SATISFIES maintenance:local-integration-guards-the-trunk
1483    #[test]
1484    fn the_integration_mode_decides_which_trunk_guards_render() {
1485        for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1486            let forge = hooks_block_for(mode, Integration::Forge);
1487            let local = hooks_block_for(mode, Integration::Local);
1488            assert!(forge.contains("- id: no-commit-to-branch"), "{forge}");
1489            assert!(forge.contains("- id: rk-no-push-to-trunk"), "{forge}");
1490            assert!(!forge.contains("rk-no-force-push-to-trunk"), "{forge}");
1491            assert!(!forge.contains("rk-no-merge-commit-to-trunk"), "{forge}");
1492            assert!(local.contains("- id: no-commit-to-branch"), "{local}");
1493            assert!(
1494                local.contains("args: [--branch, RK_TRUNK_BRANCH]\n        stages: [pre-commit]"),
1495                "the local trunk guard runs at the pre-commit stage alone: {local}"
1496            );
1497            assert!(local.contains("- id: rk-no-force-push-to-trunk"), "{local}");
1498            assert!(
1499                local.contains("- id: rk-no-merge-commit-to-trunk"),
1500                "{local}"
1501            );
1502            assert!(!local.contains("rk-no-push-to-trunk"), "{local}");
1503            // Everything the integration axis does not own is unchanged.
1504            for kept in [
1505                "- id: conventional-pre-commit",
1506                "- id: rk-message",
1507                "- id: rk-branch-name",
1508                "- id: rk-no-hand-authored-tag",
1509                "- id: rk-status-check",
1510            ] {
1511                assert!(forge.contains(kept), "{kept}: {forge}");
1512                assert!(local.contains(kept), "{kept}: {local}");
1513            }
1514            // The checkout axis still decides the location guard, and the
1515            // integration axis touches it in neither direction.
1516            let located = mode == CheckoutMode::LinkedWorktree;
1517            assert_eq!(forge.contains("- id: rk-worktree-location"), located);
1518            assert_eq!(local.contains("- id: rk-worktree-location"), located);
1519            // Both integration modes teach the install line, for either
1520            // hook runner, with the three stages the block's hooks run at.
1521            for block in [&forge, &local] {
1522                assert!(
1523                    block.contains(
1524                        "# prek install --hook-type pre-commit --hook-type commit-msg --hook-type pre-push"
1525                    ),
1526                    "{block}"
1527                );
1528                assert!(
1529                    block.contains("prek or pre-commit, which take the same flags"),
1530                    "{block}"
1531                );
1532            }
1533            // Only local integration names the pre-integrate contract.
1534            assert!(
1535                local.contains("prek run --hook-stage manual --all-files"),
1536                "{local}"
1537            );
1538            assert!(local.contains("or as `pre-commit run` with"), "{local}");
1539            assert!(!forge.contains("--hook-stage manual"), "{forge}");
1540            for token in [
1541                "RK_TRUNK_COMMIT_GUARD",
1542                "RK_TRUNK_PUSH_GUARD",
1543                "RK_SWEEP_NOTE",
1544            ] {
1545                assert!(!local.contains(token), "{token} survived: {local}");
1546                assert!(!forge.contains(token), "{token} survived: {forge}");
1547            }
1548        }
1549        // The sweep note names exactly the hooks a trunk checkout meets:
1550        // the trunk guard under either integration mode, and the location
1551        // guard in the worktree mode.
1552        assert!(
1553            hooks_block_for(CheckoutMode::LinkedWorktree, Integration::Local)
1554                .contains("SKIP=no-commit-to-branch,rk-worktree-location in")
1555        );
1556        assert!(
1557            hooks_block_for(CheckoutMode::MainWorktree, Integration::Local)
1558                .contains("SKIP=no-commit-to-branch in")
1559        );
1560        assert!(
1561            unguarded_hooks_block(CheckoutMode::MainWorktree, Integration::Local)
1562                .contains("A CI sweep needs no SKIP here")
1563        );
1564    }
1565
1566    /// Both sweep notes invite the project to name its own local-only
1567    /// checks, so the pairing that starts empty still teaches the
1568    /// mechanism rather than only the one that starts full.
1569    ///
1570    /// SATISFIES git:a-check-declares-where-it-runs
1571    #[test]
1572    fn a_landed_sweep_note_invites_the_projects_own_skips() {
1573        let with_skip = hooks_block_for(CheckoutMode::LinkedWorktree, Integration::Local);
1574        assert!(
1575            with_skip.contains("SKIP=no-commit-to-branch,rk-worktree-location in"),
1576            "{with_skip}"
1577        );
1578        assert!(with_skip.contains("local-only"), "{with_skip}");
1579        assert!(
1580            with_skip.contains("a job running the hook runner against a trunk"),
1581            "{with_skip}"
1582        );
1583        assert!(with_skip.contains("the value is a list the"), "{with_skip}");
1584
1585        let without = unguarded_hooks_block(CheckoutMode::MainWorktree, Integration::Local);
1586        assert!(
1587            without.contains("A CI sweep needs no SKIP here"),
1588            "{without}"
1589        );
1590        assert!(without.contains("local-only"), "{without}");
1591    }
1592
1593    /// The pre-integrate note teaches the stage assignment and the scope
1594    /// assignment together, because a check assigned a stage and no scope
1595    /// is still unclassified.
1596    ///
1597    /// SATISFIES git:a-check-declares-where-it-runs
1598    #[test]
1599    fn a_landed_integrate_note_names_both_axes() {
1600        for mode in [CheckoutMode::LinkedWorktree, CheckoutMode::MainWorktree] {
1601            let local = hooks_block_for(mode, Integration::Local);
1602            assert!(
1603                local.contains("prek run --hook-stage manual --all-files"),
1604                "{local}"
1605            );
1606            assert!(
1607                local.contains("continuous integration invoke the same stages"),
1608                "{local}"
1609            );
1610            assert!(
1611                local.contains("guards one side alone"),
1612                "the note states the consequence: {local}"
1613            );
1614            // The note belongs to the mode that has a pre-integrate gate.
1615            let forge = hooks_block_for(mode, Integration::Forge);
1616            assert!(!forge.contains("--hook-stage manual"), "{forge}");
1617        }
1618    }
1619
1620    /// One definition of an ill-formed hook file, for every reader: the
1621    /// One definition of an ill-formed hook file, for every reader: the
1622    /// well-formed shapes pass and each ambiguous shape names a defect.
1623    #[test]
1624    fn the_hook_marker_defects_are_named() {
1625        use super::hooks_marker_defect;
1626        let owned = hooks_block(CheckoutMode::MainWorktree);
1627        let block = owned.as_str();
1628        assert_eq!(hooks_marker_defect(""), None);
1629        assert_eq!(hooks_marker_defect(&format!("repos:\n{block}\n")), None);
1630        for (case, text) in [
1631            (
1632                "a second begin",
1633                format!("repos:\n{block}\n# BEGIN release-kit\n"),
1634            ),
1635            (
1636                "a second end",
1637                format!("repos:\n{block}\n# END release-kit\n"),
1638            ),
1639            (
1640                "an unpaired begin",
1641                "repos:\n# BEGIN release-kit\n".to_owned(),
1642            ),
1643            ("an unpaired end", "repos:\n# END release-kit\n".to_owned()),
1644            (
1645                "an end before its begin",
1646                "repos:\n# END release-kit\n# BEGIN release-kit\n".to_owned(),
1647            ),
1648        ] {
1649            assert!(
1650                hooks_marker_defect(&text).is_some(),
1651                "{case} must be a defect"
1652            );
1653        }
1654    }
1655}