feat(bookspec): the specification text and an example for the ledger's own laws (P13 step 4)
bookspec/spec holds the first edition: the model and the five laws (00-model.md) and one file per area, each B-rule a section with its status, a MUST/MAY statement, sources pinned either to a vendored source with a verbatim quotation or to an exact external paragraph, its symbolic form, its examples and its applicability. B-39, B-40, B-43 and B-45 are reserved sections (allocated, not yet stated in rules.md); B-44 is stated in rules.md and now carries its bookspec id and CONVENTION status. Eight new examples show the laws that had none, each figure worked by hand: an unbalanced entry is refused (Pacioli ch. 36, Pub. 583), a negative or zero line is refused, a fraction of a cent is refused (ISO 4217), a child account takes its parent's type, the trial balance and the accounting equation on one small book (cash 6550.25 = loan 2000 + capital 5000 + (sales 750.50 - rent 1200.25)), a reversal leaves the original standing and a second reversal is refused, a close sweeps revenue and expenses into Retained Earnings (3999) and the closed quarter takes no late entry, and cents add exactly. The refusal vocabulary gains unbalanced, negative_amount, zero_amount, too_few_lines, finer_than_minor_unit, parent_type_mismatch, already_reversed and period_closed; a refused entry may carry a non-positive amount. The checker's spec.rs fails the build on a rule without its section, a statement without MUST/SHOULD/MAY, a pin that is not verbatim in its vendored source, examples that differ from the catalogue, or more than 60 pages. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
f034b0d45b
commit
790ec8c0e6
24 files changed
+1576
-35
No files matched your search
+26
-16
@@ -156,6 +156,12 @@ pub fn rule_for_id(id: &RuleId) -> Option<&'static Rule> {
|
||||
/// (bookspec/README.md).
|
||||
pub fn rule_for_refusal(reason: &str) -> Option<&'static Rule> {
|
||||
let b = match reason {
|
||||
"unbalanced" | "too_few_lines" => 1,
|
||||
"negative_amount" | "zero_amount" => 2,
|
||||
"finer_than_minor_unit" => 3,
|
||||
"parent_type_mismatch" => 4,
|
||||
"already_reversed" => 10,
|
||||
"period_closed" => 12,
|
||||
"end_before_start" | "start_after_today" => 30,
|
||||
"blank" | "unchanged" | "sibling_has_it" | "holds_a_tin" | "not_one_line"
|
||||
| "code_taken" => 31,
|
||||
@@ -211,84 +217,87 @@ const SPEC: Spec = &[
|
||||
Area::Entry,
|
||||
"Every entry is a double entry: debits equal credits",
|
||||
Some(Status::Authority),
|
||||
&[],
|
||||
&[
|
||||
"entry-an-unbalanced-entry-is-refused",
|
||||
"statement-the-books-balance-and-the-equation-holds",
|
||||
],
|
||||
),
|
||||
(
|
||||
2,
|
||||
Area::Entry,
|
||||
"A line's amount is positive; its side gives the direction",
|
||||
Some(Status::Convention),
|
||||
&[],
|
||||
&["entry-a-line-amount-is-positive"],
|
||||
),
|
||||
(
|
||||
3,
|
||||
Area::Ledger,
|
||||
"One money through the ledger; foreign amounts at the transaction-date rate",
|
||||
Some(Status::Authority),
|
||||
&[],
|
||||
&["ledger-a-fraction-of-a-cent-is-refused"],
|
||||
),
|
||||
(
|
||||
4,
|
||||
Area::Ledger,
|
||||
"Five elements, closed list",
|
||||
Some(Status::Authority),
|
||||
&[],
|
||||
&["ledger-a-child-account-has-its-parents-type"],
|
||||
),
|
||||
(
|
||||
5,
|
||||
Area::Ledger,
|
||||
"Normal balances",
|
||||
Some(Status::Convention),
|
||||
&[],
|
||||
&["statement-the-books-balance-and-the-equation-holds"],
|
||||
),
|
||||
(
|
||||
6,
|
||||
Area::Statement,
|
||||
"The trial balance balances",
|
||||
Some(Status::Authority),
|
||||
&[],
|
||||
&["statement-the-books-balance-and-the-equation-holds"],
|
||||
),
|
||||
(
|
||||
7,
|
||||
Area::Statement,
|
||||
"The accounting equation",
|
||||
Some(Status::Authority),
|
||||
&[],
|
||||
&["statement-the-books-balance-and-the-equation-holds"],
|
||||
),
|
||||
(
|
||||
8,
|
||||
Area::Ledger,
|
||||
"Posted is forever: the book is written only forward",
|
||||
Some(Status::Authority),
|
||||
&[],
|
||||
&["ledger-a-mistake-is-reversed-never-erased"],
|
||||
),
|
||||
(
|
||||
9,
|
||||
Area::Ledger,
|
||||
"A mistake is corrected by an opposite entry, never erased",
|
||||
Some(Status::Authority),
|
||||
&[],
|
||||
&["ledger-a-mistake-is-reversed-never-erased"],
|
||||
),
|
||||
(
|
||||
10,
|
||||
Area::Ledger,
|
||||
"An entry is reversed at most once; a closing entry never",
|
||||
Some(Status::Choice),
|
||||
&[],
|
||||
&["ledger-a-mistake-is-reversed-never-erased"],
|
||||
),
|
||||
(
|
||||
11,
|
||||
Area::Period,
|
||||
"Revenue and expenses close into profit and loss, and that into capital",
|
||||
Some(Status::Authority),
|
||||
&[],
|
||||
&["period-a-close-sweeps-the-year-into-capital"],
|
||||
),
|
||||
(
|
||||
12,
|
||||
Area::Period,
|
||||
"A closed period takes no new postings; periods close in order",
|
||||
Some(Status::Convention),
|
||||
&[],
|
||||
&["period-a-close-sweeps-the-year-into-capital"],
|
||||
),
|
||||
(
|
||||
13,
|
||||
@@ -316,6 +325,7 @@ const SPEC: Spec = &[
|
||||
"cash-flow.reversal-of-a-reversal",
|
||||
"cash-flow.reversed-property-contribution",
|
||||
"cash-flow.two-member-llc-first-year",
|
||||
"period-a-close-sweeps-the-year-into-capital",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -392,7 +402,7 @@ const SPEC: Spec = &[
|
||||
Area::Entry,
|
||||
"Exact amounts in the books; rounding only on returns",
|
||||
Some(Status::Authority),
|
||||
&[],
|
||||
&["entry-cents-add-exactly"],
|
||||
),
|
||||
(
|
||||
22,
|
||||
@@ -406,7 +416,7 @@ const SPEC: Spec = &[
|
||||
Area::Ledger,
|
||||
"Control roles: one account each, of the right kind, moved only when empty",
|
||||
Some(Status::Convention),
|
||||
&[],
|
||||
&["arap-hand-control-posting-refused"],
|
||||
),
|
||||
(
|
||||
24,
|
||||
@@ -570,8 +580,8 @@ const SPEC: Spec = &[
|
||||
(
|
||||
44,
|
||||
Area::Ledger,
|
||||
"Extraction and dedup (allocated, not yet stated)",
|
||||
None,
|
||||
"Evidence is read from the bytes, never trusted over them; identical files are kept once",
|
||||
Some(Status::Convention),
|
||||
&[],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -1193,7 +1193,9 @@ impl Example {
|
||||
if !matches!(line.side.as_str(), "debit" | "credit") {
|
||||
return Err(what(format!("side {:?}", line.side)));
|
||||
}
|
||||
check_amount(&line.amount, true, &what)?;
|
||||
// A refused entry may carry the amount the books refuse (zero or
|
||||
// negative, B-2); an accepted one carries a positive amount.
|
||||
check_amount(&line.amount, ev.refused.is_none(), &what)?;
|
||||
if let Some(p) = &line.project
|
||||
&& !projects.contains(p.as_str())
|
||||
{
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! The specification text (`bookspec/spec/*.md`, P13 step 4): every rule
|
||||
//! has its section, its statement, its pinned sources, its symbolic form
|
||||
//! and its examples, and every citation resolves.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bookspec_checker::{Rule, rules};
|
||||
|
||||
fn spec_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../spec")
|
||||
}
|
||||
|
||||
fn sources_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/sources")
|
||||
}
|
||||
|
||||
/// A rule's section as the text gives it.
|
||||
#[derive(Default, Debug)]
|
||||
struct Section {
|
||||
file: String,
|
||||
heading: String,
|
||||
fields: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// Every `### BS-...` section of the area files, by its identifier.
|
||||
fn sections() -> BTreeMap<String, Section> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(spec_dir())
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "md"))
|
||||
.collect();
|
||||
files.sort();
|
||||
for f in files {
|
||||
let name = f.file_name().unwrap().to_string_lossy().into_owned();
|
||||
let text = std::fs::read_to_string(&f).unwrap();
|
||||
let mut current: Option<(String, Section)> = None;
|
||||
let mut field = String::new();
|
||||
for line in text.lines() {
|
||||
if let Some(h) = line.strip_prefix("### BS-") {
|
||||
if let Some((id, s)) = current.take() {
|
||||
assert!(out.insert(id.clone(), s).is_none(), "{id} twice");
|
||||
}
|
||||
let id = format!("BS-{}", h.split(':').next().unwrap());
|
||||
current = Some((
|
||||
id,
|
||||
Section {
|
||||
file: name.clone(),
|
||||
heading: h.to_string(),
|
||||
..Section::default()
|
||||
},
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let Some((_, s)) = current.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(rest) = line.strip_prefix("- **") {
|
||||
let (key, value) = rest.split_once(":**").expect("a field: - **Name:** ...");
|
||||
field = key.to_string();
|
||||
let e = s.fields.entry(field.clone()).or_default();
|
||||
if !value.trim().is_empty() {
|
||||
e.push(value.trim().to_string());
|
||||
}
|
||||
} else if let Some(item) = line.strip_prefix(" - ") {
|
||||
s.fields
|
||||
.entry(field.clone())
|
||||
.or_default()
|
||||
.push(item.to_string());
|
||||
}
|
||||
}
|
||||
if let Some((id, s)) = current.take() {
|
||||
assert!(out.insert(id.clone(), s).is_none(), "{id} twice");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn one<'a>(s: &'a Section, id: &str, field: &str) -> &'a str {
|
||||
let v = s
|
||||
.fields
|
||||
.get(field)
|
||||
.unwrap_or_else(|| panic!("{id}: no {field}"));
|
||||
assert_eq!(v.len(), 1, "{id}: {field} is one line");
|
||||
&v[0]
|
||||
}
|
||||
|
||||
/// Whitespace and typographic quotes made plain, so a pin copied from a
|
||||
/// wrapped or curly-quoted source still matches.
|
||||
fn squash(s: &str) -> String {
|
||||
let plain = s
|
||||
.replace(['\u{2019}', '\u{2018}'], "'")
|
||||
.replace(['\u{201c}', '\u{201d}'], "\"");
|
||||
plain.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn backticked(s: &str) -> Vec<&str> {
|
||||
s.split('`').skip(1).step_by(2).collect()
|
||||
}
|
||||
|
||||
/// Every one of the 45 rules has one section, in the file of its area,
|
||||
/// headed with its identifier and B-number.
|
||||
#[test]
|
||||
fn every_rule_has_its_section() {
|
||||
let sections = sections();
|
||||
assert_eq!(sections.len(), 45, "one section per rule");
|
||||
for rule in rules() {
|
||||
let s = sections
|
||||
.get(rule.id.as_str())
|
||||
.unwrap_or_else(|| panic!("{}: no section", rule.id));
|
||||
assert_eq!(s.file, format!("{}.md", rule.area.key()), "{}", rule.id);
|
||||
assert!(
|
||||
s.heading.ends_with(&format!("(B-{})", rule.b)),
|
||||
"{}: heading {:?} names B-{}",
|
||||
rule.id,
|
||||
s.heading,
|
||||
rule.b
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A stated rule has a status the catalogue agrees with, a normative
|
||||
/// statement, a symbolic form and applicability; a reserved one says so.
|
||||
#[test]
|
||||
fn every_stated_rule_is_complete() {
|
||||
let sections = sections();
|
||||
for rule in rules() {
|
||||
let s = §ions[rule.id.as_str()];
|
||||
let id = rule.id.as_str();
|
||||
let status = one(s, id, "Status");
|
||||
match rule.status {
|
||||
Some(st) => {
|
||||
assert!(status.starts_with(st.key()), "{id}: status {status:?}");
|
||||
let statement = one(s, id, "Statement");
|
||||
assert!(
|
||||
["MUST", "SHOULD", "MAY"]
|
||||
.iter()
|
||||
.any(|w| statement.contains(w)),
|
||||
"{id}: a statement says MUST, SHOULD or MAY"
|
||||
);
|
||||
for field in ["Symbolic form", "Applicability"] {
|
||||
assert!(
|
||||
one(s, id, field).len() > 10 && !one(s, id, field).starts_with("none"),
|
||||
"{id}: {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
None => assert!(status.starts_with("reserved"), "{id}: reserved"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A section's examples are the catalogue's, exactly; "none yet" says why.
|
||||
#[test]
|
||||
fn the_examples_are_the_catalogues() {
|
||||
let sections = sections();
|
||||
for rule in rules() {
|
||||
let s = §ions[rule.id.as_str()];
|
||||
let id = rule.id.as_str();
|
||||
let line = one(s, id, "Examples");
|
||||
let listed: BTreeSet<&str> = backticked(line)
|
||||
.into_iter()
|
||||
.filter(|b| *b != "none yet")
|
||||
.collect();
|
||||
let want: BTreeSet<&str> = rule.examples.iter().copied().collect();
|
||||
if rule.examples.is_empty() {
|
||||
assert!(line.starts_with("none yet"), "{id}: {line}");
|
||||
if rule.status.is_some() {
|
||||
assert!(
|
||||
line.len() > "none yet. ".len() + 20,
|
||||
"{id}: a stated rule with no example says why"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert_eq!(listed, want, "{id}: examples");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every citation resolves: a vendored source exists, and a verbatim
|
||||
/// pin appears in it; an external reference names its paragraph.
|
||||
#[test]
|
||||
fn every_citation_resolves() {
|
||||
let sections = sections();
|
||||
let mut vendored = 0;
|
||||
let mut external = 0;
|
||||
let mut bad: Vec<String> = Vec::new();
|
||||
for rule in rules() {
|
||||
let s = §ions[rule.id.as_str()];
|
||||
let id = rule.id.as_str();
|
||||
let sources = s
|
||||
.fields
|
||||
.get("Sources")
|
||||
.unwrap_or_else(|| panic!("{id}: no Sources"));
|
||||
if rule.status.is_none() {
|
||||
assert_eq!(sources, &vec!["none yet".to_string()], "{id}");
|
||||
continue;
|
||||
}
|
||||
assert!(!sources.is_empty(), "{id}: sourced");
|
||||
for item in sources {
|
||||
if let Some(rest) = item.strip_prefix("vendored `") {
|
||||
let (file, rest) = rest
|
||||
.split_once("`:")
|
||||
.unwrap_or_else(|| panic!("{id}: {item}"));
|
||||
let path = sources_dir().join(file.strip_prefix("sources/").unwrap_or(file));
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|_| panic!("{id}: {file} is not vendored"));
|
||||
let hay = squash(&text);
|
||||
// Every quoted pin is verbatim in the file.
|
||||
let quoted: Vec<&str> = rest.split('"').skip(1).step_by(2).collect();
|
||||
for q in "ed {
|
||||
if !hay.contains(&squash(q)) {
|
||||
bad.push(format!("{id}: {q:?} is not in {file}"));
|
||||
}
|
||||
}
|
||||
if quoted.is_empty() {
|
||||
bad.push(format!("{id}: {item} has no verbatim pin"));
|
||||
}
|
||||
vendored += 1;
|
||||
} else if let Some(rest) = item.strip_prefix("external:") {
|
||||
assert!(
|
||||
rest.trim().len() > 20,
|
||||
"{id}: an external reference is exact: {item}"
|
||||
);
|
||||
external += 1;
|
||||
} else {
|
||||
panic!("{id}: a source is `vendored` or `external`: {item}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "pins not found:\n{}", bad.join("\n"));
|
||||
assert!(vendored >= 30, "vendored citations: {vendored}");
|
||||
assert!(external >= 15, "external citations: {external}");
|
||||
}
|
||||
|
||||
/// The first edition's page budget: 60 pages of 55 lines.
|
||||
#[test]
|
||||
fn the_edition_fits_its_page_budget() {
|
||||
let mut lines = 0;
|
||||
for e in std::fs::read_dir(spec_dir()).unwrap() {
|
||||
let p = e.unwrap().path();
|
||||
if p.extension().is_some_and(|x| x == "md") {
|
||||
lines += std::fs::read_to_string(p).unwrap().lines().count();
|
||||
}
|
||||
}
|
||||
assert!(lines <= 60 * 55, "{lines} lines is over 60 pages");
|
||||
}
|
||||
|
||||
/// A stated rule with no example is exactly one an engine cannot claim.
|
||||
#[test]
|
||||
fn rules_without_examples_are_not_claimable() {
|
||||
let uncovered: Vec<&Rule> = rules().iter().filter(|r| !r.has_examples()).collect();
|
||||
let claimable: BTreeSet<_> = bookspec_checker::covered_rules()
|
||||
.into_iter()
|
||||
.map(|r| r.b)
|
||||
.collect();
|
||||
for r in uncovered {
|
||||
assert!(!claimable.contains(&r.b), "B-{}", r.b);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user