campaign-repos: a campaign's repo set changes by target revision; one line_of decides supersession lineage (squash of eureka-substrate/campaign-repos c0a1f2c..4442e1a)
target.repos additive at epoch v2; Docs::repos_in_force owns membership; line_of owns lineage (targets per campaign, cut_specs per campaign+cut, stewardships per instance+repo, rulings and follow_ups global); rival_in_force owns one-assignment-in-force. Soul s5 holds (eureka-substrate:verdict:cut-campaign-repos.s5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
35dda44001
commit
f8eaf50a03
11 changed files with 596 additions and 43 deletions
|
|
@ -10,6 +10,7 @@
|
|||
# Epiphany ef956865 modulo that rename (verdict ...s2). Never regenerate to make
|
||||
# a failing test pass: a diff here is a wire change and needs its own ruling.
|
||||
# cut_spec.reads-and-symbol and finding.location-symbol were appended after 44c010c added CodeLocation.symbol and PipelineCutSpec.reads (ruling context-pack-read-anchors); no earlier line changed.
|
||||
# target.repos was appended after 9298371 added PipelineTarget.repos (ruling campaign-repo-set-home); no earlier line changed.
|
||||
sample.campaign eureka-state:campaign:self epiphany.pipeline.campaign.v2 EpiphanyPipelineCampaignDocument 9185a4736c7567ac657572656b612d7374617465a57469746c65b5457572656b6120706970656c696e65207374617465a57265706f7391b147616d6543756c742f4570697068616e79ae776f726b696e675f6272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465aa7461726765745f646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864
|
||||
sample.target eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9186a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864
|
||||
sample.question eureka-state:question:Q1 epiphany.pipeline.question.v2 EpiphanyPipelineQuestionDocument 9189a863616d706169676eac657572656b612d7374617465a56c6162656ca25131a57469746c65b357686f206f776e73207468652073746174653fa87175657374696f6eb357686f206f776e73207468652073746174653fa76f7074696f6e739282a56c6162656ca141a474657874ab616e20696e7374616e636582a56c6162656ca142a474657874a661207265706fab7265636f6d6d656e646564a141a7646570656e647391a6437574203361a97261697365645f696e82a46b696e64a743757453706563a26964bf657572656b612d73746174653a6375745f737065633a6375742d33612e7231a861736b65645f6f6eaa323032362d30392d3135
|
||||
|
|
@ -43,3 +44,4 @@ finding.severity-low eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding
|
|||
ruling.authority-mind eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273c0a663686f696365c0a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465c0a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f72697479a44d696e64
|
||||
cut_spec.reads-and-symbol eureka-state:cut_spec:cut-3a.r1 epiphany.pipeline.cut_spec.v2 EpiphanyPipelineCutSpecDocument 91de0013a863616d706169676eac657572656b612d7374617465a3637574a23361a87265766973696f6e01a57469746c65b2506970656c696e6520646f63756d656e7473a47265706fb147616d6543756c742f4570697068616e79a66272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465a462617365a83566393832323864aa646570656e64735f6f6e91a132a5666972737491ae526561642074686520737065632ea764656c657465739183a470617468a66f6c642e7273a56c696e657303a46e6f7465a464656164ab6b656570735f6d6f76657391ac636f6d6d6974206f776e6572a46164647391b5706970656c696e655f646f63756d656e74732e7273ac66696c655f6368616e6765739182a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab76616c75655f7479706573a66368616e6765a3616464a572656164739282a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e650aa8656e645f6c696e6514a673796d626f6caf506970656c696e6543757453706563a3776879b274686520646f63756d656e7420736861706582a86c6f636174696f6e83a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e651ea8656e645f6c696e65c0a3776879a86f6e65206c696e65ad617574686f726974795f6d617087a56f776e6572a4636f7265a6696e7075747391af747970656420646f63756d656e7473a76f75747075747391a9656e76656c6f706573ad646572697665645f737461746591ac64657269766564206b657973b1666f7262696464656e5f7772697465727391a34d4350ac7368617265645f706174687391a961646d697373696f6ead64656c6574696f6e5f6c696e65a36e2f61ac766572696669636174696f6e84a66275696c647391ab636172676f20636865636ba574657374739182a46e616d65a46b657973a470696e73af6f6e652064657269766564206b6579a86e656761746976659182a77061747465726ea75665633c75383ea573636f7065a9646f63756d656e7473a86f70657261746f7291a46e6f6e65a8657374696d61746588ab6c696e65735f6164646564cd0384ad6c696e65735f72656d6f76656400b2646570656e64656e636965735f616464656491a8736368656d617273b4646570656e64656e636965735f72656d6f76656490ad666f726d6174735f616464656491b66570697068616e792e706970656c696e652e2a2e7631af666f726d6174735f72656d6f76656490ad746172676574735f616464656490af746172676574735f72656d6f76656490a772756c696e677391b6657572656b612d73746174653a72756c696e673a5238a97175657374696f6e7391b8657572656b612d73746174653a7175657374696f6e3a5131
|
||||
finding.location-symbol eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a448696768a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739184a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab2631313533303930343938b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564
|
||||
target.repos eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9187a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a57265706f7392b147616d6543756c742f4570697068616e79af47616d6543756c742f487567696e6ea3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864
|
||||
|
|
|
|||
|
|
@ -491,16 +491,17 @@ macro_rules! unit_enums {
|
|||
)*};
|
||||
}
|
||||
|
||||
/// RS-L closing fix 4: `campaign.repos` refuses two entries naming the same
|
||||
/// RS-L closing fix 4: a repo list refuses two entries naming the same
|
||||
/// repository by identity, not by raw spelling, so `GameCult/Epiphany` and
|
||||
/// `gamecult/epiphany` in one list refuse exactly as one entry twice would.
|
||||
/// This is a cross-element invariant on `PipelineCampaign` alone, not a
|
||||
/// per-type format rule any other `value_types!` struct shares, so it is
|
||||
/// wired in as that struct's one `=> extra` hook rather than folded into
|
||||
/// `Bounded for OrgRepo` or the list bound in `bounded_field!`.
|
||||
fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<(), PipelineRefusal> {
|
||||
/// This is a cross-element invariant on the lists alone, not a per-type format
|
||||
/// rule any other `value_types!` struct shares, so it is wired in as the
|
||||
/// `=> extra` hook of the two structs that carry a repo list
|
||||
/// (`PipelineCampaign.repos` and `PipelineTarget.repos`) rather than folded
|
||||
/// into `Bounded for OrgRepo` or the list bound in `bounded_field!`.
|
||||
fn repos_are_distinct(repos: &[OrgRepo], at: &str) -> Result<(), PipelineRefusal> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for repo in &campaign.repos {
|
||||
for repo in repos {
|
||||
if !seen.insert(repo.identity()) {
|
||||
return Err(PipelineRefusal::DuplicateRepo {
|
||||
field: format!("{at}.repos"),
|
||||
|
|
@ -511,6 +512,14 @@ fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<(), PipelineRefusal> {
|
||||
repos_are_distinct(&campaign.repos, at)
|
||||
}
|
||||
|
||||
fn target_repos_are_distinct(target: &PipelineTarget, at: &str) -> Result<(), PipelineRefusal> {
|
||||
repos_are_distinct(&target.repos, at)
|
||||
}
|
||||
|
||||
/// The most lines one read anchor may span.
|
||||
const READ_WINDOW_MAX_LINES: u32 = 100;
|
||||
|
||||
|
|
@ -625,10 +634,12 @@ value_types! {
|
|||
}
|
||||
|
||||
pub struct PipelineCampaign { slug: Slug, title: Title, repos: Vec<OrgRepo>[8], working_branch: Short, target_doc: DocRef } => campaign_repos_are_distinct
|
||||
/// `repos`, when non-empty, is the campaign's whole repo set from this
|
||||
/// revision on; empty keeps the previous set (ruling campaign-repo-set-home).
|
||||
pub struct PipelineTarget {
|
||||
campaign: Slug, revision: u32, invariants: Vec<TargetInvariant>[32], not_in_scope: Vec<Line>[32],
|
||||
canonical_implementations: Vec<Line>[16], doc: DocRef,
|
||||
}
|
||||
canonical_implementations: Vec<Line>[16], repos: Vec<OrgRepo>[16] = absent, doc: DocRef,
|
||||
} => target_repos_are_distinct
|
||||
pub struct PipelineQuestion {
|
||||
campaign: Slug, label: Label, title: Title, question: Para, options: Vec<QuestionOption>[8], recommended: Label,
|
||||
depends: Vec<Line>[8], raised_in: Option<PipelineRef>, asked_on: Date,
|
||||
|
|
@ -1238,7 +1249,7 @@ mod tests {
|
|||
(D::Target(PipelineTarget {
|
||||
campaign: slug(CAMPAIGN), revision: 2,
|
||||
invariants: vec![TargetInvariant { label: l("mind-admits"), statement: "Only admission writes.".into() }],
|
||||
not_in_scope: vec!["Eve browsing".into()], canonical_implementations: vec!["CultLib".into()], doc: doc_ref(),
|
||||
not_in_scope: vec!["Eve browsing".into()], canonical_implementations: vec!["CultLib".into()], repos: vec![], doc: doc_ref(),
|
||||
}), format!("{CAMPAIGN}:target:r2")),
|
||||
(D::Question(PipelineQuestion {
|
||||
campaign: slug(CAMPAIGN), label: l("Q1"), title: t("Who owns the state?"), question: "Who owns the state?".into(),
|
||||
|
|
@ -1506,6 +1517,9 @@ mod tests {
|
|||
let mut f = finding_sample();
|
||||
f.locations[0].symbol = Some("&1153090498".into());
|
||||
out.push(("finding.location-symbol".into(), D::Finding(f)));
|
||||
let PipelineDocument::Target(mut target) = samples().remove(1).0 else { unreachable!() };
|
||||
target.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("GameCult/Huginn".into())];
|
||||
out.push(("target.repos".into(), D::Target(target)));
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -3163,6 +3177,47 @@ mod tests {
|
|||
assert_eq!(PipelineDocument::Finding(finding).validate(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_repos_absent_decode_and_round_trip() -> Result<()> {
|
||||
// The MCP door: a JSON target with no `repos` key decodes to an empty list.
|
||||
let PipelineDocument::Target(sample) = samples().remove(1).0 else { unreachable!() };
|
||||
let mut json = serde_json::to_value(PipelineDocument::Target(sample.clone()))?;
|
||||
assert!(json["value"].get("repos").is_none(), "an empty repos is not written");
|
||||
let PipelineDocument::Target(decoded) = serde_json::from_value::<PipelineDocument>(json.clone())? else { unreachable!() };
|
||||
assert!(decoded.repos.is_empty());
|
||||
assert!(!rmp_serde::to_vec_named(&PipelineDocument::Target(sample.clone()))?.windows(5).any(|window| window == b"repos"));
|
||||
|
||||
let mut named = sample;
|
||||
named.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("GameCult/Huginn".into())];
|
||||
let document = PipelineDocument::Target(named);
|
||||
document.validate()?;
|
||||
json = serde_json::to_value(&document)?;
|
||||
assert_eq!(serde_json::from_value::<PipelineDocument>(json)?, document);
|
||||
let packed = rmp_serde::to_vec_named(&document)?;
|
||||
assert_eq!(rmp_serde::from_slice::<PipelineDocument>(&packed)?, document);
|
||||
assert!(packed.windows(5).any(|window| window == b"repos"), "the repos key is written");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_repos_bounded_and_distinct() {
|
||||
let with_repos = |repos: Vec<OrgRepo>| {
|
||||
let PipelineDocument::Target(mut target) = samples().remove(1).0 else { unreachable!() };
|
||||
target.repos = repos;
|
||||
PipelineDocument::Target(target).validate()
|
||||
};
|
||||
let distinct = |count: usize| (0..count).map(|index| OrgRepo(format!("GameCult/Repo{index}"))).collect::<Vec<_>>();
|
||||
assert_eq!(with_repos(distinct(16)), Ok(()));
|
||||
assert_eq!(
|
||||
with_repos(distinct(17)),
|
||||
Err(PipelineRefusal::FieldBound { field: "target.repos".into(), limit: 16, actual: 17 })
|
||||
);
|
||||
assert_eq!(
|
||||
with_repos(vec![OrgRepo("GameCult/Huginn".into()), OrgRepo("gamecult/huginn".into())]),
|
||||
Err(PipelineRefusal::DuplicateRepo { field: "target.repos".into(), repo: "gamecult/huginn".into() })
|
||||
);
|
||||
}
|
||||
|
||||
/// The marker needs an `Absent` spelling; the two impls say what absent is.
|
||||
#[test]
|
||||
fn absent_is_none_and_empty() {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ use schemars::JsonSchema;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::docs::{CitationRole, Docs, Staged, citations, kind_of_id, outcome_citations};
|
||||
use crate::docs::{CitationRole, Docs, Staged, citations, kind_of_id, line_of, outcome_citations};
|
||||
use crate::mind::{HuginnMindEpoch, Mind, unavailable};
|
||||
use crate::persona::{self, HuginnPersonaEntry, PersonaPutOutcome};
|
||||
use crate::receipt::{self, CommitOutcome, PipelineProvenance};
|
||||
|
|
@ -456,6 +456,15 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> {
|
|||
D::Target(target) => {
|
||||
let predecessor = D::Target(eureka_pipeline::PipelineTarget { revision: target.revision.wrapping_sub(1), ..target.clone() });
|
||||
revision_rule(docs, K::Target, key, target.revision, &predecessor)?;
|
||||
// A revision past the first may not empty the repo set its predecessor
|
||||
// named: empty means "keep the previous set", and that would silently
|
||||
// narrow it to the campaign's opening set (ruling campaign-repo-set-home).
|
||||
if target.repos.is_empty() && target.revision > 1 {
|
||||
let named = matches!(docs.find(K::Target, &pipeline_key(&predecessor)?), Some(D::Target(previous)) if !previous.repos.is_empty());
|
||||
if named {
|
||||
return Err(MindRefusal::EmptyRepos { campaign: key.into() });
|
||||
}
|
||||
}
|
||||
unique_labels("target.invariants", target.invariants.iter().map(|invariant| invariant.label.0.as_str()))
|
||||
}
|
||||
D::Question(question) => {
|
||||
|
|
@ -492,7 +501,7 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> {
|
|||
let Some(campaign) = campaign else {
|
||||
return Err(MindRefusal::MissingReference { kind: K::Campaign, id: spec.campaign.0.clone() });
|
||||
};
|
||||
if !campaign.repos.contains(&spec.repo) {
|
||||
if !docs.repos_in_force(campaign).contains(&spec.repo) {
|
||||
return Err(MindRefusal::RepoNotInCampaign { repo: spec.repo.0.clone() });
|
||||
}
|
||||
for ruling in &spec.rulings {
|
||||
|
|
@ -589,7 +598,7 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> {
|
|||
actual: stewardship.sequence,
|
||||
});
|
||||
}
|
||||
if docs.stewardships_of(mind, &stewardship.repo, None).iter().any(|(other, _)| *other != key) {
|
||||
if docs.rival_in_force(key, &staged.document).is_some() {
|
||||
return Err(MindRefusal::AlreadyStewarded { repo: stewardship.repo.0.clone() });
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -761,11 +770,16 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M
|
|||
&& subject_kind == K::Resolution
|
||||
&& let Some(D::Resolution(reinstating)) = docs.find(K::Resolution, subject_id)
|
||||
&& let Some(base) = docs.find(reinstating.subject.kind, &reinstating.subject.id.0)
|
||||
&& let Some(later) = docs.later_in_force(base)
|
||||
&& let Some(rival) = docs.rival_in_force(&reinstating.subject.id.0, base)
|
||||
{
|
||||
// The repo already has a steward, earlier or later: the admission
|
||||
// refusal for the same fact, from the same owner.
|
||||
if let D::Stewardship(base) = base {
|
||||
return Err(MindRefusal::AlreadyStewarded { repo: base.repo.0.clone() });
|
||||
}
|
||||
return Err(MindRefusal::WouldReinstateOverLater {
|
||||
subject: reinstating.subject.id.0.clone(),
|
||||
later: later.into(),
|
||||
later: rival.into(),
|
||||
});
|
||||
}
|
||||
// Withdrawing a run's closure puts the run back in force, and a run in
|
||||
|
|
@ -785,11 +799,12 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M
|
|||
}
|
||||
}
|
||||
match &resolution.outcome {
|
||||
ResolutionOutcome::Superseded { by } if subject_kind == K::CutSpec => {
|
||||
let Some(D::CutSpec(subject)) = docs.find(K::CutSpec, subject_id) else { return Ok(()) };
|
||||
ResolutionOutcome::Superseded { by } => {
|
||||
let Some(subject) = docs.find(subject_kind, subject_id) else { return Ok(()) };
|
||||
let line = line_of(subject);
|
||||
for supersessor in by {
|
||||
if let Some(D::CutSpec(spec)) = docs.find(K::CutSpec, &supersessor.id.0)
|
||||
&& spec.cut != subject.cut
|
||||
if let Some(next) = docs.find(subject_kind, &supersessor.id.0)
|
||||
&& (line.is_none() || line != line_of(next))
|
||||
{
|
||||
return Err(incompatible());
|
||||
}
|
||||
|
|
@ -1541,7 +1556,8 @@ mod tests {
|
|||
/// Q19 A's rationale, which the cap alone does not carry: a withdrawal
|
||||
/// never re-raises an earlier record over a later one. The repo went away
|
||||
/// and came back, so withdrawing the hand-off's withdrawal would put the
|
||||
/// first assignment back in force beside the second.
|
||||
/// first assignment back in force beside the second: the repo already has
|
||||
/// a steward.
|
||||
#[test]
|
||||
fn a_withdrawal_does_not_reinstate_a_stewardship_over_a_later_one() {
|
||||
let mut mind = seeded();
|
||||
|
|
@ -1549,12 +1565,11 @@ mod tests {
|
|||
let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() };
|
||||
back.handed_on = eureka_pipeline::Date("2026-09-17".into());
|
||||
committed(admit(&mut mind, vec![D::HandOff(back)]));
|
||||
let first = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1");
|
||||
let again = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n2");
|
||||
let withdrawal = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n1");
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])),
|
||||
MindRefusal::WouldReinstateOverLater { subject: first, later: again.clone() }
|
||||
MindRefusal::AlreadyStewarded { repo: REPO.into() }
|
||||
);
|
||||
let docs = Docs::from_image(mind.envelopes()).unwrap();
|
||||
let in_force = docs.stewardships_of(&slug(INSTANCE), &repo(REPO), None).iter().map(|(key, _)| *key).collect::<Vec<_>>();
|
||||
|
|
@ -1672,6 +1687,33 @@ mod tests {
|
|||
assert_eq!(in_force, vec![stewardship_key(1)]);
|
||||
}
|
||||
|
||||
/// One assignment stands per repo, whichever record a withdrawal would put
|
||||
/// back: reinstating the later of two withdrawn assignments while the
|
||||
/// earlier is back in force is refused, and the next assignment then meets
|
||||
/// exactly the one that stands.
|
||||
#[test]
|
||||
fn a_withdrawal_does_not_reinstate_a_stewardship_beside_an_earlier_one() {
|
||||
let mut mind = seeded();
|
||||
let n1 = stewardship_key(1);
|
||||
let n2 = stewardship_key(2);
|
||||
let first = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n1");
|
||||
let second = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n2.n1");
|
||||
committed(admit(&mut mind, vec![resolution(r(K::Stewardship, &n1), withdrawn())]));
|
||||
committed(admit(&mut mind, vec![stewardship_n(INSTANCE, REPO, 2)]));
|
||||
committed(admit(&mut mind, vec![resolution(r(K::Stewardship, &n2), withdrawn())]));
|
||||
committed(admit(&mut mind, vec![resolution(r(K::Resolution, &first), withdrawn())]));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &second), withdrawn())])),
|
||||
MindRefusal::AlreadyStewarded { repo: REPO.into() }
|
||||
);
|
||||
let in_force = docs_of(&mind).stewardships_of(&slug(INSTANCE), &repo(REPO), None).iter().map(|(key, _)| key.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(in_force, vec![n1], "the reinstated assignment stands alone");
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![stewardship_n(INSTANCE, REPO, 3)])),
|
||||
MindRefusal::AlreadyStewarded { repo: REPO.into() }
|
||||
);
|
||||
}
|
||||
|
||||
/// And it reads the batch: the assignment that stands later than the one
|
||||
/// a withdrawal would reinstate may be landing beside that withdrawal,
|
||||
/// derived by a hand-off in the same batch.
|
||||
|
|
@ -1685,7 +1727,7 @@ mod tests {
|
|||
hand_off_on(OTHER_INSTANCE, INSTANCE, "2026-09-17"),
|
||||
resolution(r(K::Resolution, &withdrawal), withdrawn()),
|
||||
])),
|
||||
MindRefusal::WouldReinstateOverLater { subject: stewardship_key(1), later: stewardship_key(2) }
|
||||
MindRefusal::AlreadyStewarded { repo: REPO.into() }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2704,6 +2746,321 @@ mod tests {
|
|||
assert_eq!(refusal(admit(&mut mind, vec![D::CutSpec(spec)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() });
|
||||
}
|
||||
|
||||
/// A target revision naming repos, and the supersession of its predecessor.
|
||||
fn repo_revision(revision: u32, repos: &[&str]) -> Vec<D> {
|
||||
let D::Target(mut named) = target(revision, &[INVARIANT]) else { panic!() };
|
||||
named.repos = repos.iter().map(|name| repo(name)).collect();
|
||||
let previous = r(K::Target, &id("target", &format!("r{}", revision - 1)));
|
||||
let this = r(K::Target, &id("target", &format!("r{revision}")));
|
||||
vec![D::Target(named), resolution(previous, superseded(&[this]))]
|
||||
}
|
||||
|
||||
fn spec_on(cut: &str, repo_name: &str) -> D {
|
||||
let mut spec = cut_spec(cut, 1);
|
||||
spec.repo = repo(repo_name);
|
||||
D::CutSpec(spec)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_revision_adds_a_repo_a_spec_may_name() {
|
||||
let mut mind = seeded();
|
||||
assert_eq!(refusal(admit(&mut mind, vec![spec_on("1", OTHER_REPO)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() });
|
||||
committed(admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO])));
|
||||
committed(admit(&mut mind, vec![spec_on("1", OTHER_REPO)]));
|
||||
|
||||
// The same revision, supersession and spec in one batch.
|
||||
let mut fresh = seeded();
|
||||
let mut batch = repo_revision(2, &[REPO, OTHER_REPO]);
|
||||
batch.push(spec_on("1", OTHER_REPO));
|
||||
committed(admit(&mut fresh, batch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_revision_removes_a_repo() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![spec_on("1", REPO)]));
|
||||
committed(admit(&mut mind, repo_revision(2, &[OTHER_REPO])));
|
||||
let spec_id = id("cut_spec", "cut-1.r1");
|
||||
let spec_view = mind.view(&r(K::CutSpec, &spec_id)).expect("a valid ref").expect("the spec is held");
|
||||
assert_eq!(spec_view.status, crate::query::PipelineStatus::InForce, "spec 1 r1 stays in force");
|
||||
committed(admit(&mut mind, vec![D::CutReport(cut_report("1", 1))]));
|
||||
committed(admit(&mut mind, vec![resolution(r(K::CutSpec, &spec_id), withdrawn())]));
|
||||
|
||||
let mut second = cut_spec("1", 2);
|
||||
second.repo = repo(REPO);
|
||||
let revised = id("cut_spec", "cut-1.r2");
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![D::CutSpec(second), resolution(r(K::CutSpec, &spec_id), superseded(&[r(K::CutSpec, &revised)]))])),
|
||||
MindRefusal::RepoNotInCampaign { repo: REPO.into() }
|
||||
);
|
||||
committed(admit(&mut mind, vec![spec_on("2", OTHER_REPO)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_revision_cannot_silently_drop_its_repos() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO])));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, repo_revision(3, &[]))),
|
||||
MindRefusal::EmptyRepos { campaign: id("target", "r3") }
|
||||
);
|
||||
// Nothing was written: the restating revision takes the same key.
|
||||
committed(admit(&mut mind, repo_revision(3, &[REPO, OTHER_REPO])));
|
||||
// An empty revision whose predecessor named none keeps the campaign's set.
|
||||
let mut plain = seeded();
|
||||
committed(admit(&mut plain, repo_revision(2, &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repo_revision_replays_idempotently() {
|
||||
let mut mind = seeded();
|
||||
let (first, _) = committed(admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO])));
|
||||
assert_eq!(
|
||||
admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO])),
|
||||
PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: first }
|
||||
);
|
||||
committed(admit(&mut mind, vec![spec_on("1", OTHER_REPO)]));
|
||||
}
|
||||
|
||||
/// Another campaign, opened with `repos`, and its own target revision 1 naming `named`.
|
||||
fn other_campaign(repos: &[&str], named: &[&str]) -> Vec<D> {
|
||||
let D::Campaign(mut campaign) = campaign(repos) else { panic!("a campaign") };
|
||||
campaign.slug = "other-campaign".into();
|
||||
let D::Target(mut target) = target(1, &[INVARIANT]) else { panic!("a target") };
|
||||
target.campaign = "other-campaign".into();
|
||||
target.repos = named.iter().map(|name| repo(name)).collect();
|
||||
vec![D::Campaign(campaign), D::Target(target)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_campaigns_target_does_not_widen_membership() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, other_campaign(&[REPO], &[REPO, OTHER_REPO])));
|
||||
assert_eq!(refusal(admit(&mut mind, vec![spec_on("1", OTHER_REPO)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_is_superseded_only_within_its_campaign() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO])));
|
||||
committed(admit(&mut mind, other_campaign(&[REPO], &[])));
|
||||
let foreign = r(K::Target, "other-campaign:target:r1");
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![resolution(r(K::Target, &id("target", "r2")), superseded(&[foreign]))])),
|
||||
MindRefusal::IncompatibleResolution { subject_kind: K::Target, outcome: "Superseded".into() }
|
||||
);
|
||||
// Nothing was written: r2 still names the repos, and its own revision still lands.
|
||||
committed(admit(&mut mind, vec![spec_on("1", OTHER_REPO)]));
|
||||
committed(admit(&mut mind, repo_revision(3, &[REPO, OTHER_REPO])));
|
||||
}
|
||||
|
||||
/// A target of the first campaign (`CAMPAIGN`) at `revision`, naming no repos.
|
||||
fn own_target(revision: u32) -> D {
|
||||
target(revision, &[INVARIANT])
|
||||
}
|
||||
|
||||
const FOREIGN_TARGET: &str = "other-campaign:target:r1";
|
||||
|
||||
fn supersession_refused(outcome: PipelineAdmissionOutcome, subject_kind: K) -> bool {
|
||||
refusal(outcome) == MindRefusal::IncompatibleResolution { subject_kind, outcome: "Superseded".into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreign_target_in_the_same_batch_does_not_supersede() {
|
||||
let mut mind = seeded();
|
||||
let mut batch = other_campaign(&[REPO], &[]);
|
||||
batch.push(resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, FOREIGN_TARGET)])));
|
||||
assert!(supersession_refused(admit(&mut mind, batch), K::Target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_supersessor_of_a_target_is_checked_whichever_is_foreign() {
|
||||
for foreign_first in [false, true] {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, other_campaign(&[REPO], &[])));
|
||||
let own = r(K::Target, &id("target", "r2"));
|
||||
let foreign = r(K::Target, FOREIGN_TARGET);
|
||||
let by = if foreign_first { [foreign, own] } else { [own, foreign] };
|
||||
let batch = vec![own_target(2), resolution(r(K::Target, &id("target", "r1")), superseded(&by))];
|
||||
assert!(supersession_refused(admit(&mut mind, batch), K::Target), "foreign first: {foreign_first}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_campaigns_target_in_its_own_batch_is_not_closed_by_a_foreign_one() {
|
||||
let mut mind = seeded();
|
||||
let mut batch = other_campaign(&[REPO], &[]);
|
||||
batch.push(resolution(r(K::Target, FOREIGN_TARGET), superseded(&[r(K::Target, &id("target", "r1"))])));
|
||||
assert!(supersession_refused(admit(&mut mind, batch), K::Target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_spec_is_superseded_only_within_its_campaign() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![spec_on("1", REPO)]));
|
||||
let mut batch = other_campaign(&[REPO], &[]);
|
||||
let D::CutSpec(mut foreign) = spec_on("1", REPO) else { panic!("a spec") };
|
||||
foreign.campaign = "other-campaign".into();
|
||||
batch.push(D::CutSpec(foreign));
|
||||
committed(admit(&mut mind, batch));
|
||||
let lone = vec![resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[r(K::CutSpec, "other-campaign:cut_spec:cut-1.r1")]))];
|
||||
assert!(supersession_refused(admit(&mut mind, lone), K::CutSpec));
|
||||
// Nothing was written: this campaign's own revision still lands.
|
||||
let D::CutSpec(mut next) = spec_on("1", REPO) else { panic!("a spec") };
|
||||
next.revision = 2;
|
||||
let this = r(K::CutSpec, &id("cut_spec", "cut-1.r2"));
|
||||
committed(admit(&mut mind, vec![D::CutSpec(next), resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[this]))]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ruling_is_superseded_by_a_ruling_of_any_campaign() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![D::Ruling(ruling("old"))]));
|
||||
let mut batch = other_campaign(&[REPO], &[]);
|
||||
let mut foreign = ruling("new");
|
||||
foreign.campaign = "other-campaign".into();
|
||||
batch.push(D::Ruling(foreign));
|
||||
batch.push(resolution(r(K::Ruling, &id("ruling", "old")), superseded(&[r(K::Ruling, "other-campaign:ruling:new")])));
|
||||
committed(admit(&mut mind, batch));
|
||||
}
|
||||
|
||||
/// The slug that shares its dash prefix with `CAMPAIGN` (`eureka-state`).
|
||||
const RIVAL: &str = "eureka-substrate";
|
||||
|
||||
/// Revision `revision` of the rival campaign's target, with its campaign when it is
|
||||
/// the first and the supersession of its predecessor when it is not.
|
||||
fn rival_revision(revision: u32) -> Vec<D> {
|
||||
let D::Target(mut named) = target(revision, &[INVARIANT]) else { panic!("a target") };
|
||||
named.campaign = RIVAL.into();
|
||||
let mut batch = Vec::new();
|
||||
if revision == 1 {
|
||||
let D::Campaign(mut opened) = campaign(&[REPO]) else { panic!("a campaign") };
|
||||
opened.slug = RIVAL.into();
|
||||
batch.push(D::Campaign(opened));
|
||||
}
|
||||
batch.push(D::Target(named));
|
||||
if revision > 1 {
|
||||
let previous = r(K::Target, &format!("{RIVAL}:target:r{}", revision - 1));
|
||||
batch.push(resolution(previous, superseded(&[r(K::Target, &format!("{RIVAL}:target:r{revision}"))])));
|
||||
}
|
||||
batch
|
||||
}
|
||||
|
||||
/// Both campaigns at revision 3, so each one's target in force is above revision 1.
|
||||
fn both_campaigns_at_revision_three() -> Mind<MemoryStore> {
|
||||
let mut mind = seeded();
|
||||
for revision in 2..=3 {
|
||||
committed(admit(&mut mind, repo_revision(revision, &[REPO])));
|
||||
}
|
||||
for revision in 1..=3 {
|
||||
committed(admit(&mut mind, rival_revision(revision)));
|
||||
}
|
||||
mind
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_is_not_superseded_by_a_campaign_sharing_its_prefix_or_a_higher_revision() {
|
||||
for (subject, supersessor) in [
|
||||
(id("target", "r3"), format!("{RIVAL}:target:r3")),
|
||||
(format!("{RIVAL}:target:r3"), id("target", "r3")),
|
||||
] {
|
||||
let mut mind = both_campaigns_at_revision_three();
|
||||
let lone = vec![resolution(r(K::Target, &subject), superseded(&[r(K::Target, &supersessor)]))];
|
||||
assert!(supersession_refused(admit(&mut mind, lone), K::Target), "{subject} by {supersessor}");
|
||||
}
|
||||
// A foreign revision above the subject's is no likelier to pass.
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, repo_revision(2, &[REPO])));
|
||||
for revision in 1..=4 {
|
||||
committed(admit(&mut mind, rival_revision(revision)));
|
||||
}
|
||||
let lone = vec![resolution(r(K::Target, &id("target", "r2")), superseded(&[r(K::Target, &format!("{RIVAL}:target:r4"))]))];
|
||||
assert!(supersession_refused(admit(&mut mind, lone), K::Target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stewardship_is_superseded_only_on_its_own_instance_and_repo_line() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![stewardship(INSTANCE, OTHER_REPO)]));
|
||||
let epiphany = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1");
|
||||
let huginn = format!("{INSTANCE}:stewardship:gamecult_-huginn.n1");
|
||||
let lone = vec![resolution(r(K::Stewardship, &epiphany), superseded(&[r(K::Stewardship, &huginn)]))];
|
||||
assert!(supersession_refused(admit(&mut mind, lone), K::Stewardship));
|
||||
// Nothing was written: the repo's own next assignment still supersedes it.
|
||||
let next = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n2");
|
||||
committed(admit(&mut mind, vec![
|
||||
stewardship_n(INSTANCE, REPO, 2),
|
||||
resolution(r(K::Stewardship, &epiphany), superseded(&[r(K::Stewardship, &next)])),
|
||||
]));
|
||||
}
|
||||
|
||||
/// Re-homing work is how Self routes it (ruling `supersession-lineage-3`):
|
||||
/// a gap follow-up of one campaign is superseded by its successor in the
|
||||
/// campaign that owns the work now, and the typed edge survives.
|
||||
#[test]
|
||||
fn a_follow_up_is_superseded_by_one_re_homed_to_another_campaign() {
|
||||
let mut mind = seeded();
|
||||
let source = r(K::Target, &id("target", "r1"));
|
||||
committed(admit(&mut mind, vec![follow_up("gap-session-id", source.clone())]));
|
||||
let mut batch = rival_revision(1);
|
||||
let D::FollowUp(mut foreign) = follow_up("gap-session-id", source) else { panic!("a follow-up") };
|
||||
foreign.campaign = RIVAL.into();
|
||||
batch.push(D::FollowUp(foreign));
|
||||
batch.push(resolution(
|
||||
r(K::FollowUp, &id("follow_up", "gap-session-id")),
|
||||
superseded(&[r(K::FollowUp, &format!("{RIVAL}:follow_up:gap-session-id"))]),
|
||||
));
|
||||
committed(admit(&mut mind, batch));
|
||||
let docs = docs_of(&mind);
|
||||
assert!(!docs.in_force(K::FollowUp, &id("follow_up", "gap-session-id")));
|
||||
assert!(docs.in_force(K::FollowUp, &format!("{RIVAL}:follow_up:gap-session-id")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supersession_on_its_own_line_commits_for_every_kind() {
|
||||
let source = r(K::Target, &id("target", "r1"));
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, repo_revision(2, &[REPO])));
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![spec_on("1", REPO)]));
|
||||
let D::CutSpec(mut next) = spec_on("1", REPO) else { panic!("a spec") };
|
||||
next.revision = 2;
|
||||
committed(admit(&mut mind, vec![
|
||||
D::CutSpec(next),
|
||||
resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[r(K::CutSpec, &id("cut_spec", "cut-1.r2"))])),
|
||||
]));
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![follow_up("old", source.clone())]));
|
||||
committed(admit(&mut mind, vec![
|
||||
follow_up("new", source),
|
||||
resolution(r(K::FollowUp, &id("follow_up", "old")), superseded(&[r(K::FollowUp, &id("follow_up", "new"))])),
|
||||
]));
|
||||
let mut mind = seeded();
|
||||
let epiphany = format!("{INSTANCE}:stewardship:gamecult_-epiphany");
|
||||
committed(admit(&mut mind, vec![
|
||||
stewardship_n(INSTANCE, REPO, 2),
|
||||
resolution(r(K::Stewardship, &format!("{epiphany}.n1")), superseded(&[r(K::Stewardship, &format!("{epiphany}.n2"))])),
|
||||
]));
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![D::Ruling(ruling("old"))]));
|
||||
committed(admit(&mut mind, vec![
|
||||
D::Ruling(ruling("new")),
|
||||
resolution(r(K::Ruling, &id("ruling", "old")), superseded(&[r(K::Ruling, &id("ruling", "new"))])),
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ruling_of_one_campaign_is_superseded_by_a_ruling_of_a_prefix_sharing_campaign() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![D::Ruling(ruling("old"))]));
|
||||
let mut batch = rival_revision(1);
|
||||
let mut foreign = ruling("new");
|
||||
foreign.campaign = RIVAL.into();
|
||||
batch.push(D::Ruling(foreign));
|
||||
batch.push(resolution(r(K::Ruling, &id("ruling", "old")), superseded(&[r(K::Ruling, &format!("{RIVAL}:ruling:new"))])));
|
||||
committed(admit(&mut mind, batch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_off_derives_this_minds_side_only() {
|
||||
let key = format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.gamecult_-epiphany.2026-09-16");
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
use cultcache_rs::CultCacheEnvelope;
|
||||
use eureka_pipeline::{
|
||||
OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome,
|
||||
Label, OrgRepo, PipelineCampaign, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome,
|
||||
RunOperator, Short, Slug,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
|
|
@ -182,6 +182,23 @@ impl Docs {
|
|||
})
|
||||
}
|
||||
|
||||
/// The repos a campaign's cuts may name: the repo set of the highest-revision
|
||||
/// in-force target of `campaign.slug` that names one, else the campaign's own
|
||||
/// opening set. The one owner of membership (ruling campaign-repo-set-home).
|
||||
pub(crate) fn repos_in_force<'a>(&'a self, campaign: &'a PipelineCampaign) -> &'a [OrgRepo] {
|
||||
self.of_kind(PipelineKind::Target)
|
||||
.filter_map(|(key, document)| match document {
|
||||
PipelineDocument::Target(target)
|
||||
if target.campaign == campaign.slug && !target.repos.is_empty() && self.in_force(PipelineKind::Target, key) =>
|
||||
{
|
||||
Some(target)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.max_by_key(|target| target.revision)
|
||||
.map_or(campaign.repos.as_slice(), |target| target.repos.as_slice())
|
||||
}
|
||||
|
||||
/// The resolution that closes the document, if any: the views' status,
|
||||
/// derived where admission derives whether there is one at all.
|
||||
pub(crate) fn closing_resolution(&self, kind: PipelineKind, id: &str) -> Option<(&str, &PipelineResolution)> {
|
||||
|
|
@ -207,12 +224,17 @@ impl Docs {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// The in-force record that stands later than `base` in the scope `base`
|
||||
/// is sequenced in, if any: what a withdrawal that put `base` back in
|
||||
/// force would leave standing beside it.
|
||||
pub(crate) fn later_in_force(&self, base: &PipelineDocument) -> Option<&str> {
|
||||
/// The in-force record, other than `base_key`, that cannot stand beside
|
||||
/// `base` on its line: the one question "may this record be in force"
|
||||
/// asks, whether `base` is being admitted (a stewardship's
|
||||
/// `AlreadyStewarded`) or put back in force by a withdrawal. A line holds
|
||||
/// one stewardship at a time, so any other stands in its way; a revision
|
||||
/// is only displaced by a later one.
|
||||
pub(crate) fn rival_in_force(&self, base_key: &str, base: &PipelineDocument) -> Option<&str> {
|
||||
let kind = base.kind();
|
||||
self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key)
|
||||
self.of_kind(kind)
|
||||
.find(|(key, other)| *key != base_key && rivals(base, other) && self.in_force(kind, key))
|
||||
.map(|(key, _)| key)
|
||||
}
|
||||
|
||||
/// The in-force run, other than `run_key`, that holds `claim`: the one
|
||||
|
|
@ -282,22 +304,57 @@ impl Docs {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether `other` is a later record than `base` in the scope `base` is
|
||||
/// sequenced in: `(instance, repo)` for a stewardship, the campaign for a
|
||||
/// target and the cut for a cut spec. Kinds that carry no sequence have no
|
||||
/// scope and no order, and a resolution is never a base here: the cap above
|
||||
/// refuses the withdrawal that would reinstate one before the question of a
|
||||
/// later record arises.
|
||||
fn later_than(base: &PipelineDocument, other: &PipelineDocument) -> bool {
|
||||
/// The line a record belongs to (rulings `supersession-lineage-2` and `-3`): the one place
|
||||
/// lineage is decided. A record supersedes, and is reinstated over, only a record
|
||||
/// of its own line. `None` is a kind the resolution matrix never lets be
|
||||
/// superseded; the match names every kind, so a new kind fails to compile until
|
||||
/// its line is stated.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Lineage<'a> {
|
||||
/// A target: the campaign.
|
||||
Campaign(&'a Slug),
|
||||
/// A cut spec: the campaign and the cut.
|
||||
Cut(&'a Slug, &'a Label),
|
||||
/// A stewardship: the instance and the repo.
|
||||
Assignment(&'a Slug, &'a OrgRepo),
|
||||
/// A ruling or a follow-up: operator directions and re-homed work cut
|
||||
/// across campaigns.
|
||||
Global,
|
||||
}
|
||||
|
||||
pub(crate) fn line_of(document: &PipelineDocument) -> Option<Lineage<'_>> {
|
||||
use PipelineDocument as D;
|
||||
match document {
|
||||
D::Target(target) => Some(Lineage::Campaign(&target.campaign)),
|
||||
D::CutSpec(spec) => Some(Lineage::Cut(&spec.campaign, &spec.cut)),
|
||||
D::Stewardship(stewardship) => Some(Lineage::Assignment(&stewardship.instance, &stewardship.repo)),
|
||||
D::Ruling(_) | D::FollowUp(_) => Some(Lineage::Global),
|
||||
D::Campaign(_)
|
||||
| D::Question(_)
|
||||
| D::CutReport(_)
|
||||
| D::Verdict(_)
|
||||
| D::Finding(_)
|
||||
| D::Resolution(_)
|
||||
| D::Instance(_)
|
||||
| D::HandOff(_)
|
||||
| D::Run(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `other` cannot stand in force beside `base`, being on `base`'s line:
|
||||
/// any other stewardship of an instance and repo, a later revision of a target
|
||||
/// or cut spec. A resolution is never a base here: the cap in `resolution_rule`
|
||||
/// refuses the withdrawal that would reinstate one first.
|
||||
fn rivals(base: &PipelineDocument, other: &PipelineDocument) -> bool {
|
||||
use PipelineDocument as D;
|
||||
let Some(line) = line_of(base) else { return false };
|
||||
if line_of(other) != Some(line) {
|
||||
return false;
|
||||
}
|
||||
match (base, other) {
|
||||
(D::Stewardship(base), D::Stewardship(other)) => {
|
||||
other.instance == base.instance && other.repo == base.repo && other.sequence > base.sequence
|
||||
}
|
||||
(D::Target(base), D::Target(other)) => other.campaign == base.campaign && other.revision > base.revision,
|
||||
(D::CutSpec(base), D::CutSpec(other)) => {
|
||||
other.campaign == base.campaign && other.cut == base.cut && other.revision > base.revision
|
||||
}
|
||||
(D::Stewardship(_), D::Stewardship(_)) => true,
|
||||
(D::Target(base), D::Target(other)) => other.revision > base.revision,
|
||||
(D::CutSpec(base), D::CutSpec(other)) => other.revision > base.revision,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
|
@ -484,3 +541,51 @@ pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, Pipel
|
|||
}
|
||||
edges
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fixtures::*;
|
||||
use PipelineDocument as D;
|
||||
use PipelineKind as K;
|
||||
|
||||
fn held(kind: K, key: String, document: D) -> Held {
|
||||
Held { kind, key, document }
|
||||
}
|
||||
|
||||
fn named(campaign: &str, revision: u32, repos: &[&str]) -> (String, D) {
|
||||
let D::Target(mut target) = target(revision, &[INVARIANT]) else { panic!("a target") };
|
||||
target.campaign = campaign.into();
|
||||
target.repos = repos.iter().map(|name| repo(name)).collect();
|
||||
(format!("{campaign}:target:r{revision}"), D::Target(target))
|
||||
}
|
||||
|
||||
/// Membership is the highest-revision target of this campaign that is in
|
||||
/// force and names repos. The image is built by hand because admission
|
||||
/// cannot reach it: a revision above 1 supersedes its predecessor in its
|
||||
/// own batch, a target is superseded only within its campaign, and an
|
||||
/// empty revision after a repo-naming one is refused, so through admission
|
||||
/// a closed repo-naming target is always older than the in-force one.
|
||||
///
|
||||
/// Taking the lowest revision instead of the highest changes nothing there
|
||||
/// either (one target per campaign is in force), so that mutation is
|
||||
/// equivalent and not pinned.
|
||||
#[test]
|
||||
fn repos_in_force_is_this_campaigns_in_force_naming_target() {
|
||||
let D::Campaign(this) = campaign(&[REPO]) else { panic!("a campaign") };
|
||||
let (in_force_key, in_force) = named(CAMPAIGN, 1, &[REPO, OTHER_REPO]);
|
||||
let (closed_key, closed) = named(CAMPAIGN, 2, &["GameCult/Closed"]);
|
||||
let (foreign_key, foreign) = named("other-campaign", 3, &["GameCult/Foreign"]);
|
||||
let closing = resolution(r(K::Target, &closed_key), superseded(&[r(K::Target, &in_force_key)]));
|
||||
let docs = Docs {
|
||||
image: vec![
|
||||
held(K::Target, in_force_key, in_force),
|
||||
held(K::Target, closed_key, closed),
|
||||
held(K::Target, foreign_key, foreign),
|
||||
held(K::Resolution, id("resolution", "target.r2.n1"), closing),
|
||||
],
|
||||
batch: Vec::new(),
|
||||
};
|
||||
assert_eq!(docs.repos_in_force(&this), [repo(REPO), repo(OTHER_REPO)], "neither the closed higher revision nor another campaign's higher target decides");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ pub(crate) fn target(revision: u32, invariants: &[&str]) -> PipelineDocument {
|
|||
.collect(),
|
||||
not_in_scope: vec![],
|
||||
canonical_implementations: vec![],
|
||||
repos: vec![],
|
||||
doc: doc_ref(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ pub enum MindRefusal {
|
|||
InvalidOptions { question: String },
|
||||
InvalidChoice { ruling: String, choice: String },
|
||||
QuoteWithoutOperator { ruling: String },
|
||||
/// `campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos.
|
||||
EmptyRepos { campaign: String },
|
||||
RepoNotInCampaign { repo: String },
|
||||
UnknownDependency { cut: String },
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ fn target() -> PipelineDocument {
|
|||
invariants: vec![TargetInvariant { label: "run-is-the-grant".into(), statement: "A run is the grant.".into() }],
|
||||
not_in_scope: vec![],
|
||||
canonical_implementations: vec![],
|
||||
repos: vec![],
|
||||
doc: doc_ref(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ fn seed() -> Vec<PipelineDocument> {
|
|||
invariants: vec![TargetInvariant { label: "run-is-the-grant".into(), statement: "A run is the grant.".into() }],
|
||||
not_in_scope: vec![],
|
||||
canonical_implementations: vec![],
|
||||
repos: vec![],
|
||||
doc: doc_ref(),
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@
|
|||
"maxLength": 1000,
|
||||
"type": "string"
|
||||
},
|
||||
"OrgRepo": {
|
||||
"description": "A GitHub repository as `Org/Repo`, at most 140 UTF-8 bytes (39 + 1 +\n100). The owner is 1 to 39 `[A-Za-z0-9-]` bytes with no leading or\ntrailing hyphen; the repo is 1 to 100 `[A-Za-z0-9._-]` bytes, is never\n`.` or `..`, and does not end in `.git`, ASCII-case-insensitively.\nIdentity is case-insensitive: `GameCult/Epiphany` and `gamecult/epiphany`\nare the same repository.",
|
||||
"maxLength": 140,
|
||||
"type": "string"
|
||||
},
|
||||
"Sha": {
|
||||
"description": "A git commit id: 7-40 lowercase hex characters.",
|
||||
"maxLength": 40,
|
||||
|
|
@ -70,6 +75,7 @@
|
|||
},
|
||||
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.target.v2.schema.json",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "`repos`, when non-empty, is the campaign's whole repo set from this\nrevision on; empty keeps the previous set (ruling campaign-repo-set-home).",
|
||||
"properties": {
|
||||
"campaign": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
|
|
@ -98,6 +104,13 @@
|
|||
"maxItems": 32,
|
||||
"type": "array"
|
||||
},
|
||||
"repos": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/OrgRepo"
|
||||
},
|
||||
"maxItems": 16,
|
||||
"type": "array"
|
||||
},
|
||||
"revision": {
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
|
|
|
|||
|
|
@ -1542,6 +1542,7 @@
|
|||
]
|
||||
},
|
||||
"PipelineTarget": {
|
||||
"description": "`repos`, when non-empty, is the campaign's whole repo set from this\nrevision on; empty keeps the previous set (ruling campaign-repo-set-home).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"campaign": {
|
||||
|
|
@ -1571,6 +1572,13 @@
|
|||
},
|
||||
"maxItems": 32
|
||||
},
|
||||
"repos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/OrgRepo"
|
||||
},
|
||||
"maxItems": 16
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
|
|
|
|||
|
|
@ -1152,6 +1152,7 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
"description": "`campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"EmptyRepos": {
|
||||
|
|
@ -3801,6 +3802,7 @@
|
|||
]
|
||||
},
|
||||
"PipelineTarget": {
|
||||
"description": "`repos`, when non-empty, is the campaign's whole repo set from this\nrevision on; empty keeps the previous set (ruling campaign-repo-set-home).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"campaign": {
|
||||
|
|
@ -3830,6 +3832,13 @@
|
|||
},
|
||||
"maxItems": 32
|
||||
},
|
||||
"repos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/OrgRepo"
|
||||
},
|
||||
"maxItems": 16
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
|
|
|
|||
Loading…
Reference in a new issue