Repository navigation
Expand file tree
/
Copy pathstepgate.py
More file actions
848 lines (779 loc) · 39.8 KB
/
Copy pathstepgate.py
File metadata and controls
848 lines (779 loc) · 39.8 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
#!/usr/bin/env python3
"""stepgate_v1 - the step count a package states, read against the count its script states.
A one-command reproduction ends in a verdict line that carries the number of steps the
run took - `REPRODUCE: ALL GREEN (10 steps)`. That number is a **claim about a program**,
and the package states it in up to three carriers: the script's own header (the block
that tells a reader what the last line will be), `README.md`, and `manuscript.md`. The
program is the authority; the two documents are copies of what it prints.
Why the copies need a collector, stated as the class rather than as a slogan: a step
count is **a number a program produces, written down by hand in two other files**, and
nothing in the tree re-compared any of them until this tool. Measured over this
repository's own record, three times, all three by hand:
* `papers/issue-145` (in review, R622/R623): `reproduce.sh:12` and `README.md:18` both
state `(9 steps)` and the run takes **10** - a stale copy, found by running it.
* `papers/issue-128` (published): the three inline comments above the `--full` blocks
numbered them `[08]`-`[10]`, the numbers the default tier already uses, while the
header's own table and the run both said `[11]`-`[13]` - two copies of one
enumeration disagreeing inside **one file**, found by a census (R626) and repaired
by hand (R627).
* `papers/issue-128` (published, still live): `manuscript.md:413` states
`ALL GREEN (7 steps)` for the default tier and `:414` states `(10 steps)` for
`--full`, while the script's header, the README and the run all say **10** and
**13**. The paper tells a reader that a run takes 7 and 10 steps; it takes 10 and 13.
The third is what this tool was written for and what it reports on its first run.
**What this tool reads.** For each `papers/issue-<N>/` package directory, two sides:
* the **stated claim**, in three carrier kinds and no others:
- `reproduce.sh`: a literal `(N steps)` on a line that also carries the verdict
word `REPRODUCE:`, with the tier read off a `# default` / `# --full` marker on
that same line; or the **one computed form** the tree uses -
`NAME=$(( $(echo $LIST | wc -w) + K ))` - evaluated as `len($LIST) + K` with
`$LIST` resolved from its own quoted assignment in the same file;
- `README.md` / `manuscript.md`: a literal `(N steps)`, with the tier read off the
invocation it belongs to - `--full` on the same line, else the nearest preceding
line naming `reproduce.sh` within ten lines, else `unspecified`;
- a **dated measurement** - `N steps green` beside a date - is a record of one run
and not a specification of the program, so it is reported as its own kind and
**not checked** (see *what this tool does not read*).
* the **script's own count**, from the same two forms. The script is the authority
because it is the program; where the script states no count for a tier, a copy's
claim about that tier is reported as **not checked** and never as a pass.
Set equality is not the question here; **agreement is**. A package that states no count
at all is reported as `not stated` - it has nothing this tool can read, and that is not
a pass either. The verdict line carries both numbers, so a `PASS` cannot be read as
"22 packages checked".
**What this tool does not read, and why each gap is named rather than guessed.**
* **the per-site step label.** `papers/issue-128/reproduce.sh` repeats a label at the
block it names (`[07]` in the header table at line 21 and at line 162), so a label
occurs twice in a healthy file and a "labels are unique" test would go red on the
repair it is meant to protect. The defect R626 found was not a duplicate but a
**wrong** one - a site labelled `[08]` where its own tier's numbering says `[11]` -
and pairing a header row to the block that implements it is not mechanically
determinable from the text. So this tool reads the **extent** of a script's step
table (its largest label, against the largest count the script states) and leaves
the per-site label to a hand; that is the half it cannot do, stated here so no
reader takes a `PASS` for the other half.
* **a measured count.** `papers/issue-141/README.md:26` records *"Measured 2026-10-20:
28 steps green"*. That is a dated observation of one run, not a spec of the program,
and the script reaches 28 through loops no static reader can count without guessing;
it is printed as `NOT CHECKED` with the date and the number, and it never counts as
agreement.
* **whether the run's own count is right.** This is a **static** gate: it compares
documents to the program's declaration, never a document to a run. A script whose
header understates what it actually does passes here and fails its reproduction - and
catching that is the *package's* own collector, at the step that runs it (issue-126
compares the line it prints against what the README and the manuscript quote;
issue-139 compares the README's quoted manuscript line, with a planted copy that must
be refused).
* **two counts inside one script.** Where a script feeds its success line and its
failure/NOT RUN lines from **different** variables (issue-126: `$ALL_STEPS` on
success, `$STEPS` on the failure paths), the report prints a note naming both sites.
It is a note and not a finding: on the healthy path the two variables hold the same
value - issue-126's twelve instrument steps plus one figure step plus three checker
steps plus three singles come to 21, the same 21 its arithmetic declares - and where
they do diverge, one leg is `NOT RUN` and the run exits 2 printing the count it
actually took. A gate that went red here would be crying wolf.
Usage:
python3 .github/tools/stepgate.py --check # the whole tree, from the repository root
python3 .github/tools/stepgate.py --root DIR # read another checkout
python3 .github/tools/stepgate.py --json # the same reading as data
python3 .github/tools/stepgate.py --selftest # the plant battery
**A disagreement an open correction round owns is counted apart from one nobody owns,
and that ownership expires.** A published file moves only through its package's own
correction round, so a finding inside a round's own sites is a defect the journal has
already taken responsibility for - and a gate that stays red for as long as the round
lasts teaches its readers to stop reading its exit code, which is the blindness an
allowlist buys, only earned by noise instead. The rounds are read **off the tree**, never
recalled: `.github/corrections.json` names, for each open round, the **issue**, the
**clock** the round is measured against, the **package** it governs and the **claim
sites** it requires changed. A finding is *owned* when a round names it, and only then:
* the round's `state` must be `open` - a round recorded `closed` owns **nothing**, so
closing the issue puts its claims back in the red on the next run;
* the round's `clock` must not have passed, read against the date the gate prints -
so an entry lapses **on its own**, with no one remembering to remove it. *An entry
that cannot expire is an allowlist wearing a number*;
* where the round lists `sites`, only those sites are owned: a **new** defect in the
same package is unowned and red, and a listed site that occupies no finding is
printed as a note, so a discharged round is visible rather than silently stale.
The gate exits non-zero **only on the unowned count**. The verdict line carries both
figures, so a `PASS` can never be read as "every claim agrees", and every owned finding
is still printed, with the issue and the clock that own it. A record that exists and
cannot be read is itself a finding: a broken record must not quietly excuse anything.
Verdict: `STEPGATE: PASS` (no disagreement is unowned - every claim agrees, or the ones
that do not are owned by an open round, printed with its issue and clock), `STEPGATE:
FAIL` (at least one disagreement no open round owns, or the record cannot be read; one
`FINDING` line per member), `STEPGATE: NOT RUN` (no claim was compared and no round owns
anything, or the tree has no packages - an empty set is that gate's `NOT RUN`, never a
`PASS`). Exit 0 / 1 / 2.
"""
import datetime
import json
import os
import re
import shutil
import sys
import tempfile
# a stated count: `(N steps)` - the form every carrier in this tree writes
STEPS = re.compile(r"\((\d+)\s+steps?\b")
# the one computed form: NAME=$(( $(echo $LIST | wc -w ...) + K ))
COMPUTED = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)="
r"\$\(\(\s*\$\(\s*echo\s+\$([A-Za-z_][A-Za-z0-9_]*)\s*\|"
r"\s*wc\s+-w[^)]*\)\s*\+\s*(\d+)\s*\)\)")
ASSIGN_QUOTED = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=\"([^\"]*)\"")
# a dated measurement of a run: `28 steps green`
MEASURED = re.compile(r"\b(\d+)\s+steps?\s+green\b")
# a step table's labels, in a comment position: `# [07] ...`
LABEL = re.compile(r"\[(\d{2})\]")
# the variable a verdict line prints as a count: `$STEPS steps`
COUNT_VAR = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)\s+steps?\b")
# the tier a line declares for itself, on the script side
TIER_MARK = re.compile(r"#\s*(--full|default)\b")
TIER_FULL = re.compile(r"--full")
COPY_CARRIERS = ("README.md", "manuscript.md")
SCRIPT = "reproduce.sh"
PACKAGE = re.compile(r"^issue-(\d+)$")
LOOKBACK = 10
KNOWN_OPTIONS = ("--check", "--root", "--json", "--selftest")
# the open correction rounds this tree records - read by read_corrections(), and
# re-read against the issues they name by the cycle opener (README.md, step 0)
CORRECTIONS = os.path.join(".github", "corrections.json")
def _read(path):
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
return fh.read().split("\n")
except OSError:
return None
def _tier_of(text):
"""The tier a copy's line belongs to, from the invocation it carries."""
return "full" if TIER_FULL.search(text) else "default"
def _invocation_tier(lines, idx):
"""The tier of a claim, from the `reproduce.sh` invocation it belongs to.
The same line first (a one-line `bash reproduce.sh --full # -> (13 steps)`), then
the nearest preceding line naming the script within LOOKBACK lines - a fenced
block writes the command and the expected last line several lines apart.
"""
if SCRIPT in lines[idx]:
return _tier_of(lines[idx])
low = max(0, idx - LOOKBACK)
for j in range(idx - 1, low - 1, -1):
if SCRIPT in lines[j]:
return _tier_of(lines[j])
return "unspecified"
def read_script(path):
"""The counts a script states, its step table's extent, and its count sites."""
lines = _read(path)
if lines is None:
return None
stated = [] # (tier, n, lineno)
labels = set()
count_vars = [] # (var, lineno)
lists = {}
for i, line in enumerate(lines):
no = i + 1
m = ASSIGN_QUOTED.match(line)
if m:
lists[m.group(1)] = m.group(2).split()
m = COMPUTED.match(line)
if m:
_name, listvar, k = m.group(1), m.group(2), int(m.group(3))
words = lists.get(listvar)
if words is not None:
stated.append(("unspecified", len(words) + k, no))
if "REPRODUCE:" in line:
m = STEPS.search(line)
if m:
tier = "unspecified"
tm = TIER_MARK.search(line)
if tm:
tier = "full" if tm.group(1) == "--full" else "default"
stated.append((tier, int(m.group(1)), no))
for vm in COUNT_VAR.finditer(line):
count_vars.append((vm.group(1), no))
if line.lstrip().startswith("#"):
for lm in LABEL.finditer(line):
labels.add(int(lm.group(1)))
table_max = max(labels) if len(labels) >= 3 else None
return {"path": path, "stated": stated, "table_max": table_max,
"count_vars": count_vars}
def _target(script, tier):
"""The script's count for one tier, or (None, why)."""
by_tier = {}
for t, n, _no in script["stated"]:
by_tier.setdefault(t, set()).add(n)
if tier in by_tier and len(by_tier[tier]) == 1:
return list(by_tier[tier])[0], None
if tier in by_tier:
return None, "the script states more than one %s count" % tier
if tier in ("default", "full"):
every = set()
for vals in by_tier.values():
every |= vals
if len(every) == 1:
return list(every)[0], None
if not every:
return None, "the script states no count this gate reads"
return None, "the script states no %s count and more than one count overall" % tier
every = set()
for vals in by_tier.values():
every |= vals
if len(every) == 1:
return list(every)[0], None
if not every:
return None, "the script states no count this gate reads"
return None, "the claim names no tier and the script states more than one count"
def read_package(root, name):
pkg = os.path.join(root, "papers", name)
script = read_script(os.path.join(pkg, SCRIPT))
rec = {"package": name, "claims": [], "statements": [], "findings": [],
"notes": [], "unchecked": [], "verdict": "not stated"}
if script is None:
rec["unchecked"].append("no %s" % SCRIPT)
return rec
if script["count_vars"]:
seen = {}
for var, no in script["count_vars"]:
seen.setdefault(var, no)
if len(seen) > 1:
rec["notes"].append(
"two counts: %s - one program prints two different variables as the "
"number of steps, and no line compares them (a note, not a finding: "
"they hold the same value on every path that prints both - "
"issue-126's instrument loop of 12, one figure step, three checkers "
"and three singles come to 21, the same 21 its own arithmetic "
"declares - and where they diverge, one leg is NOT RUN and the run "
"exits 2 printing the count it took)"
% "; ".join("$%s at line %d" % (v, no) for v, no in
sorted(seen.items(), key=lambda kv: kv[1])))
for carrier in COPY_CARRIERS:
lines = _read(os.path.join(pkg, carrier))
if lines is None:
continue
for i, line in enumerate(lines):
m = MEASURED.search(line)
if m:
rec["unchecked"].append(
"%s:%d states %s steps - a dated measurement of one run, not a "
"specification of the program" % (carrier, i + 1, m.group(1)))
continue
m = STEPS.search(line)
if not m:
continue
tier = _invocation_tier(lines, i)
rec["claims"].append((carrier, i + 1, tier, int(m.group(1))))
stated = script["stated"]
if stated:
rec["statements"] = ["%s:%d (%d, %s)" % (SCRIPT, no, n, t)
for t, n, no in stated]
if rec["claims"]:
rec["verdict"] = "PASS"
for carrier, no, tier, n in rec["claims"]:
want, why = _target(script, tier)
if want is None:
rec["unchecked"].append("%s:%d states %d steps (%s) - %s"
% (carrier, no, n, tier, why))
continue
if want != n:
site = None
for t, sn, sno in stated:
if t == tier or (tier in ("default", "full") and t == "unspecified"
and sn == want):
site = sno
break
if site is None:
for t, sn, sno in stated:
if sn == want:
site = sno
break
rec["findings"].append(
{"site": "%s:%d" % (carrier, no),
"text": "%s:%d states %d steps (%s); %s:%s states %d"
% (carrier, no, n, tier, SCRIPT,
site if site is not None else "?", want)})
if script["table_max"] is not None and stated:
largest = max(n for _t, n, _no in stated)
if script["table_max"] != largest:
# a defect in the script's own enumeration is not a claim a round names,
# so it carries no site and can never be owned
rec["findings"].append(
{"site": None,
"text": "%s's step table reaches [%02d] but its largest stated "
"count is %d" % (SCRIPT, script["table_max"], largest)})
if rec["findings"]:
rec["verdict"] = "FAIL"
elif not rec["claims"] and rec["unchecked"]:
# it states something, and this gate could not read it: naming that as
# `not stated` would say the package is silent when it is not.
rec["verdict"] = "NOT CHECKED"
return rec
def read_corrections(root):
"""The correction rounds this tree records, and the record's own problems.
A missing file is not a problem - a tree with no open round legitimately records
none, and its findings are then simply unowned. A file that exists and cannot be
read **is** one: a record that excuses nothing must not look like a record that
excuses everything, so each problem is returned and read as a finding.
"""
path = os.path.join(root, CORRECTIONS)
if not os.path.isfile(path):
return [], []
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, ValueError) as exc:
return [], ["%s could not be read (%s), so no round owns anything and every "
"disagreement is unowned" % (CORRECTIONS, exc)]
raw = data.get("rounds") if isinstance(data, dict) else None
if not isinstance(raw, list):
return [], ["%s carries no `rounds` list, so no round owns anything"
% CORRECTIONS]
rounds, problems = [], []
for i, item in enumerate(raw):
if not isinstance(item, dict):
problems.append("%s round %d is not an object, so it owns nothing"
% (CORRECTIONS, i + 1))
continue
missing = [k for k in ("issue", "package", "clock", "state")
if k not in item]
if missing:
problems.append("%s round %d names no %s, so it owns nothing"
% (CORRECTIONS, i + 1, ", ".join(missing)))
continue
sites = item.get("sites")
rounds.append({"issue": item["issue"],
"issue_txt": "issue #%s" % item["issue"],
"package": str(item["package"]),
"clock": str(item["clock"]),
"state": str(item["state"]),
"sites": [str(s) for s in sites]
if isinstance(sites, list) else None,
"used": 0})
return rounds, problems
def _owner(name, site, rounds, today):
"""The round that owns one finding - or (None, why it does not).
Ownership is the round's, never a package's: the round must be `open`, its clock
must not have passed, and where it lists sites the finding must be one of them.
"""
if site is None:
return None, None
for r in rounds:
if r["package"] != name:
continue
if r["sites"] is not None and site not in r["sites"]:
continue
if r["state"] != "open":
return None, ("the round recorded for %s is %s, and a round that is not "
"open owns nothing" % (r["issue_txt"], r["state"]))
if r["clock"] < today:
return None, ("%s's clock %s has passed, read as of %s, so its claims are "
"unowned again" % (r["issue_txt"], r["clock"], today))
return r, None
return None, None
def read_tree(root, today=None):
if today is None:
today = datetime.date.today().isoformat()
papers = os.path.join(root, "papers")
report = {"root": root, "as_of": today, "packages": [], "findings": [],
"owned": [], "unchecked": [], "notes": [], "stated": 0,
"not_stated": 0, "not_checked": 0, "agree": 0, "disagree": 0,
"rounds": [], "problems": [], "denied": [], "verdict": "NOT RUN",
"reason": None}
if not os.path.isdir(papers):
report["reason"] = "no papers/ directory under %s" % root
return report
names = sorted((n for n in os.listdir(papers)
if PACKAGE.match(n) and os.path.isdir(os.path.join(papers, n))),
key=lambda n: int(PACKAGE.match(n).group(1)))
if not names:
report["reason"] = "no papers/issue-<N>/ package directory under %s" % root
return report
rounds, problems = read_corrections(root)
report["rounds"] = rounds
report["problems"] = list(problems)
for name in names:
rec = read_package(root, name)
report["packages"].append(rec)
if rec["verdict"] == "not stated":
report["not_stated"] += 1
continue
report["stated"] += 1
if rec["verdict"] == "NOT CHECKED":
report["not_checked"] += 1
if rec["verdict"] == "FAIL":
report["disagree"] += len(rec["findings"])
else:
report["agree"] += len(rec["claims"])
for f in rec["findings"]:
where = "%s/%s" % (name, f["site"]) if f["site"] else name
owner, why_not = _owner(name, f["site"], rounds, today)
if owner is None:
report["findings"].append(
"%s: %s%s" % (where, f["text"],
"" if not why_not else " [%s]" % why_not))
if why_not:
report["denied"].append("%s [%s]" % (where, why_not))
else:
owner["used"] += 1
report["owned"].append(
"%s: %s [owned by an open correction, %s, clock %s]"
% (where, f["text"], owner["issue_txt"], owner["clock"]))
for u in rec["unchecked"]:
report["unchecked"].append("%s/%s" % (name, u))
for nt in rec["notes"]:
report["notes"].append("%s/%s" % (name, nt))
for r in rounds:
if r["state"] != "open":
report["notes"].append(
"%s: %s's round is recorded %s, so it owns nothing"
% (CORRECTIONS, r["issue_txt"], r["state"]))
elif r["clock"] < today:
report["notes"].append(
"%s: %s's clock %s has passed as of %s, so it owns nothing"
% (CORRECTIONS, r["issue_txt"], r["clock"], today))
elif not r["used"]:
report["notes"].append(
"%s: the round recorded for %s (%s, clock %s) occupies no finding, so "
"the claims it named now agree - the round may be discharged and the "
"entry retired" % (CORRECTIONS, r["package"], r["issue_txt"],
r["clock"]))
if report["findings"] or report["problems"]:
report["verdict"] = "FAIL"
elif report["agree"] == 0 and not report["owned"]:
report["verdict"] = "NOT RUN"
report["reason"] = ("no claim was compared, so this gate took no reading of "
"the tree: %d package(s) read, %d state a count it could "
"not read, %d state no count at all"
% (len(names), report["stated"], report["not_stated"]))
else:
report["verdict"] = "PASS"
return report
def render(report, as_json=False):
if as_json:
print(json.dumps(report, indent=2, sort_keys=True, default=str))
return
print("stepgate_v1 - the step count a package states, read against the count "
"its script states")
print("root: %s" % report["root"])
print("as of: %s (the date every correction clock is read against)" % report["as_of"])
print("set: %d package(s) under papers/ - an issue-<N> directory is a publication"
% len(report["packages"]))
if report["rounds"]:
print("corrections: %d round(s) recorded in %s - an entry owns a finding only "
"while it is open and its clock has not passed"
% (len(report["rounds"]), CORRECTIONS))
for r in report["rounds"]:
print(" %s package %s clock %s %s sites: %s"
% (r["issue_txt"], r["package"], r["clock"], r["state"],
", ".join(r["sites"]) if r["sites"] else "the whole package"))
print("instrument: a stated count is a literal `(N steps)` in reproduce.sh / "
"README.md / manuscript.md, or the computed form "
"`VAR=$(( $(echo $LIST | wc -w) + K ))`; the script is the authority, the "
"documents are copies of what it prints")
print(" stated: %d of %d package(s) state a count (%d compared, %d not checked)"
% (report["stated"], len(report["packages"]),
report["stated"] - report["not_checked"], report["not_checked"]))
silent = []
for rec in report["packages"]:
if rec["verdict"] == "not stated":
silent.append(rec["package"])
continue
print(" %-16s %s" % (rec["package"], rec["verdict"]))
for st in rec["statements"]:
print(" the script states: %s" % st)
for c in rec["claims"]:
print(" claim: %s:%d states %d steps (%s)"
% (c[0], c[1], c[3], c[2]))
for f in rec["findings"]:
print(" FINDING: %s" % f["text"])
for u in rec["unchecked"]:
print(" NOT CHECKED: %s" % u)
for nt in rec["notes"]:
print(" note: %s" % nt)
print(" not stated (%d, named so a reader can see which, and it is not a pass): %s"
% (len(silent), ", ".join(silent) if silent else "none"))
for p in report["problems"]:
print(" FINDING: %s" % p)
for nt in report["notes"]:
print(" note: %s" % nt)
if report["denied"]:
print(" unowned (%d) - a correction round's sites denied it, because the round "
"is not open or its clock has passed:" % len(report["denied"]))
for d in report["denied"]:
print(" %s" % d)
if report["owned"]:
owners = []
for r in report["rounds"]:
if r["used"]:
owners.append("%s, clock %s" % (r["issue_txt"], r["clock"]))
print(" owned (%d) - an open correction round owns these, and they are named "
"here rather than dropped: %s" % (len(report["owned"]), "; ".join(owners)))
for o in report["owned"]:
print(" %s" % o)
if report["unchecked"]:
print(" not checked: %d claim(s) - named above, each with its reason"
% len(report["unchecked"]))
if report["verdict"] == "NOT RUN":
print("STEPGATE: NOT RUN - %s" % report["reason"])
return
print("STEPGATE: %s - %d unowned%s; %d claim(s) agree; %d not checked; "
"%d package(s) state no count"
% (report["verdict"],
len(report["findings"]) + len(report["problems"]),
"" if not report["owned"] else ", %d owned" % len(report["owned"]),
report["agree"], len(report["unchecked"]), report["not_stated"]))
def _plant(root, files):
for rel, body in files.items():
path = os.path.join(root, rel)
d = os.path.dirname(path)
if d and not os.path.isdir(d):
os.makedirs(d)
with open(path, "w", encoding="utf-8") as fh:
fh.write(body)
def _run(root, today="2026-10-25"):
report = read_tree(root, today)
import io
buf = io.StringIO()
old = sys.stdout
try:
sys.stdout = buf
render(report)
finally:
sys.stdout = old
out = buf.getvalue()
if report["verdict"] == "PASS":
return 0, out
if report["verdict"] == "FAIL":
return 1, out
return 2, out
SCRIPT_ONE_TIER = """#!/usr/bin/env bash
# Expected last line:
#
# REPRODUCE: ALL GREEN (10 steps)
#
set -u
echo "REPRODUCE: ALL GREEN (10 steps)"
"""
SCRIPT_TWO_TIERS = """#!/usr/bin/env bash
# Expected last line, for each tier:
#
# REPRODUCE: ALL GREEN (10 steps) # default
# REPRODUCE: ALL GREEN (13 steps) # --full
#
# WHAT THE STEPS ARE
#
# [01] corpus pins
# [02]-[06] the certificate suites
# [07] check_one_statistic.py
# [08] make_figures --selftest-title
# [09] canonical.py --check
# [10] make_figures --check
#
# --full adds three byte-identity steps:
# [11] the instruments re-run
# [12] canonical re-derived
# [13] figures + manuscript rebuilt
set -u
"""
SCRIPT_COMPUTED = """#!/usr/bin/env bash
# Expected last line:
#
# REPRODUCE: ALL GREEN (21 steps, 0 failures)
set -u
INSTRUMENTS="a b c d e f g h i j k l"
STEPS=0
ALL_STEPS=$(( $(echo $INSTRUMENTS | wc -w | tr -d ' ') + 9 ))
LINE="REPRODUCE: ALL GREEN ($ALL_STEPS steps, 0 failures)"
echo "REPRODUCE: $STEPS FAILURE(S) over $STEPS steps"
echo "$LINE"
"""
README_ONE = """# issue 10
```
bash reproduce.sh
```
Expected output (last line):
```
REPRODUCE: ALL GREEN (%s steps)
```
"""
README_TWO = """# issue 10
```
bash reproduce.sh
```
Expected output (last line):
```
REPRODUCE: ALL GREEN (10 steps)
```
A fuller check:
```
bash reproduce.sh --full # -> REPRODUCE: ALL GREEN (13 steps)
```
"""
def selftest():
tmp = tempfile.mkdtemp(prefix="stepgate-selftest-")
cases = []
ok = 0
try:
# the two trees the ownership cases are built on: one stale copy, one correct
STALE = {"papers/issue-10/reproduce.sh": SCRIPT_ONE_TIER,
"papers/issue-10/README.md": README_ONE % "9"}
GOOD = {"papers/issue-10/reproduce.sh": SCRIPT_ONE_TIER,
"papers/issue-10/README.md": README_ONE % "10"}
def record(*rounds):
"""A correction record, the form read_corrections() reads."""
return {".github/corrections.json":
json.dumps({"rounds": list(rounds)}, indent=2)}
def round_for(state="open", clock="2026-12-01", sites=("README.md:10",),
package="issue-10", issue=10):
r = {"issue": issue, "package": package, "clock": clock, "state": state}
if sites is not None:
r["sites"] = list(sites)
return r
plant = {
# a copy that states a stale count: the shape of issue-145 and issue-128's
# manuscript - the script says 10, the README says 9
"stale-copy": ({"papers/issue-10/reproduce.sh": SCRIPT_ONE_TIER,
"papers/issue-10/README.md": README_ONE % "9"},
"FAIL", 1, ["README.md:10", "states 9 steps", "states 10"]),
# the same package with its copy corrected: green, and the negative control
# that proves the red above came from the number and not from the plant
"copy-correct": ({"papers/issue-10/reproduce.sh": SCRIPT_ONE_TIER,
"papers/issue-10/README.md": README_ONE % "10"},
"PASS", 0, []),
# the tier half: the --full line is compared with the --full count, so a
# default count written on the --full line is a finding - issue-128's
# manuscript:414, whose `--full` line states the default tier's number
"tier-confused": ({"papers/issue-10/reproduce.sh": SCRIPT_TWO_TIERS,
"papers/issue-10/README.md": README_TWO.replace(
"(13 steps)", "(10 steps)")},
"FAIL", 1, ["README.md:16", "states 10 steps (full)",
"states 13"]),
"two-tiers-agree": ({"papers/issue-10/reproduce.sh": SCRIPT_TWO_TIERS,
"papers/issue-10/README.md": README_TWO},
"PASS", 0, []),
# the computed form: 12 words + 9 = 21, and the copy that states it agrees
"computed-agree": ({"papers/issue-10/reproduce.sh": SCRIPT_COMPUTED,
"papers/issue-10/README.md":
"```\nbash reproduce.sh\n```\n\n"
"REPRODUCE: ALL GREEN (21 steps, 0 failures)\n"},
"PASS", 0, []),
"computed-stale": ({"papers/issue-10/reproduce.sh": SCRIPT_COMPUTED,
"papers/issue-10/README.md":
"```\nbash reproduce.sh\n```\n\n"
"REPRODUCE: ALL GREEN (20 steps, 0 failures)\n"},
"FAIL", 1, ["states 20 steps", "states 21"]),
# the script's own table reaching further than the count it states
"table-runs-past-the-count": ({"papers/issue-10/reproduce.sh":
SCRIPT_TWO_TIERS.replace("(13 steps)", "(11 steps)"),
"papers/issue-10/README.md": README_TWO},
"FAIL", 1, ["step table reaches [13]",
"largest stated count is 11"]),
# a dated measurement is named and never counted as agreement
"measured": ({"papers/issue-10/reproduce.sh": SCRIPT_ONE_TIER,
"papers/issue-10/README.md": README_ONE % "10" +
"\nMeasured 2026-10-20: 28 steps green.\n"},
"PASS", 0, ["NOT CHECKED", "28 steps", "not checked: 1"]),
# a package whose only statement is a measurement of a run: the reading is
# named, the package is NOT CHECKED, and with no claim compared at all the
# tree verdict is NOT RUN - the shape issue-141 would have if it were alone
"measured-only": ({"papers/issue-10/reproduce.sh":
"#!/usr/bin/env bash\nset -u\necho REPRODUCE: ALL GREEN\n",
"papers/issue-10/README.md":
"Measured 2026-10-20: 28 steps green.\n"},
"NOT RUN", 2, ["NOT CHECKED", "28 steps",
"no claim was compared"]),
# a package that states nothing is `not stated`, never a pass - and a tree
# in which nothing is stated is this gate's NOT RUN, not its PASS
"silent": ({"papers/issue-10/reproduce.sh":
"#!/usr/bin/env bash\nset -u\necho REPRODUCE: ALL GREEN\n"},
"NOT RUN", 2, ["NOT RUN", "not stated (1"]),
# the two-counts note: two variables feed one program's verdict lines, the
# issue-126 shape - reported, and deliberately not a finding
"two-counts-noted": ({"papers/issue-10/reproduce.sh": SCRIPT_COMPUTED,
"papers/issue-10/README.md":
"```\nbash reproduce.sh\n```\n\n"
"REPRODUCE: ALL GREEN (21 steps, 0 failures)\n"},
"PASS", 0, ["note: two counts", "$ALL_STEPS",
"$STEPS"]),
# --- the owned/unowned split (R631) ---------------------------------------
# a disagreement no round owns is red, and a record naming another package
# does not acquire this one: ownership is the round's, never a side effect
# of somebody having written a record
"unowned-is-red": ({**STALE, **record(round_for(issue=11,
package="issue-11"))},
"FAIL", 1, ["1 unowned", "FINDING",
"README.md:10 states 9 steps"]),
# the owner is named, the clock is printed, and the finding is STILL named
# - the owned line is a reading, not a deletion
"owned-open-green": ({**STALE, **record(round_for())},
"PASS", 0, ["0 unowned, 1 owned", "owned (1)",
"issue #10", "clock 2026-12-01",
"README.md:10 states 9 steps"]),
# the first expiry: the issue closed, so the round owns nothing again
"owned-closed-red": ({**STALE, **record(round_for(state="closed"))},
"FAIL", 1, ["1 unowned", "is closed",
"owns nothing"]),
# the second expiry: nobody had to remember anything - the clock ran out
"owned-expired-red": ({**STALE, **record(round_for(clock="2026-01-01"))},
"FAIL", 1, ["1 unowned",
"clock 2026-01-01 has passed",
"as of 2026-10-25"]),
# the sites narrow the ownership: a defect at a site the round did not
# name is unowned even inside the package the round governs
"owned-site-narrow": ({**STALE, **record(round_for(sites=["README.md:99"]))},
"FAIL", 1, ["1 unowned"]),
# a round whose claims now agree is visible rather than silently stale
"owned-discharged": ({**GOOD, **record(round_for())},
"PASS", 0, ["STEPGATE: PASS",
"occupies no finding"]),
# a record that cannot be read excuses nothing, and says so out loud
"record-unreadable": ({**STALE, ".github/corrections.json": "{not json"},
"FAIL", 1, ["could not be read"]),
}
for label, (files, want, want_code, members) in plant.items():
root = os.path.join(tmp, label)
_plant(root, files)
code, out = _run(root)
m = re.search(r"^STEPGATE: (PASS|FAIL|NOT RUN)", out, re.M)
verdict = m.group(1) if m else "NO VERDICT LINE"
good = verdict == want and code == want_code
missing = [s for s in members if s not in out]
if missing:
good = False
cases.append((label, good, "%s/exit %d (want %s/exit %d)%s"
% (verdict, code, want, want_code,
"" if not missing else " missing: %s" % ", ".join(missing))))
ok += 1 if good else 0
finally:
shutil.rmtree(tmp, ignore_errors=True)
for label, good, detail in cases:
print(" [%s] %-28s %s" % ("ok" if good else "FAIL", label, detail))
print("selftest: %d/%d cases ok" % (ok, len(cases)))
return 0 if ok == len(cases) else 1
def main(argv):
if "--selftest" in argv:
return selftest()
unknown = [a for a in argv
if a.startswith("-") and a not in KNOWN_OPTIONS]
if unknown:
print("stepgate_v1 - the stated step count read against the script that "
"states it (tree-wide: it reads every package under papers/)")
print("NOT RUN: %s is not an option of this gate, so no window was read and "
"no verdict is taken.\n This gate takes no path; its options "
"are %s." % (unknown[0], ", ".join(KNOWN_OPTIONS)))
print()
print("usage: stepgate.py --check | --root DIR [--json] | --selftest")
return 2
as_json = "--json" in argv
root = os.getcwd()
for i, a in enumerate(argv):
if a == "--root" and i + 1 < len(argv):
root = os.path.abspath(argv[i + 1])
if not os.path.isdir(root):
print("stepgate: %s is not a directory" % root, file=sys.stderr)
return 2
report = read_tree(root)
render(report, as_json)
if report["verdict"] == "PASS":
return 0
if report["verdict"] == "FAIL":
return 1
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))