Skip to main content

release_kit/
landing.rs

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