build: the lift matches cards by identity, not position

Sequences were walked side by side, so a rebuilt page whose cards sit
in a different order, or with a new card in front, gave every card
its neighbour's words: uhhm.no's front door got "Attention is earned"
on the wrong card that way. Items now match on the bucket they record
into, the page they lead to, a field's name, the group they invite
into or the bucket they read; information features with no identity
still match by position. Lifting live uhhm.no into the current plan
puts each of its three headings on the card with its bucket.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-23 20:48:47 +02:00
co-authored by Claude Fable 5.1
parent bf60879de9
commit f5f5132056
2 changed files with 80 additions and 5 deletions
+6 -3
View File
@@ -45,9 +45,12 @@ default.
`--words-from <repo>` lifts the words a repo already has back into the `--words-from <repo>` lifts the words a repo already has back into the
words map by structure, so a rebuild keeps hand-edited wording and words map by structure, so a rebuild keeps hand-edited wording and
adds only the new slots. The lift is by position, so it holds for a adds only the new slots. Cards are matched by what they are (the
rebuild of the same shape and not across a restructure: on uhhm.no it bucket they record into, the page they lead to, a field's name, the
put the old front door's three headings on three new cards. `--words group they invite into), not by position, so a rebuild that reorders
or reshapes a page still hands each card its own words; on uhhm.no
the live front door's three headings follow their buckets onto the
new page. `--words
<json>` overrides slots by page and may repeat, each file layered over <json>` overrides slots by page and may repeat, each file layered over
the last (the words a build used, then a round of hand changes); the last (the words a build used, then a round of hand changes);
`words.json` in the output is the words as used. See `words.json` in the output is the words as used. See
+74 -2
View File
@@ -1693,6 +1693,39 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result<BTreeMap<String, V
Ok(fallback.iter().map(|(path, v)| (path.clone(), untokenize(v, path, "", &found))).collect()) Ok(fallback.iter().map(|(path, v)| (path.clone(), untokenize(v, path, "", &found))).collect())
} }
/// What identifies an item of a page regardless of its position: the
/// structural strings portal itself keys on. Word slots (tokens on
/// the rendered side) are never an identity.
fn identity(v: &serde_yaml::Value) -> Vec<(&'static str, String)> {
const KEYS: &[&[&str]] = &[
&["record_as"],
&["action"],
&["name"],
&["invite", "group"],
&["resource", "source", "bucket"],
&["self_transition", "bucket"],
];
let mut id = Vec::new();
for path in KEYS {
let mut at = v;
for key in path.iter() {
match at.get(*key) {
Some(next) => at = next,
None => {
at = &serde_yaml::Value::Null;
break;
}
}
}
if let Some(s) = at.as_str() {
if !s.starts_with('\u{1}') && !s.is_empty() {
id.push((path[0], s.to_string()));
}
}
}
id
}
fn token(path: &str, pointer: &str) -> String { fn token(path: &str, pointer: &str) -> String {
format!("\u{1}{path}\u{2}{pointer}\u{3}") format!("\u{1}{path}\u{2}{pointer}\u{3}")
} }
@@ -1725,9 +1758,27 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::
} }
} }
} }
// Items are matched by what they are, not where they sit: the
// bucket an alternative records into, the page it leads to, a
// field's name, the group it invites into. A rebuild that
// reorders or reshapes a page still hands each card its own
// words; on uhhm.no a positional match once put the old front
// door's three headings on three unrelated new cards. Items
// with no identity (an information feature) fall back to
// position.
(serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) => { (serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) => {
for (va, vb) in x.iter().zip(y) { let mut used = vec![false; y.len()];
collect_words(va, vb, found); for (i, va) in x.iter().enumerate() {
let ida = identity(va);
let hit = if ida.is_empty() {
(i < y.len() && !used[i] && identity(&y[i]).is_empty()).then_some(i)
} else {
(0..y.len()).find(|&j| !used[j] && identity(&y[j]).iter().any(|k| ida.contains(k)))
};
if let Some(j) = hit {
used[j] = true;
collect_words(va, &y[j], found);
}
} }
} }
(serde_yaml::Value::String(t), serde_yaml::Value::String(live)) if t.starts_with('\u{1}') => { (serde_yaml::Value::String(t), serde_yaml::Value::String(live)) if t.starts_with('\u{1}') => {
@@ -1786,4 +1837,25 @@ mod invite_tests {
assert!(lifted2["questions/trafikk/index.yaml"]["invites"]["styret"]["heading"].as_str().unwrap().starts_with("Invite someone")); assert!(lifted2["questions/trafikk/index.yaml"]["invites"]["styret"]["heading"].as_str().unwrap().starts_with("Invite someone"));
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn words_follow_their_card_when_the_live_page_is_ordered_differently() {
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
let mut words = fallback_words(&plan);
words.get_mut("questions/index.yaml").unwrap()["needs"]["melde"]["heading"] = json!("Meld fra om en fare");
let dir = std::env::temp_dir().join(format!("lift-order-{}", std::process::id()));
write_repo(&dir, render(&plan, &words).unwrap()).unwrap();
// The live front door has an extra first card the plan knows
// nothing about, so the hazard form is no longer first.
let front = dir.join("questions/index.yaml");
let mut page: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(&front).unwrap()).unwrap();
let alternatives = page["alternatives"].as_sequence_mut().unwrap();
let stranger: serde_yaml::Value = serde_yaml::from_str("name: About us\ndescription: Founded in 1947.\nrecord_as: newsletter\nfeatures: []\n").unwrap();
alternatives.insert(0, stranger);
std::fs::write(&front, serde_yaml::to_string(&page).unwrap()).unwrap();
let lifted = lift_words(&plan, &dir).unwrap();
assert_eq!(lifted["questions/index.yaml"]["needs"]["melde"]["heading"], "Meld fra om en fare");
let _ = std::fs::remove_dir_all(&dir);
}
} }