Skip to main content

max / makenotwork

13.6 KB · 380 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 ("mail_cap.monthly_display", Form::Verbatim),
122 ("synckit.storage_usd_per_gb_month", Form::Money),
123 ("synckit.invoice_floor_usd", Form::Money),
124 ("derived.annual_founding_basic", Form::Dollars),
125 ("derived.annual_founding_small_files", Form::Dollars),
126 ("derived.annual_founding_big_files", Form::Dollars),
127 ("derived.annual_founding_everything", Form::Dollars),
128 ("derived.annual_standard_basic", Form::Dollars),
129 ("derived.annual_standard_small_files", Form::Dollars),
130 ("derived.annual_standard_big_files", Form::Dollars),
131 ("derived.annual_standard_everything", Form::Dollars),
132 ];
133
134 /// Lines that legitimately carry a linted literal. Matched as
135 /// (path suffix, substring of the offending line, reason), a substring rather
136 /// than a line number so ordinary edits above don't invalidate the waiver.
137 const EXEMPT: &[(&str, &str, &str)] = &[
138 (
139 "guide/git.md",
140 "10,000 lines",
141 "commit-diff display cap, unrelated to the broadcast recipient cap",
142 ),
143 (
144 "guide/items.md",
145 "10,000 characters",
146 "changelog field length, unrelated to the broadcast recipient cap",
147 ),
148 (
149 "about/how-we-work.md",
150 "1,000 transactions",
151 "illustrative transaction count in a revenue example, not the cohort cap",
152 ),
153 (
154 "guide/stripe.md",
155 "| Canada |",
156 "Canadian Stripe rate (C$), coincidentally the same digits as the US rate; \
157 only the US row is ours to substitute",
158 ),
159 (
160 "guide/stripe.md",
161 "| Australia |",
162 "Australian Stripe rate (A$), not the US flat fee",
163 ),
164 ];
165
166 #[test]
167 fn no_bare_assumption_values_in_site_docs() {
168 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load");
169
170 // Render each linted key through the engine itself, so the literal we hunt
171 // for is byte-identical to what the marker would have emitted.
172 let mut needles: Vec<(&str, String)> = Vec::new();
173 for &(key, form) in LINTED {
174 assert!(a.get(key).is_some(), "linted key {key} is not in the table");
175 let render = |expr: &str| {
176 a.substitute(&format!("{{{{ {expr} }}}}"))
177 .unwrap_or_else(|e| panic!("cannot render {expr}: {e}"))
178 };
179 match form {
180 Form::Money => needles.push((key, render(&format!("{key} | money")))),
181 Form::Percent => needles.push((key, render(&format!("{key} | percent")))),
182 Form::Verbatim => needles.push((key, render(key))),
183 Form::Dollars => {
184 let plain = render(key);
185 needles.push((key, format!("${plain}")));
186 let grouped = group_thousands(&plain);
187 if grouped != plain {
188 needles.push((key, format!("${grouped}")));
189 }
190 }
191 }
192 }
193
194 let mut findings = Vec::new();
195 visit_markdown(std::path::Path::new(SITE_DOCS_PATH), &mut |path, body| {
196 let display = path.display().to_string();
197 let code = code_span_ranges(body);
198 let prose = strip_ranges(body, &code);
199 let prose = strip_markers(&prose);
200
201 for (lineno, line) in prose.lines().enumerate() {
202 for (key, needle) in &needles {
203 if !contains_bare(line, needle) {
204 continue;
205 }
206 if EXEMPT
207 .iter()
208 .any(|(file, snippet, _)| display.ends_with(file) && line.contains(snippet))
209 {
210 continue;
211 }
212 findings.push(format!(
213 " {display}:{}: bare {needle:?}. Use a {{{{ {key} }}}} marker\n {}",
214 lineno + 1,
215 line.trim()
216 ));
217 }
218 }
219 });
220
221 assert!(
222 findings.is_empty(),
223 "{} bare assumption value(s) in the corpus \
224 (substitute them, or add an EXEMPT entry with a reason):\n{}",
225 findings.len(),
226 findings.join("\n")
227 );
228 }
229
230 /// A waiver that no longer matches anything is a waiver that should be
231 /// deleted, not carried. Keeps `EXEMPT` from silently outliving its reason.
232 #[test]
233 fn every_exemption_still_matches_a_line() {
234 let mut stale = Vec::new();
235 for (file, snippet, reason) in EXEMPT {
236 let path = std::path::Path::new(SITE_DOCS_PATH).join(file);
237 let matched = std::fs::read_to_string(&path)
238 .is_ok_and(|body| body.lines().any(|line| line.contains(*snippet)));
239 if !matched {
240 stale.push(format!(
241 " {file}: {snippet:?} no longer present ({reason})"
242 ));
243 }
244 }
245 assert!(
246 stale.is_empty(),
247 "{} stale bare-value exemption(s), delete them:\n{}",
248 stale.len(),
249 stale.join("\n")
250 );
251 }
252
253 /// Blank out byte ranges (code spans, `{{ … }}` markers) with spaces, keeping
254 /// every other byte at its original offset so line numbers stay truthful.
255 fn strip_ranges(text: &str, ranges: &[(usize, usize)]) -> String {
256 let mut out: Vec<u8> = text.as_bytes().to_vec();
257 for &(start, end) in ranges {
258 for b in &mut out[start..end.min(text.len())] {
259 if *b != b'\n' {
260 *b = b' ';
261 }
262 }
263 }
264 String::from_utf8(out).expect("only ASCII bytes replaced")
265 }
266
267 /// Blank out `{{ … }}` markers: a value inside one is substituted, not bare.
268 fn strip_markers(text: &str) -> String {
269 let mut ranges = Vec::new();
270 let mut rest = text;
271 let mut base = 0;
272 while let Some(open) = rest.find("{{") {
273 let after = open + 2;
274 match rest[after..].find("}}") {
275 Some(rel) => {
276 let close = after + rel + 2;
277 ranges.push((base + open, base + close));
278 base += close;
279 rest = &rest[close..];
280 }
281 // Unclosed marker: nothing further to blank.
282 None => break,
283 }
284 }
285 strip_ranges(text, &ranges)
286 }
287
288 /// True when `needle` occurs in `line` as a whole number, not as a fragment of
289 /// a longer one. `$8` must not match inside `$8,410`, and `2.9%` must not match
290 /// inside `12.9%`.
291 fn contains_bare(line: &str, needle: &str) -> bool {
292 let bytes = line.as_bytes();
293 let mut from = 0;
294 while let Some(rel) = line[from..].find(needle) {
295 let start = from + rel;
296 let end = start + needle.len();
297 let before_ok = start == 0 || !matches!(bytes[start - 1], b'0'..=b'9' | b'.' | b',' | b'$');
298 let after_ok =
299 end == bytes.len() || !matches!(bytes[end], b'0'..=b'9' | b'.' | b',' | b'%');
300 if before_ok && after_ok {
301 return true;
302 }
303 from = end;
304 }
305 false
306 }
307
308 /// `61960` → `61,960`. Leaves anything with a decimal point alone.
309 fn group_thousands(digits: &str) -> String {
310 if !digits.bytes().all(|b| b.is_ascii_digit()) {
311 return digits.to_string();
312 }
313 let mut out = String::with_capacity(digits.len() + digits.len() / 3);
314 let n = digits.len();
315 for (i, c) in digits.chars().enumerate() {
316 if i > 0 && (n - i).is_multiple_of(3) {
317 out.push(',');
318 }
319 out.push(c);
320 }
321 out
322 }
323
324 fn visit_markdown(dir: &std::path::Path, f: &mut impl FnMut(&std::path::Path, &str)) {
325 let Ok(entries) = std::fs::read_dir(dir) else {
326 return;
327 };
328 for entry in entries.flatten() {
329 let path = entry.path();
330 if path.is_dir() {
331 visit_markdown(&path, f);
332 } else if path.extension().and_then(|s| s.to_str()) == Some("md")
333 && let Ok(body) = std::fs::read_to_string(&path)
334 {
335 f(&path, &body);
336 }
337 }
338 }
339
340 mod lint_unit {
341 use super::*;
342
343 #[test]
344 fn contains_bare_rejects_longer_numbers() {
345 assert!(contains_bare("costs $580 a month", "$580"));
346 assert!(!contains_bare("revenue of $5,800", "$580"));
347 assert!(!contains_bare("total $5804", "$580"));
348 assert!(contains_bare("fee is 2.9% today", "2.9%"));
349 assert!(!contains_bare("fee is 12.9% today", "2.9%"));
350 assert!(!contains_bare("$10,000/month", "10,000"));
351 assert!(contains_bare("cap is 10,000 per list", "10,000"));
352 }
353
354 #[test]
355 fn strip_markers_preserves_offsets_and_lines() {
356 let input = "a {{ x.y }} b\nc {{ z }} d\n";
357 let out = strip_markers(input);
358 assert_eq!(out.len(), input.len());
359 assert_eq!(out.lines().count(), input.lines().count());
360 assert!(!out.contains("x.y"), "marker body must be blanked: {out:?}");
361 assert!(out.starts_with("a "), "prose must survive: {out:?}");
362 }
363
364 #[test]
365 fn strip_ranges_blanks_code_spans_only() {
366 let input = "real $580 and `literal $580`";
367 let out = strip_ranges(input, &code_span_ranges(input));
368 assert!(contains_bare(&out, "$580"), "prose copy still visible");
369 assert_eq!(out.matches("$580").count(), 1, "code copy blanked: {out:?}");
370 }
371
372 #[test]
373 fn group_thousands_inserts_separators() {
374 assert_eq!(group_thousands("61960"), "61,960");
375 assert_eq!(group_thousands("580"), "580");
376 assert_eq!(group_thousands("1000000"), "1,000,000");
377 assert_eq!(group_thousands("0.31"), "0.31");
378 }
379 }
380