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