Skip to main content

max / makenotwork

13.5 KB · 379 lines History Blame Raw
1 //! CI guard for business assumptions.
2 //!
3 //! Catches at PR time what would otherwise only fail at prod boot:
4 //! - `docs/business/assumptions.toml` parses
5 //! - All consistency rules pass (sums, bounds, founding ≤ standard)
6 //! - Every `{{ ... }}` marker in the live site-docs corpus resolves
7 //! - No *bare* copy of a linted assumption value survives in the corpus
8 //! - The `[synckit]` mirror still matches the authoritative Rust constants
9 //!
10 //! Run with: cargo test --test assumptions
11
12 use makenotwork::synckit_billing;
13 use mnw_assumptions::{Assumptions, code_span_ranges};
14
15 // Canonical assumptions.toml ships with the repo at server/docs/business/.
16 // Cargo runs tests with cwd = crate manifest dir (MNW/server/), so the
17 // relative path is just docs/..., no traversal.
18 const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml";
19 const SITE_DOCS_PATH: &str = "site-docs/public";
20
21 #[test]
22 fn real_assumptions_file_parses_and_validates() {
23 let a = Assumptions::load(ASSUMPTIONS_PATH)
24 .unwrap_or_else(|e| panic!("failed to load {ASSUMPTIONS_PATH}: {e}"));
25 a.validate()
26 .unwrap_or_else(|e| panic!("assumptions failed validation:\n{e}"));
27 }
28
29 #[test]
30 fn every_marker_in_site_docs_resolves() {
31 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load");
32
33 let mut failures = Vec::new();
34 visit_markdown(std::path::Path::new(SITE_DOCS_PATH), &mut |path, body| {
35 if !body.contains("{{") {
36 return;
37 }
38 if let Err(e) = a.substitute(body) {
39 failures.push(format!(" {}: {e}", path.display()));
40 }
41 });
42
43 assert!(
44 failures.is_empty(),
45 "{} doc(s) contain unresolved {{{{ … }}}} markers:\n{}",
46 failures.len(),
47 failures.join("\n")
48 );
49 }
50
51 /// `[synckit]` in assumptions.toml mirrors constants that live in Rust, so the
52 /// public developer docs can substitute them. Rust is authoritative; this test
53 /// is the drift guard, the same shape as the `tier_limits` ↔ `tier_bytes` rule
54 /// inside `Assumptions::validate`.
55 #[test]
56 fn synckit_block_mirrors_billing_constants() {
57 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load");
58 let num = |key: &str| {
59 a.get(key)
60 .unwrap_or_else(|| panic!("missing key {key}"))
61 .as_f64()
62 .unwrap_or_else(|| panic!("{key} is not numeric"))
63 };
64
65 // Both sides are money, so compare in whole cents rather than comparing
66 // f64s for equality.
67 let cents = |key: &str| (num(key) * 100.0).round() as i64;
68
69 assert_eq!(
70 cents("synckit.storage_usd_per_gb_month"),
71 synckit_billing::STORAGE_RATE_CENTS_PER_GB,
72 "synckit.storage_usd_per_gb_month != STORAGE_RATE_CENTS_PER_GB"
73 );
74 assert_eq!(
75 cents("synckit.invoice_floor_usd"),
76 synckit_billing::BASE_FLOOR_CENTS,
77 "synckit.invoice_floor_usd != BASE_FLOOR_CENTS"
78 );
79 assert_eq!(
80 num("synckit.max_priced_gb").round() as i64,
81 synckit_billing::MAX_STORAGE_GB,
82 "synckit.max_priced_gb != MAX_STORAGE_GB"
83 );
84 }
85
86 // --- bare-value lint
87
88 /// How a linted key renders when a writer types it by hand. Each variant maps
89 /// to the marker + filter a doc would use, so the searched-for literal is
90 /// exactly what substitution would have produced.
91 #[derive(Copy, Clone)]
92 enum Form {
93 /// `{{ k | money }}`: `$0.30`.
94 Money,
95 /// `{{ k | percent }}`: `2.9%`.
96 Percent,
97 /// `${{ k }}`: `$580`, plus the comma-grouped hand-written form.
98 Dollars,
99 /// `{{ k }}`: the value verbatim (already a display string).
100 Verbatim,
101 }
102
103 /// Assumption values distinctive enough that a bare occurrence in the corpus
104 /// is a stale-copy bug rather than a coincidence.
105 ///
106 /// Deliberately NOT linted: the tier prices and Fan+ prices (small dollar
107 /// amounts like `$8` collide with illustrative sale prices all over the guide),
108 /// the tier envelope sizes (`5 MB` is also the sandbox per-file cap, `500 MB`
109 /// the export cap, different limits that happen to share a number), and
110 /// `uptime.target_downtime_hours_year` (a bare `44` is noise). Those still
111 /// carry markers in the corpus; they just can't be policed by string search.
112 const LINTED: &[(&str, Form)] = &[
113 ("expenses.F_monthly", Form::Dollars),
114 ("stripe.percent", Form::Percent),
115 ("stripe.fixed", Form::Money),
116 ("stripe.dispute_fee", Form::Money),
117 ("uptime.target_pct", Form::Percent),
118 ("derived.R_cap", Form::Dollars),
119 ("cohort.cap_display", Form::Verbatim),
120 ("broadcasts.recipients_per_send_display", Form::Verbatim),
121 ("synckit.storage_usd_per_gb_month", Form::Money),
122 ("synckit.invoice_floor_usd", Form::Money),
123 ("derived.annual_founding_basic", Form::Dollars),
124 ("derived.annual_founding_small_files", Form::Dollars),
125 ("derived.annual_founding_big_files", Form::Dollars),
126 ("derived.annual_founding_everything", Form::Dollars),
127 ("derived.annual_standard_basic", Form::Dollars),
128 ("derived.annual_standard_small_files", Form::Dollars),
129 ("derived.annual_standard_big_files", Form::Dollars),
130 ("derived.annual_standard_everything", Form::Dollars),
131 ];
132
133 /// Lines that legitimately carry a linted literal. Matched as
134 /// (path suffix, substring of the offending line, reason), a substring rather
135 /// than a line number so ordinary edits above don't invalidate the waiver.
136 const EXEMPT: &[(&str, &str, &str)] = &[
137 (
138 "guide/git.md",
139 "10,000 lines",
140 "commit-diff display cap, unrelated to the broadcast recipient cap",
141 ),
142 (
143 "guide/items.md",
144 "10,000 characters",
145 "changelog field length, unrelated to the broadcast recipient cap",
146 ),
147 (
148 "about/how-we-work.md",
149 "1,000 transactions",
150 "illustrative transaction count in a revenue example, not the cohort cap",
151 ),
152 (
153 "guide/stripe.md",
154 "| Canada |",
155 "Canadian Stripe rate (C$), coincidentally the same digits as the US rate; \
156 only the US row is ours to substitute",
157 ),
158 (
159 "guide/stripe.md",
160 "| Australia |",
161 "Australian Stripe rate (A$), not the US flat fee",
162 ),
163 ];
164
165 #[test]
166 fn no_bare_assumption_values_in_site_docs() {
167 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load");
168
169 // Render each linted key through the engine itself, so the literal we hunt
170 // for is byte-identical to what the marker would have emitted.
171 let mut needles: Vec<(&str, String)> = Vec::new();
172 for &(key, form) in LINTED {
173 assert!(a.get(key).is_some(), "linted key {key} is not in the table");
174 let render = |expr: &str| {
175 a.substitute(&format!("{{{{ {expr} }}}}"))
176 .unwrap_or_else(|e| panic!("cannot render {expr}: {e}"))
177 };
178 match form {
179 Form::Money => needles.push((key, render(&format!("{key} | money")))),
180 Form::Percent => needles.push((key, render(&format!("{key} | percent")))),
181 Form::Verbatim => needles.push((key, render(key))),
182 Form::Dollars => {
183 let plain = render(key);
184 needles.push((key, format!("${plain}")));
185 let grouped = group_thousands(&plain);
186 if grouped != plain {
187 needles.push((key, format!("${grouped}")));
188 }
189 }
190 }
191 }
192
193 let mut findings = Vec::new();
194 visit_markdown(std::path::Path::new(SITE_DOCS_PATH), &mut |path, body| {
195 let display = path.display().to_string();
196 let code = code_span_ranges(body);
197 let prose = strip_ranges(body, &code);
198 let prose = strip_markers(&prose);
199
200 for (lineno, line) in prose.lines().enumerate() {
201 for (key, needle) in &needles {
202 if !contains_bare(line, needle) {
203 continue;
204 }
205 if EXEMPT
206 .iter()
207 .any(|(file, snippet, _)| display.ends_with(file) && line.contains(snippet))
208 {
209 continue;
210 }
211 findings.push(format!(
212 " {display}:{}: bare {needle:?}. Use a {{{{ {key} }}}} marker\n {}",
213 lineno + 1,
214 line.trim()
215 ));
216 }
217 }
218 });
219
220 assert!(
221 findings.is_empty(),
222 "{} bare assumption value(s) in the corpus \
223 (substitute them, or add an EXEMPT entry with a reason):\n{}",
224 findings.len(),
225 findings.join("\n")
226 );
227 }
228
229 /// A waiver that no longer matches anything is a waiver that should be
230 /// deleted, not carried. Keeps `EXEMPT` from silently outliving its reason.
231 #[test]
232 fn every_exemption_still_matches_a_line() {
233 let mut stale = Vec::new();
234 for (file, snippet, reason) in EXEMPT {
235 let path = std::path::Path::new(SITE_DOCS_PATH).join(file);
236 let matched = std::fs::read_to_string(&path)
237 .is_ok_and(|body| body.lines().any(|line| line.contains(*snippet)));
238 if !matched {
239 stale.push(format!(
240 " {file}: {snippet:?} no longer present ({reason})"
241 ));
242 }
243 }
244 assert!(
245 stale.is_empty(),
246 "{} stale bare-value exemption(s), delete them:\n{}",
247 stale.len(),
248 stale.join("\n")
249 );
250 }
251
252 /// Blank out byte ranges (code spans, `{{ … }}` markers) with spaces, keeping
253 /// every other byte at its original offset so line numbers stay truthful.
254 fn strip_ranges(text: &str, ranges: &[(usize, usize)]) -> String {
255 let mut out: Vec<u8> = text.as_bytes().to_vec();
256 for &(start, end) in ranges {
257 for b in &mut out[start..end.min(text.len())] {
258 if *b != b'\n' {
259 *b = b' ';
260 }
261 }
262 }
263 String::from_utf8(out).expect("only ASCII bytes replaced")
264 }
265
266 /// Blank out `{{ … }}` markers: a value inside one is substituted, not bare.
267 fn strip_markers(text: &str) -> String {
268 let mut ranges = Vec::new();
269 let mut rest = text;
270 let mut base = 0;
271 while let Some(open) = rest.find("{{") {
272 let after = open + 2;
273 match rest[after..].find("}}") {
274 Some(rel) => {
275 let close = after + rel + 2;
276 ranges.push((base + open, base + close));
277 base += close;
278 rest = &rest[close..];
279 }
280 // Unclosed marker: nothing further to blank.
281 None => break,
282 }
283 }
284 strip_ranges(text, &ranges)
285 }
286
287 /// True when `needle` occurs in `line` as a whole number, not as a fragment of
288 /// a longer one. `$8` must not match inside `$8,410`, and `2.9%` must not match
289 /// inside `12.9%`.
290 fn contains_bare(line: &str, needle: &str) -> bool {
291 let bytes = line.as_bytes();
292 let mut from = 0;
293 while let Some(rel) = line[from..].find(needle) {
294 let start = from + rel;
295 let end = start + needle.len();
296 let before_ok = start == 0 || !matches!(bytes[start - 1], b'0'..=b'9' | b'.' | b',' | b'$');
297 let after_ok =
298 end == bytes.len() || !matches!(bytes[end], b'0'..=b'9' | b'.' | b',' | b'%');
299 if before_ok && after_ok {
300 return true;
301 }
302 from = end;
303 }
304 false
305 }
306
307 /// `61960` → `61,960`. Leaves anything with a decimal point alone.
308 fn group_thousands(digits: &str) -> String {
309 if !digits.bytes().all(|b| b.is_ascii_digit()) {
310 return digits.to_string();
311 }
312 let mut out = String::with_capacity(digits.len() + digits.len() / 3);
313 let n = digits.len();
314 for (i, c) in digits.chars().enumerate() {
315 if i > 0 && (n - i).is_multiple_of(3) {
316 out.push(',');
317 }
318 out.push(c);
319 }
320 out
321 }
322
323 fn visit_markdown(dir: &std::path::Path, f: &mut impl FnMut(&std::path::Path, &str)) {
324 let Ok(entries) = std::fs::read_dir(dir) else {
325 return;
326 };
327 for entry in entries.flatten() {
328 let path = entry.path();
329 if path.is_dir() {
330 visit_markdown(&path, f);
331 } else if path.extension().and_then(|s| s.to_str()) == Some("md")
332 && let Ok(body) = std::fs::read_to_string(&path)
333 {
334 f(&path, &body);
335 }
336 }
337 }
338
339 mod lint_unit {
340 use super::*;
341
342 #[test]
343 fn contains_bare_rejects_longer_numbers() {
344 assert!(contains_bare("costs $580 a month", "$580"));
345 assert!(!contains_bare("revenue of $5,800", "$580"));
346 assert!(!contains_bare("total $5804", "$580"));
347 assert!(contains_bare("fee is 2.9% today", "2.9%"));
348 assert!(!contains_bare("fee is 12.9% today", "2.9%"));
349 assert!(!contains_bare("$10,000/month", "10,000"));
350 assert!(contains_bare("cap is 10,000 per list", "10,000"));
351 }
352
353 #[test]
354 fn strip_markers_preserves_offsets_and_lines() {
355 let input = "a {{ x.y }} b\nc {{ z }} d\n";
356 let out = strip_markers(input);
357 assert_eq!(out.len(), input.len());
358 assert_eq!(out.lines().count(), input.lines().count());
359 assert!(!out.contains("x.y"), "marker body must be blanked: {out:?}");
360 assert!(out.starts_with("a "), "prose must survive: {out:?}");
361 }
362
363 #[test]
364 fn strip_ranges_blanks_code_spans_only() {
365 let input = "real $580 and `literal $580`";
366 let out = strip_ranges(input, &code_span_ranges(input));
367 assert!(contains_bare(&out, "$580"), "prose copy still visible");
368 assert_eq!(out.matches("$580").count(), 1, "code copy blanked: {out:?}");
369 }
370
371 #[test]
372 fn group_thousands_inserts_separators() {
373 assert_eq!(group_thousands("61960"), "61,960");
374 assert_eq!(group_thousands("580"), "580");
375 assert_eq!(group_thousands("1000000"), "1,000,000");
376 assert_eq!(group_thousands("0.31"), "0.31");
377 }
378 }
379