Skip to main content

max / makenotwork

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