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