joulespec: a bullet whose label could not be read was corrupting the obligation above it #568

Merged
dcharlot merged 1 commit from joulespec/applied into main 2026-11-02 03:37:24 -05:00
3 changed files with 183 additions and 16 deletions

View file

@ -303,6 +303,24 @@ shape = "heading-label" # or bulleted-bold-label; omitted = numbered
expect_at_least = 14 # the floor still fails closed
```
Run against this family's other specifications — documents written without
this producer in mind — the bulleted shape reads their conformance lists and
the heading shape reads a normative companion kept as one section per
obligation. What does not read is reported rather than returned empty: a
conformance section written as a table of profiles, or as numbered prose with
no labels, says it holds no items of the declared shape. `joulespec clauses`
exists for exactly this, because the alternative is discovering a mis-declared
shape through an item count, which says nothing about which items were missed.
Doing that found two things. In the producer: a bullet carrying a bold label
the splitter could not read was being folded into the item above, which
appended a *different* item's words to that obligation's addressed text —
a soundness bug, since the obligation then says something nobody wrote. It now
fails closed, and a bare label like `**C-neg:**` reads as the item it is. In a
sibling standard: its conformance section enumerates a clause its own
normative companion never defines, so that obligation could never have been
evidenced by anything.
Nothing else about the extraction varies, and a heading that states no
requirement is not read as one — addressing the empty string would create an
obligation nobody wrote and then grade it. Changing the declared shape moves

View file

@ -196,6 +196,41 @@ fn cmd_verify(args: &Args) -> Result<i32, String> {
Ok(0)
}
/// Show what the extractor reads out of a specification document, and the
/// obligation address each item would carry.
///
/// Authoring a manifest starts by agreeing with the producer about what the
/// clauses *are*. Without this an operator discovers a mis-declared shape
/// through a clause count, which says nothing about which items were missed.
fn cmd_clauses(args: &Args) -> Result<i32, String> {
let path = args.need("document")?;
let section = args.need("section")?;
let shape = match args.get("shape") {
Some(name) => specdoc::Shape::parse(name).map_err(|e| e.to_string())?,
None => specdoc::Shape::default(),
};
let floor: usize = match args.get("expect-at-least") {
Some(v) => v.parse().map_err(|_| format!("--expect-at-least {v:?} is not a number"))?,
None => 1,
};
let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
let items = specdoc::items(&text, section, floor, shape).map_err(|e| e.to_string())?;
let clauses = specdoc::clauses(&items);
println!("document {path}");
println!("section {section}");
println!("shape {}", shape.label());
println!("items {}", items.len());
println!();
for (item, clause) in items.iter().zip(&clauses) {
let obligation = clause.obligation().map_err(|e| e.to_string())?;
println!("{:<6} {}", item.label, obligation.as_str());
println!(" {} — {}", item.name, item.text);
println!();
}
Ok(0)
}
fn cmd_drift(args: &Args) -> Result<i32, String> {
let read = |p: &str| -> Result<SignedSpec, String> {
let text = std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?;
@ -243,6 +278,9 @@ joulespec — the reference producer and checker for joulespec/1
[--author-seed N] [--witness-seed N] [--steward-seed N]
joulespec verify --receipt <path> --steward <id> --key <64 hex chars>
joulespec drift --from <spec.json> --to <spec.json>
joulespec clauses --document <path> --section <heading>
[--shape numbered-bold-label|bulleted-bold-label|heading-label]
[--expect-at-least N]
harvest exits 2 when it has a finding (a check that did not run, or one that
passed while its subject was broken) and 3 when an owed obligation falls below
@ -257,6 +295,7 @@ fn main() {
"harvest" => cmd_harvest(&args),
"verify" => cmd_verify(&args),
"drift" => cmd_drift(&args),
"clauses" => cmd_clauses(&args),
"" | "help" | "--help" | "-h" => {
print!("{USAGE}");
Ok(0)

View file

@ -59,6 +59,22 @@ pub enum SpecDocError {
/// The shape that matched nothing.
shape: &'static str,
},
/// A line opens something shaped like an item but cannot be read as one.
///
/// Fails closed rather than guessing. The alternative — folding it into
/// the item above — changes that item's addressed text without saying so.
#[error(
"in section {section:?} under shape {shape:?}, this looks like an item \
but cannot be read as one: {line:?}"
)]
UnreadableItem {
/// The section searched.
section: String,
/// The declared shape.
shape: &'static str,
/// The offending line, truncated.
line: String,
},
/// The declared shape is not one this producer implements.
#[error("unknown clause shape {got:?}; expected one of {known}")]
UnknownShape {
@ -165,17 +181,41 @@ fn is_section_heading(line: &str, section: &str) -> bool {
/// Split `**C1 — Addressing.** rest…` into its three parts.
///
/// The em dash is required: it is what distinguishes a labelled conformance
/// item from an ordinary bolded sentence in a numbered list.
/// The bold segment holds a label and, usually, a short name. Three
/// separators appear in practice and all three are accepted, because which
/// one a document uses is punctuation rather than meaning. A bold segment
/// carrying only a label — `**C-neg:** …` — is also an item: the name is
/// then empty and the obligation is addressed over the body alone.
///
/// Returns `None` only when the segment is not bold at all, or when the label
/// itself is empty. Those are the cases where there is nothing to name.
fn split_item(body: &str) -> Option<(String, String, String)> {
let rest = body.strip_prefix("**")?;
let (head, tail) = rest.split_once("**")?;
let (label, name) = head.split_once(" — ")?;
let name = name.trim().trim_end_matches('.');
if label.trim().is_empty() || name.is_empty() {
let (label, name) = match head
.split_once(" — ")
.or_else(|| head.split_once(": "))
.or_else(|| head.split_once(" - "))
{
Some((l, n)) => (l, n.trim().trim_end_matches(['.', ':']).to_string()),
// A bold segment with no separator is a bare label — but only if it
// looks like one. `**C-neg:**` is an item; `**Bolded sentence.**` is
// a lead-in to an ordinary list and naming it would invent an
// obligation. A label carries no whitespace, which is also what the
// core refuses when it validates one, so this is the same rule read
// one step earlier.
None => {
let bare = head.trim_end_matches([':', '.', ' ']);
if bare.contains(char::is_whitespace) {
return None;
}
(bare, String::new())
}
};
if label.trim().is_empty() {
return None;
}
Some((label.trim().to_string(), name.to_string(), tail.trim().to_string()))
Some((label.trim().to_string(), name, tail.trim().to_string()))
}
/// Split a heading's text into `label`, `name` on either an em dash or a
@ -200,6 +240,9 @@ enum Line<'a> {
Opens(String, String, String),
/// This line continues the open item, or closes it if blank.
Body(&'a str),
/// This line opens something shaped like an item that cannot be read as
/// one. Never treated as body — see [`classify`].
Unreadable,
}
/// Classify one line. The shape decides only what *opens* an item and what
@ -226,11 +269,21 @@ fn classify<'a>(line: &'a str, shape: Shape, section_depth: usize) -> Line<'a> {
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* ")),
};
match body.and_then(split_item) {
Some((label, name, text)) => Line::Opens(label, name, text),
// A list item that does not carry a label is body: it may be
// a sub-list under the open item.
None => Line::Body(line),
match body {
// A list item carrying a bold segment is an item. If it
// cannot be read as one, it is **not** quietly folded into
// the item above: doing that appends a different item's words
// to an obligation's addressed text, which silently changes
// what that obligation says. Observed on a sibling
// specification, where a `**C-neg:**` bullet ended up inside
// C5's address.
Some(b) if b.trim_start().starts_with("**") => match split_item(b) {
Some((label, name, text)) => Line::Opens(label, name, text),
None => Line::Unreadable,
},
// A list item with no bold segment is body: a genuine
// sub-point under the open item.
_ => Line::Body(line),
}
}
// Here the items *are* headings, so the section ends only at a
@ -287,6 +340,19 @@ pub fn items(
for line in lines {
match classify(line, shape, section_depth) {
Line::SectionEnd => break,
// Only an *open* item can be corrupted by folding a line into
// it, so that is the only case this refuses. With nothing open
// there is nothing to append to, and a section whose bolded
// lines are ordinary prose should report that it holds no items
// of this shape rather than name one of them as malformed.
Line::Unreadable if pending.is_some() => {
return Err(SpecDocError::UnreadableItem {
section: section.to_string(),
shape: shape.label(),
line: line.trim().chars().take(72).collect(),
});
}
Line::Unreadable => {}
Line::Opens(label, name, text) => {
if let Some(item) = pending.take() {
out.push(item);
@ -352,11 +418,15 @@ pub fn clauses(items: &[Item]) -> Vec<Clause> {
items
.iter()
.map(|i| {
Clause::new(
i.label.clone(),
ClauseKind::Requirement,
format!("{} — {}", i.name, i.text),
)
// An item with no name is addressed over its body alone. Joining
// an empty name with a separator would put punctuation nobody
// wrote into the obligation's text.
let text = if i.name.is_empty() {
i.text.clone()
} else {
format!("{} — {}", i.name, i.text)
};
Clause::new(i.label.clone(), ClauseKind::Requirement, text)
})
.collect()
}
@ -453,6 +523,46 @@ The reference implementation is conformant by construction.
assert!(matches!(err, SpecDocError::TooFew { found: 3, expected: 11, .. }), "{err}");
}
/// Observed on a sibling specification: `- **C-neg:** text` carries a
/// label and no name. Before this was handled, the line failed to parse
/// and was folded into the item above, appending a different item's words
/// to that obligation's addressed text.
#[test]
fn a_bare_label_is_an_item_and_does_not_leak_into_the_item_above() {
let doc = concat!(
"## 7. Conformance\n\n",
"- **C1 — Named.** The first item.\n",
"- **C-neg:** The negative pack.\n",
);
let got = items(doc, "7. Conformance", 2, Shape::BulletedBold).expect("parses");
assert_eq!(got.len(), 2, "{got:#?}");
assert_eq!(got[1].label, "C-neg");
assert!(got[1].name.is_empty(), "a bare label has no name");
assert_eq!(
got[0].text, "The first item.",
"the next item's words must not appear in this obligation"
);
// With no name, the obligation is addressed over the body alone
// rather than over a separator nobody wrote.
let c = clauses(&got);
assert_eq!(c[1].text, "The negative pack.");
}
/// A line that looks like an item and cannot be read as one fails the
/// parse rather than joining the item above, for the same reason.
#[test]
fn an_item_shaped_line_that_cannot_be_read_fails_closed() {
let doc = concat!(
"## 7. Conformance\n\n",
"- **C1 — Named.** The first item.\n",
"- **** empty label.\n",
);
assert!(matches!(
items(doc, "7. Conformance", 1, Shape::BulletedBold),
Err(SpecDocError::UnreadableItem { .. })
));
}
#[test]
fn an_unlabelled_numbered_line_is_not_an_item() {
let doc = "## 7. Conformance\n\n1. **Bolded but unlabelled.** No em dash.\n";