-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcheck_docs.py
More file actions
executable file
·1346 lines (1182 loc) · 60.7 KB
/
Copy pathcheck_docs.py
File metadata and controls
executable file
·1346 lines (1182 loc) · 60.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Assert the documents still agree with the configs they describe.
Documentation drift is this repository's most frequently-recurring defect, and
unlike the others it has never had a check. `oracle` was recorded as an
i5-1235U with 32 GB when it is a dual-core A6-9200 with 4 GB, and ADR-0008 notes
that wrong entry was load-bearing in planning. `shiva` was described as the
hypervisor for several revisions when it is the iLO. `roadmap.md` says of itself
that it "has already been wrong about the switch answering SNMP and about the
history purge". Alert-rule and panel counts went stale in eight places (#72),
one of them a verification step inside a deploy runbook.
Every other class of defect here was answered by moving truth somewhere CI can
check it. This does that for `docs/`, following the pattern
`scripts/snmp-targets.sh --check` already established for the SNMP inventory:
The device list must live in exactly one place. It is currently spread
across five ... and --check asserts the other copies still agree.
Ten assertions, each comparing prose against something machine-readable:
1. Counted claims rules, unit-test coverage, dashboards, panels,
Alloy agents, ADRs, runbooks, and the size of this
list — README.md says how many assertions run, and
that number is read from the registry in main()
2. SNMP targets snmp.yaml <-> docs/network.md
3. Host and stack table docs/architecture.md <-> docs/network.md, stacks/
4. Ports table docs/architecture.md <-> compose.yaml
5. Compute table docs/hardware.md <-> docs/network.md, and
neither pinning a point release
6. Image versions no version pins in prose; compose.yaml owns them
7. ADR numbering one ADR per number, and each file's H1 agrees with
the number in its filename
8. Firewall posture docs/security.md <-> docs/firewall-claims.yaml. The
CI half of #363; the claims file is checked against
the running firewall by check_firewall_claims.py,
which cannot run here because the ruleset is not in
this repository and deliberately never will be.
9. Guest claims a host that says it runs no guests, while another
row says it is a guest on it. Checkable only against
its siblings, which is why it is its own assertion.
10. Outstanding buys README.md <-> the buy table in docs/roadmap.md. The
roadmap is a source here, not a target: that table
is the one place a purchase may enter or leave, so
it is what README's count answers to.
Only present-tense documents are checked. `docs/roadmap.md` and `docs/adr/`
record what was true when the work landed — `roadmap.md` still says "(34 rules)"
in a Done entry, and that is correct as written. Failing on those would need an
ignore list, and this repository has already learned where that leads: the
`.gitleaksignore` deleted in the history purge "was an acknowledgement, not a
fix, and it existed because a CI job that is permanently red for a known reason
gets ignored". So the scope is a fixed list of files rather than a suppression
mechanism that grows.
A count is matched whether it is written in digits or spelled out. The first
version of this check only looked for the phrasings someone happened to think
of, which left "13 LogQL rules" in README.md unguarded next to a checked "13
log-based", and left "five dashboards" in docs/images/README.md unguarded in a
file that was not in scope at all. Both were correct, and both would have gone
stale silently — the exact failure #72 is about, surviving inside its own fix.
Usage: scripts/check_docs.py
"""
from __future__ import annotations
import functools
import json
import pathlib
import re
import subprocess
import sys
try:
import yaml
except ModuleNotFoundError:
print("installing PyYAML", file=sys.stderr)
if subprocess.run(
[sys.executable, "-m", "pip", "install", "--quiet",
"--disable-pip-version-check", "pyyaml"],
check=False,
).returncode:
sys.exit("PyYAML is required and could not be installed")
import yaml
REPO = pathlib.Path(__file__).resolve().parent.parent
STACK = REPO / "stacks/observability"
COMPOSE = STACK / "compose.yaml"
ALERTMANAGER = STACK / "alertmanager/alertmanager.yaml"
NETWORK_MD = REPO / "docs/network.md"
ARCH_MD = REPO / "docs/architecture.md"
HARDWARE_MD = REPO / "docs/hardware.md"
ROADMAP_MD = REPO / "docs/roadmap.md"
# Present-tense documents. See the module docstring for why roadmap.md and
# adr/ are deliberately absent — they are records, not claims about now.
PROSE = (
"README.md",
"SECURITY.md",
"docs/architecture.md",
"docs/hardware.md",
"docs/images/README.md",
"docs/network.md",
"docs/observability.md",
"docs/security.md",
# Every stack's README, globbed rather than listed. This was the single
# literal "stacks/observability/README.md", so stacks/lab's README was
# prose nothing checked — its image table could have carried a version pin
# and gone stale silently, which is the #73 defect the whole PROSE list
# exists to prevent (#263).
*sorted(
str(p.relative_to(REPO)) for p in REPO.glob("stacks/*/README.md")
),
*sorted(
str(p.relative_to(REPO)) for p in (REPO / "docs/runbooks").glob("*.md")
),
)
# Prose spells small numbers out, and a spelled count goes stale exactly as
# readily as a digit: "five dashboards" and "across six files" were both
# unguarded while the digits beside them were checked. Longest-first so "seven"
# cannot match inside "seventeen" and leave the rest of the pattern to fail.
#
# The word branch is case-insensitive because prose capitalises a number that
# starts a sentence, and a capital is not a different claim. It was matching
# lowercase only, so "There are five dashboards" was guarded while "Five
# dashboards are provisioned" two files away was not — and both were stale
# together (#81). `number()` already lowercased, so only the pattern was wrong.
#
# THE TABLE USED TO STOP AT TWENTY, described here as "comfortably above any
# count here — the cap keeps the alternation short, it is not a claim about the
# ceiling". That was true when it was written and stopped being true without
# anything noticing. On 2026-09-07 the unit-test coverage sentence in
# observability.md read "Coverage is forty-five rules of 67" and TWO rules were
# added that day: the digit was caught both times and the word was not, because
# "forty-five" was not in the table (#367). The check exists to prevent exactly
# that, and the defect survived inside it.
#
# So the words are GENERATED through ninety-nine rather than typed, and the
# ceiling is now asserted rather than assumed — see check_unparsed_counts().
# Beyond a hundred, prose here uses digits, and the assertion is what makes that
# a rule rather than a hope.
#
# The cost of widening it: a heading like "Two dashboards are not captured" is a
# claim about a subset, and this reads it as a claim about the total and fails.
# That is the right way round — a false positive is a reword, a false negative is
# a document that lies. Phrase a subset so it does not put a bare count in front
# of the noun.
_ONES = ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine"]
_TEENS = ["ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
"sixteen", "seventeen", "eighteen", "nineteen"]
_TENS = ["twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty",
"ninety"]
def _number_words() -> dict[str, int]:
words = {w: i + 1 for i, w in enumerate(_ONES)}
words.update({w: 10 + i for i, w in enumerate(_TEENS)})
for t_i, tens in enumerate(_TENS):
base = 20 + t_i * 10
words[tens] = base
# Hyphenated is the only form used here and the only one generated.
# "forty five" as two words would need the pattern to span whitespace,
# which would also match "forty" ending one claim and "five" starting
# the next.
for o_i, one in enumerate(_ONES):
words[f"{tens}-{one}"] = base + o_i + 1
return words
NUMBER_WORDS = _number_words()
# Built as STRUCTURE rather than as a flat list of all ninety-nine words. A
# 99-branch alternation is correct and slow: it is applied for ~20 claim
# patterns across ~25 documents, and flattening it took check_docs from 1.6s to
# 5.5s — measured. Expressing the compounds as "tens, optionally hyphen ones"
# gives 27 branches for the same language and puts the cost back to noise.
#
# Longest-first within each group, so "seven" cannot match inside "seventeen"
# and leave the rest of the pattern to fail.
def _alt(words: list[str]) -> str:
return "|".join(sorted(words, key=len, reverse=True))
_WORD_NUMBER = (
rf"(?:{_alt(_TENS)})(?:-(?:{_alt(_ONES)}))?" # twenty, forty-five
rf"|{_alt(_TEENS)}" # ten .. nineteen
rf"|{_alt(_ONES)}" # one .. nine
)
COUNT = r"\b(\d+|(?i:" + _WORD_NUMBER + r"))"
# Number words this table cannot turn into an integer. A claim built on one of
# these is invisible to every assertion below, so it is reported rather than
# skipped — the ceiling stays visible instead of becoming the next silent gap.
# This is the lesson of #367 rather than a guess about what comes next.
# The optional leading word absorbs the determiner or multiplier these always
# carry in prose — "a hundred panels", "two hundred panels" — because without it
# the article sits between the verb and the number and the claim pattern's
# whitespace cannot span it. That was the first version's bug, caught by testing
# the assertion rather than assuming it worked.
UNPARSEABLE_NUMBER = re.compile(
r"(?:\w+[ \t]+)?\b(?i:hundred|thousand|million|billion|dozen|score)\b"
)
# Prose wraps, and a counted claim wraps with it. "It routes all seven\nVLANs"
# in restore-the-firewall.md was invisible to a line-by-line scan for the whole
# life of #209 — the claim was there, the grep that would have found it was not.
# So claims are matched against the whole file and the line is derived from the
# offset. One newline is allowed inside a claim and a blank line is not, so a
# count ending one paragraph cannot bind to a noun starting the next.
WS = r"(?:[ \t]+|[ \t]*\n[ \t]*)"
def number(token: str) -> int:
"""A counted claim, written either as digits or as a word."""
return int(token) if token.isdigit() else NUMBER_WORDS[token.lower()]
# ---------------------------------------------------------------------------
# Markdown helpers
# ---------------------------------------------------------------------------
def cells(line: str) -> list[str]:
"""Split one markdown table row into stripped cells."""
return [c.strip() for c in line.strip().strip("|").split("|")]
def is_separator(line: str) -> bool:
return bool(re.fullmatch(r"\|[\s:|-]+\|", line.strip()))
def tables_under(text: str, heading: re.Pattern[str]) -> list[list[list[str]]]:
"""Every markdown table in the section introduced by `heading`.
A section runs to the next heading of the same or higher level, so a
table belonging to a later section is never attributed to this one.
"""
out: list[list[list[str]]] = []
lines = text.splitlines()
start = None
for i, line in enumerate(lines):
if heading.match(line):
start = i
break
if start is None:
return out
level = len(lines[start]) - len(lines[start].lstrip("#"))
rows: list[list[str]] = []
for line in lines[start + 1:]:
if line.startswith("#"):
depth = len(line) - len(line.lstrip("#"))
if depth <= level:
break
if line.lstrip().startswith("|"):
if not is_separator(line):
rows.append(cells(line))
elif rows:
out.append(rows)
rows = []
if rows:
out.append(rows)
return out
def strip_md(cell: str) -> str:
"""Reduce a table cell to its plain text: no backticks, links or emphasis."""
cell = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", cell) # links
cell = re.sub(r"\[\^[^\]]*\]", "", cell) # footnote refs
cell = cell.replace("`", "").replace("*", "").replace("**", "")
return cell.strip()
# A host-and-stack row for something that does not exist yet. See the block
# above check_host_stack_table() for what it does there; count_alloy_agents()
# below reads it too, because a row describing an undeployed host describes an
# undeployed agent.
#
# Matched against the RAW cell, never strip_md()'s output: that helper removes
# every `*`, which takes the emphasis with it and leaves the marker unfindable.
NOT_BUILT = re.compile(r"\*\*not built yet\*\*", re.I)
# ---------------------------------------------------------------------------
# Facts, computed from the configs
# ---------------------------------------------------------------------------
def count_alerts(paths) -> int:
return sum(
len(re.findall(r"^\s*-\s*alert:", p.read_text(encoding="utf-8"), re.M))
for p in paths
)
def tested_alertnames(paths) -> set[str]:
"""Every alert named by a promtool unit test.
`alertname:` appears twice per case — once selecting the rule, once inside
exp_labels — so this is a set, not a count. A case asserting
`exp_alerts: []` still counts the rule as covered: it was fed an input and
its silence was asserted, which is the half of the pairing #63 was missing.
This counts what the tests NAME, not what they prove. A test file naming a
rule that no longer exists would inflate it — but `promtool test rules`
fails on that first, so the two checks bracket each other.
"""
names: set[str] = set()
for path in paths:
names.update(
re.findall(
r"^\s*alertname:\s*(\S+)", path.read_text(encoding="utf-8"), re.M
)
)
return names
def count_panels(paths) -> int:
"""Every panel object, rows included.
Rows are counted because the numbers already in the documents count them:
84 with rows, 73 without. Pinning the definition here is what makes the
figure reproducible rather than a number somebody once arrived at.
"""
total = 0
def walk(panels) -> None:
nonlocal total
for panel in panels or []:
total += 1
walk(panel.get("panels"))
for path in paths:
walk(json.loads(path.read_text(encoding="utf-8")).get("panels"))
return total
def compose_services() -> dict:
"""Services that `make up` actually starts.
Profile-gated services are excluded: `renderer` sits behind the `capture`
profile precisely so the running stack stays six services, and counting it
would make this check disagree with a document that is correct.
"""
doc = yaml.safe_load(COMPOSE.read_text(encoding="utf-8"))
return {
name: svc or {}
for name, svc in (doc.get("services") or {}).items()
if not (svc or {}).get("profiles")
}
def count_notifying_receivers() -> int:
"""Receivers in alertmanager.yaml that actually send somewhere.
`null` is excluded. It exists to swallow `info` and has no *_configs at
all, so counting it would make the prose say five and be wrong in the
other direction. The test is "declares at least one delivery config"
rather than a name blocklist, so a second discard receiver would be
excluded on the same grounds without anyone remembering to add it here.
"""
doc = yaml.safe_load(ALERTMANAGER.read_text(encoding="utf-8")) or {}
return sum(
1
for r in (doc.get("receivers") or [])
if any(k.endswith("_configs") and r[k] for k in r)
)
# A Contents cell saying a host runs NO Alloy agent. Stripped before the
# substring test below, so that saying so does not read as saying the opposite.
#
# "no Alloy" covers both shapes the table uses: "runs no Alloy" on the six lab
# machines ADR-0029 scrapes rather than instruments, and "No Alloy agent" on a
# host that is scraped for a different reason. A negation this does NOT know
# would overcount — the failure is loud rather than silent, because the count
# is asserted against hardware.md's sentence, but it is worth extending here
# rather than reaching for a different phrasing in the table.
NO_ALLOY = re.compile(r"\bno\s+alloy\b", re.I)
def count_alloy_agents() -> int:
"""Hosts the architecture table says run an Alloy agent.
The agents are not in this repository — deploy-agent.sh puts them on
hosts, and nothing here lists the hosts — so the Host and stack mapping
is the machine-readable side. A row whose Contents cell names Alloy is an
agent; "two Alloy agents" in hardware.md was unguarded and stale for as
long as it took to deploy a third (#88).
A row marked NOT_BUILT is not counted, because an agent on a host that does
not exist is not an agent. `stacks/lab` declares one for `alexander`, and it
collects nothing until that guest is racked (#262). The exclusion is not a
convenience: dropping the marker on the commit that builds the host pushes
this count to four and fails hardware.md's "three Alloy agents" in the same
run, which is exactly when that sentence should be forced to change.
SAYING A HOST RUNS NO ALLOY USED TO COUNT IT AS RUNNING ONE. The test was a
bare `"alloy" in cell`, which cannot tell "Alloy agent (Docker)" from "runs
no Alloy". It was survivable only by accident: every row that says so is
also marked NOT_BUILT, so the negation was masked by the exclusion above —
and would have surfaced on the commit that dropped the marker, which is the
commit already busy changing this count for a real reason. Found 2026-09-16
when `smaug`'s row said "No Alloy agent" and pushed the count to five; six
further rows (`bahamut`, `leviathan`, `titan`, `ramuh`, `carbuncle`,
`siren`) carry "runs no Alloy" and are waiting to do the same.
Negations are STRIPPED rather than the affirmative being matched, and that
is the part worth writing down. Matching `alloy agent` instead looks
tidier and is wrong: `prometheus` names Alloy in a service list — "…
docker-socket-proxy, Alloy" — and never says "Alloy agent" at all, so that
test would drop a host which genuinely runs one and quietly report three.
An over-count fails loudly against hardware.md; an under-count would have
been a checker agreeing with a stale sentence.
"""
tables = tables_under(
ARCH_MD.read_text(encoding="utf-8"),
re.compile(r"^##\s+Host and stack mapping"),
)
if not tables:
return 0
return sum(
1 for row in tables[0][1:]
if len(row) > 3
and "alloy" in NO_ALLOY.sub("", strip_md(row[3])).lower()
and not NOT_BUILT.search(row[3])
)
def count_vlans() -> int:
"""VLANs in docs/network.md's segment table.
The table is the enumeration; the prose above it was the claim, and they
disagreed five times over (#209). `WAN` and `LAN` carry a dash in the VLAN
column precisely because they are not VLANs — the untagged switch-management
LAN is a real network and a real seventh thing to count, which is why the
wrong number was so durable. Counting the tag column rather than the rows
keeps that distinction.
"""
text = NETWORK_MD.read_text(encoding="utf-8")
rows = tables_under(text, re.compile(r"^#\s+Network$", re.M))
if not rows:
return 0
return sum(1 for row in rows[0] if len(row) > 1 and strip_md(row[1]).isdigit())
def count_adrs() -> int:
"""ADR files in docs/adr/, superseded ones included.
README.md is the only place this number is asserted and it was wrong by four
when this was written — twenty-three against twenty-seven. It had been right
once. Nothing failed as 0024 through 0027 landed, because "N ADRs" was a
phrasing nobody had thought of, which is the #72 shape this whole file
exists to prevent surviving inside the fix for it — exactly as "forty-five
rules" did (#367).
Superseded ADRs count. ADR-0002 and ADR-0013 are marked and not deleted, and
ADR-0001 says why: "the history of what was believed and when is the point."
A count that skipped them would be counting decisions still in force, which
is a different claim from the one the README makes.
This counts the directory rather than reading the files, and that is the
module docstring's scoping rule showing through: docs/adr/ is deliberately
outside PROSE, so an ADR stating a count in its own text stays untouched.
"""
return len(list((REPO / "docs/adr").glob("[0-9][0-9][0-9][0-9]-*.md")))
def count_runbooks() -> int:
"""Runbooks in docs/runbooks/.
THIS COUNT IS CORRECT TODAY, and it is guarded anyway, which needs saying
because guarding a true number can look like work for its own sake.
It is correct the way the ADR count above was correct once. #265 is in
review as this lands and adds build-the-lab-domain.md, which makes it wrong
— so this is the rare case where the drift can be watched arriving instead
of found four ADRs later. Nothing about the change that falsifies it touches
README.md, or any file a reviewer of that branch would think to reread: a
directory gains a file, and a sentence somewhere else quietly stops being
true. An unguarded number that happens to be right is not a number anybody
is keeping right.
Every .md here is a runbook and there is no index file, so the glob is the
count. One added later would not be a runbook and would need excluding.
"""
return len(list((REPO / "docs/runbooks").glob("*.md")))
def facts() -> dict:
prom_rules = sorted((STACK / "prometheus/rules").glob("*.rules.yaml"))
loki_rules = sorted((STACK / "loki/rules").glob("*.rules.yaml"))
dashboards = sorted((STACK / "grafana/dashboards").glob("*.json"))
prom = count_alerts(prom_rules)
loki = count_alerts(loki_rules)
tested = tested_alertnames(
sorted((STACK / "prometheus/tests").glob("*.test.yaml"))
)
return {
"prometheus_rules": prom,
"loki_rules": loki,
"total_rules": prom + loki,
"dashboards": len(dashboards),
"panels": count_panels(dashboards),
"prometheus_rule_files": len(prom_rules),
"tested_rules": len(tested),
"untested_rules": prom - len(tested),
"alloy_agents": count_alloy_agents(),
"receivers": count_notifying_receivers(),
"vlans": count_vlans(),
"adrs": count_adrs(),
"runbooks": count_runbooks(),
}
# ---------------------------------------------------------------------------
# 1. Counted claims
# ---------------------------------------------------------------------------
@functools.lru_cache(maxsize=None)
def _unreadable_pattern(pattern: str) -> re.Pattern[str]:
"""`pattern` with the number token swapped for the ones that cannot be read.
Memoised because the claim patterns are rebuilt on every call and compiling
a 99-branch alternation repeatedly is most of what made the ceiling scan
expensive.
"""
return re.compile(pattern.replace(COUNT, UNPARSEABLE_NUMBER.pattern))
def check_counts(f: dict) -> list[str]:
# "N alert rules" is genuinely ambiguous in this repository: README uses it
# for the total, security.md for the Prometheus half. Both readings are
# legitimate prose, so both are accepted — the check still catches a number
# that is neither, which is what stale looks like.
claims = (
(rf"{COUNT}" + WS + r"alert rules", {f["prometheus_rules"], f["total_rules"]},
"alert rules"),
(rf"{COUNT}" + WS + r"rules in total", {f["total_rules"]}, "total rules"),
(rf"{COUNT}" + WS + r"rules loaded", {f["prometheus_rules"]}, "rules loaded"),
(rf"{COUNT}" + WS + r"rules across", {f["prometheus_rules"]}, "Prometheus rules"),
(rf"{COUNT}" + WS + r"metric-based", {f["prometheus_rules"]}, "metric-based rules"),
(rf"{COUNT}" + WS + r"log-based", {f["loki_rules"]}, "log-based rules"),
# README says "13 LogQL rules" where observability.md says "log-based".
# Same number, different prose; the first phrasing matched nothing.
(rf"{COUNT}" + WS + r"LogQL rules", {f["loki_rules"]}, "LogQL rules"),
(rf"{COUNT}" + WS + r"(?:provisioned\s+)?dashboards", {f["dashboards"]},
"dashboards"),
(rf"{COUNT}" + WS + r"panels", {f["panels"]}, "panels"),
# "39 rules across six files" states two counts. The first was checked
# and the second was not, so splitting a rule file could not fail here.
(rf"rules across" + WS + COUNT + WS + r"files", {f["prometheus_rule_files"]},
"Prometheus rule files"),
# How many rules have a unit test, and how many do not. Both were
# unguarded and both were already stale: the sentence read "Coverage is
# six rules of 39 ... ContainerHighMemory and Watchdog" while
# blackbox.test.yaml had covered three more for weeks. This is the
# figure most likely to drift, because it moves whenever a test lands.
(rf"[Cc]overage is" + WS + COUNT + WS + r"rules", {f["tested_rules"]},
"unit-tested rules"),
(rf"[Oo]ther" + WS + COUNT + WS + r"are still validated", {f["untested_rules"]},
"rules without a unit test"),
# "Coverage is fifteen rules of 45" states two counts and only the
# first was checked, so the denominator could go stale on its own —
# the same shape as "39 rules across six files" above, and it did go
# stale the same way the moment a rule was added (#81).
(rf"rules of" + WS + COUNT + WS + r"so far", {f["prometheus_rules"]},
"rules in the coverage denominator"),
# Where the agents run is documented, not deployed from here, so the
# architecture table is the source and hardware.md's sentence is the
# claim. See count_alloy_agents.
(rf"{COUNT}" + WS + r"Alloy agents", {f["alloy_agents"]}, "Alloy agents"),
# The second time a number in observability.md drifted (#72, then #212):
# `security` was added and the sentence introducing the routing table
# still said three. Both halves of "N receivers, N separate
# destinations" are counted, because they went stale as a pair and
# guarding only the first would leave the second free to drift alone —
# the "39 rules across six files" shape above.
#
# Derived from alertmanager.yaml the way the rule counts are derived
# from the rule files, so adding a receiver fails here rather than
# waiting for someone to reread the paragraph.
(rf"{COUNT}" + WS + r"receivers", {f["receivers"]}, "notifying receivers"),
(rf"{COUNT}" + WS + r"separate destinations", {f["receivers"]},
"separate destinations"),
# Asserted five times and enumerated zero times (#209). The count is
# the segment table's tag column, so the untagged switch-management LAN
# stays uncounted here and "seven internal networks" stays sayable.
(rf"{COUNT}" + WS + r"VLANs", {f["vlans"]}, "VLANs"),
# Two counts in one README sentence and neither was guarded. "23 ADRs"
# was stale by four. "19 runbooks" is correct, and is guarded because
# #265 is in review and adds one — the same drift, caught on the way in
# rather than four ADRs after the fact.
#
# THE HONEST OBJECTION IS THAT NEITHER NUMBER MEANS ANYTHING, and it is
# worth writing down because it nearly won. Every other count above is
# load-bearing: alert rules are the coverage ADR-0007 explicitly trades
# away, Alloy agents say which hosts are observed, a stale panel count
# broke a verification step inside a deploy runbook (#72). An ADR total
# says only how long the project has been running. Nothing reads it,
# nothing branches on it, and deleting both numbers from the prose would
# have ended the obligation rather than automating it.
#
# Guarded anyway, for what the sentence is doing rather than what it
# counts. README.md is where this repository claims its documents are
# checked against the things they describe, and a number that is visibly
# wrong there is a false claim about the checking rather than a stale
# fact about ADRs. The precedent is also settled: #72, #81 and #367 were
# each answered by widening the patterns, never once by removing the
# number, and "delete the claim" is a fix available to every one of them.
#
# Plural only, like `receivers` and `VLANs` above. "one ADR" in prose is
# a reference and not an inventory.
(rf"{COUNT}" + WS + r"ADRs", {f["adrs"]}, "ADRs"),
(rf"{COUNT}" + WS + r"runbooks", {f["runbooks"]}, "runbooks"),
# How many assertions this file runs — and the same shape as #72, #81
# and #367: a counted claim that no pattern named, going stale where
# nothing could see it. README.md said "Six assertions" while main()
# registered ten, and the enumeration beside it had been overtaken by
# four checks — ADR numbering, firewall posture, guest claims and the
# buy list were all running and none was mentioned. Drift in the one
# file whose subject is catching drift, which makes it a false claim
# about the checking rather than a stale fact about the checks.
#
# The ADRs comment above settles the "just delete the number" argument
# and it applies here with more force: the sentence exists to say how
# thoroughly these documents are checked, so the number is the claim.
#
# Sourced from len(checks) in main() rather than a constant here, so
# an eleventh assertion fails this line on the way in instead of
# waiting for someone to reread the bullet. main() writes it into the
# facts dict after building the registry; facts() cannot compute it,
# because the registry closes over the dict facts() is still building.
(rf"{COUNT}" + WS + r"assertions", {f["assertions"]}, "assertions"),
)
problems = []
for rel in PROSE:
path = REPO / rel
if not path.exists():
continue
text = path.read_text(encoding="utf-8")
# Whether this file could possibly hold an unreadable count, asked once
# per file instead of once per claim pattern. Without it the ceiling
# scan re-walks every document with a 99-branch alternation for each of
# ~20 patterns, and check_docs went from 1.6s to 7.5s — measured, not
# guessed. Almost no file contains "hundred" at all, so this skips the
# whole scan for nearly all of them and the cost returns to noise.
may_be_unreadable = UNPARSEABLE_NUMBER.search(text) is not None
for pattern, expected, label in claims:
for match in re.finditer(pattern, text):
if number(match.group(1)) in expected:
continue
n = text.count("\n", 0, match.start()) + 1
want = " or ".join(str(v) for v in sorted(expected))
claimed = " ".join(match.group(1).split())
problems.append(
f"{rel}:{n} claims {claimed} {label}; "
f"the repository has {want}"
)
# THE SAME CLAIM, WRITTEN IN A NUMBER THIS FILE CANNOT READ.
# NUMBER_WORDS reaches ninety-nine and no further, so "a hundred
# panels" is a counted claim that every assertion above walks
# straight past — which is exactly how "forty-five rules" stayed
# stale while the digit beside it was corrected twice in one day
# (#367). The ceiling is asserted here rather than assumed, so the
# next time prose outgrows the table it fails loudly instead of
# going quiet.
if not may_be_unreadable:
continue
unreadable = _unreadable_pattern(pattern)
for match in unreadable.finditer(text):
n = text.count("\n", 0, match.start()) + 1
problems.append(
f"{rel}:{n} states {label} as a number this check cannot "
f"read ({' '.join(match.group(0).split())!r}). Counts above "
f"ninety-nine must be written in digits, or NUMBER_WORDS "
f"needs extending — an unreadable count is an unchecked one"
)
return problems
# ---------------------------------------------------------------------------
# 2. SNMP targets against the network inventory
# ---------------------------------------------------------------------------
def network_sections(text: str) -> dict[str, list[list[str]]]:
"""Host rows keyed by VLAN id, plus 'wan' and 'lan'."""
out: dict[str, list[list[str]]] = {}
for heading in re.finditer(r"^##\s+(.+)$", text, re.M):
title = heading.group(1)
vlan_match = re.search(r"VLAN\s+(\d+)", title)
if vlan_match:
key = vlan_match.group(1)
elif title.strip().upper() in ("WAN", "LAN"):
key = title.strip().lower()
else:
continue
found = tables_under(text, re.compile(re.escape(heading.group(0))))
if found:
out[key] = found[0]
return out
def check_snmp_targets() -> list[str]:
targets_file = STACK / "prometheus/targets/snmp.yaml"
targets = yaml.safe_load(targets_file.read_text(encoding="utf-8")) or []
sections = network_sections(NETWORK_MD.read_text(encoding="utf-8"))
problems = []
for entry in targets:
labels = entry.get("labels") or {}
device = labels.get("device", "")
vlan = str(labels.get("vlan", ""))
ips = entry.get("targets") or []
rows = sections.get(vlan)
if rows is None:
problems.append(
f"snmp.yaml polls {device} with vlan label {vlan!r}, and "
f"docs/network.md has no section for it"
)
continue
for ip in ips:
hit = [
r for r in rows
if strip_md(r[0]).lower() == device.lower() and ip in strip_md(r[1])
]
if not hit:
problems.append(
f"snmp.yaml polls {device} at {ip} on VLAN {vlan}, and "
f"docs/network.md does not list that host at that address "
f"in the VLAN {vlan} table"
)
return problems
# ---------------------------------------------------------------------------
# 3. Host and stack mapping
# ---------------------------------------------------------------------------
# A stack directory can legitimately exist before the host that runs it does.
# `stacks/lab` was committed complete — compose file, configs, rules, unit
# tests — while the guest that will run it, `alexander`, was still an issue
# (#262, #264). ADR-0004 puts the host-to-stack mapping in this document, so the
# row has to exist; but docs/network.md is the inventory of what is actually on
# the wire, and writing an unbuilt guest into it would be the precise kind of
# false claim this file exists to catch. It would also mean inventing a MAC, a
# device and an OS for a machine whose distribution is explicitly undecided.
#
# So the row is marked, and the marker INVERTS the check rather than switching
# it off. A normal row's host must APPEAR in network.md; a row marked "not built
# yet" must be ABSENT from it. That is what makes the marker self-clearing —
# rack the host, add its network.md row, and this fails saying the marker is
# stale, instead of quietly tolerating a row that claims both things at once.
# The address is still required and still checked for collisions, so a plan is
# held to the same standard as a deployment.
#
# The marker itself is defined next to strip_md(), because count_alloy_agents()
# reads it too.
def check_host_stack_table() -> list[str]:
text = ARCH_MD.read_text(encoding="utf-8")
tables = tables_under(text, re.compile(r"^##\s+Host and stack mapping"))
if not tables:
return ["docs/architecture.md has no 'Host and stack mapping' table"]
rows = tables[0][1:]
sections = network_sections(NETWORK_MD.read_text(encoding="utf-8"))
problems = []
named_stacks: set[str] = set()
for row in rows:
host_cell = strip_md(row[0])
host = host_cell.split("(")[0].strip()
ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", host_cell)
vlan_match = re.search(r"(\d+)", strip_md(row[1]))
named_stacks.update(re.findall(r"stacks/([a-z0-9-]+)", row[2]))
planned = bool(len(row) > 3 and NOT_BUILT.search(row[3]))
if not (ip_match and vlan_match):
problems.append(
f"docs/architecture.md host row {host_cell!r} has no address or "
f"no VLAN, so it cannot be checked against docs/network.md"
)
continue
ip, vlan = ip_match.group(1), vlan_match.group(1)
rows_for_vlan = sections.get(vlan, [])
hit = [
r for r in rows_for_vlan
if strip_md(r[0]).lower() == host.lower() and ip in strip_md(r[1])
]
if planned:
# The VLAN must be one network.md actually describes, or a typo'd
# segment would make every assertion below vacuously true.
if not rows_for_vlan:
problems.append(
f"docs/architecture.md plans {host} on VLAN {vlan}, which "
f"docs/network.md has no table for"
)
if hit:
problems.append(
f"docs/architecture.md still marks {host} 'not built yet', "
f"and docs/network.md now lists it at {ip} on VLAN {vlan} — "
f"it has been built, so drop the marker"
)
# An unbuilt host planned onto an address something else already
# holds is a real conflict, and the cheapest possible moment to
# find it is before anyone racks it.
clash = [
r for r in rows_for_vlan
if ip in strip_md(r[1]) and strip_md(r[0]).lower() != host.lower()
]
if clash:
problems.append(
f"docs/architecture.md plans {host} at {ip}, which "
f"docs/network.md already gives to "
f"{strip_md(clash[0][0])} on VLAN {vlan}"
)
elif not hit:
problems.append(
f"docs/architecture.md places {host} at {ip} on VLAN {vlan}; "
f"docs/network.md does not list it there"
)
on_disk = {
p.name for p in (REPO / "stacks").iterdir()
if p.is_dir() and not p.name.startswith(".")
}
for missing in sorted(on_disk - named_stacks):
problems.append(
f"stacks/{missing}/ exists and no row of the host and stack mapping "
f"in docs/architecture.md names it — ADR-0004 puts that mapping in "
f"documentation, which only works if it is complete"
)
return problems
# ---------------------------------------------------------------------------
# 4. Ports
# ---------------------------------------------------------------------------
def published_ports(services: dict) -> dict[str, list[tuple[str, str]]]:
"""service -> [(bind, container port)] for anything bound to the host."""
out: dict[str, list[tuple[str, str]]] = {}
for name, svc in services.items():
for spec in svc.get("ports") or []:
parts = str(spec).split(":")
if len(parts) < 2:
continue # "9116" — exposed to the compose network only
bind = parts[0]
container = parts[-1]
bind = "127.0.0.1" if bind == "127.0.0.1" else "${BIND_ADDR}"
out.setdefault(name, []).append((bind, container))
return out
def check_ports() -> list[str]:
text = ARCH_MD.read_text(encoding="utf-8")
tables = tables_under(text, re.compile(r"^##\s+Ports\s*$"))
if not tables:
return ["docs/architecture.md has no 'Ports' table"]
services = compose_services()
published = published_ports(services)
problems = []
documented: set[tuple[str, str]] = set()
for row in tables[0][1:]:
service = strip_md(row[0]).lower().split()[0]
port = strip_md(row[1])
bind = strip_md(row[2])
internal_only = "compose network" in bind.lower()
if service not in services:
problems.append(
f"docs/architecture.md documents a port for {service!r}, which "
f"is not a service in compose.yaml"
)
continue
if internal_only:
if service in published:
problems.append(
f"docs/architecture.md says {service} is never published to "
f"a host interface; compose.yaml publishes it"
)
continue
want_bind = "127.0.0.1" if "127.0.0.1" in bind else "${BIND_ADDR}"
actual = published.get(service, [])
match = [a for a in actual if a[1] == port]
if not match:
problems.append(
f"docs/architecture.md says {service} publishes {port}; "
f"compose.yaml publishes "
f"{', '.join(p for _, p in actual) or 'nothing'}"
)
continue
if match[0][0] != want_bind:
problems.append(
f"docs/architecture.md says {service}:{port} binds to {bind}; "
f"compose.yaml binds it to {match[0][0]}"
)
documented.add((service, port))
for service, entries in published.items():
for _, port in entries:
if (service, port) not in documented:
problems.append(
f"compose.yaml publishes {service}:{port} and the ports "
f"table in docs/architecture.md does not mention it"
)
return problems
# ---------------------------------------------------------------------------
# 5. Compute table
# ---------------------------------------------------------------------------
def os_key(text: str) -> tuple[str, str]:
"""('ubuntu', '24.04') from 'Ubuntu Server 24.04 LTS'.
The two documents word the same OS differently on purpose — network.md
says 'Ubuntu 24.04 LTS' where hardware.md says 'Ubuntu Server 24.04 LTS',
and 'FreeBSD 16.0' where the other says 'FreeBSD 16.0 (pfSense)'. Comparing
the family and the version rather than the string is what keeps this an
agreement check instead of a house-style check.
"""
plain = strip_md(text)
family = re.match(r"([A-Za-z]+)", plain)
version = re.search(r"(\d+(?:\.\d+)*)", plain)
return (
family.group(1).lower() if family else "",
version.group(1) if version else "",
)
# A third component is a point release — 24.04.3, 9.2.11 — and a point release
# changes when the host takes an update, which is not an edit to this
# repository. Both laptops were recorded as 24.04.3 while running 24.04.4, and
# the two documents agreed with each other throughout, so the assertion below
# could not see it: it compares the copies, and both copies were stale.
#
# So the tables record the release line, which changes only on a decision to
# rebuild, and the running version stays where it is already collected —
# `node_os_info{pretty_name="Ubuntu 24.04.4 LTS"}` on every Linux host, from
# the same Alloy agents that produce everything else here.
#
# An assertion rather than a house rule in prose, because the rule is invisible
# to whoever adds the next host, and reading it off the existing rows is
# exactly what nobody does.
POINT_RELEASE = re.compile(r"\d+(?:\.\d+){2,}")
def point_release_problem(doc: str, host: str, cell: str) -> str | None:
if not POINT_RELEASE.search(cell):
return None
return (
f"{doc} gives {host} the OS {cell!r} — that column records the release "
f"line, not the point release, which goes stale the next time the host "
f"takes an update. node_os_info carries the running one"
)
def check_compute_table() -> list[str]:
tables = tables_under(
HARDWARE_MD.read_text(encoding="utf-8"), re.compile(r"^##\s+Compute")
)
if not tables:
return ["docs/hardware.md has no 'Compute' table"]
sections = network_sections(NETWORK_MD.read_text(encoding="utf-8"))
hosts: dict[str, tuple[str, str]] = {}
# `hosts` keeps the first row per host, because the cross-document
# comparison below needs one OS per host and every table agrees today.
# `pinned` must not: `morpheus` has a row in all seven segment tables, so a
# point release written into the sixth would be invisible to a check that
# only ever reads the first. Every row is scanned; identical (host, cell)
# pairs collapse so one mistake is reported once rather than seven times.
pinned: set[tuple[str, str]] = set()
for rows in sections.values():
for row in rows[1:]:
if len(row) >= 5:
host, cell = strip_md(row[0]).lower(), strip_md(row[4])
hosts.setdefault(host, os_key(row[4]))
if POINT_RELEASE.search(cell):
pinned.add((host, cell))
problems = []
for host, cell in sorted(pinned):
problem = point_release_problem("docs/network.md", host, cell)
if problem:
problems.append(problem)
for row in tables[0][1:]:
host = strip_md(row[0])
want = os_key(row[5])
have = hosts.get(host.lower())
problem = point_release_problem("docs/hardware.md", host, strip_md(row[5]))
if problem:
problems.append(problem)
if have is None:
problems.append(
f"docs/hardware.md lists {host}, which appears nowhere in "
f"docs/network.md"
)
elif have != want:
problems.append(
f"docs/hardware.md says {host} runs {strip_md(row[5])!r}; "