Python Part II · Module 31 · Generating RTL and Verification Artifacts
Keeping Generated Files Honest
Every chapter in this module has emitted a file with a do-not-edit header, and Chapter 31.6 measured what that header is worth: an edit sat in a generated test until the next regeneration quietly removed it, with the header present throughout. This chapter closes the module by replacing the request with a mechanism — regenerate the artefact and compare — and then spends most of its length on the four kinds of well-intentioned metadata that make that comparison useless, the worst of which is a timestamp.
Advanced21 min readPythonPart IIAutomationCode GenerationValidation
Part II · Module 31 · Chapter 31.7 · Keeping Generated Files Honest
1. What the Header Can and Cannot Do
CLAIMS = [
("tells a reader not to edit", "yes", "a human reads it"),
("names the generator", "yes", "so it can be re-run"),
("names the source", "yes", "so the real input can be found"),
("prevents an edit", "no", "nothing enforces it"),
("detects an edit", "no", "the header is unchanged by an edit below it"),
]
print(f"{'what a header does':<32} {'does it?':<10} why")
for what, does, why in CLAIMS:
print(f"{what:<32} {does:<10} {why}")
print()
print("things it does :", sum(1 for c in CLAIMS if c[1] == "yes"))
print("things it does not:", sum(1 for c in CLAIMS if c[1] == "no"))
print("and the two it cannot do are the two that matter")what a header does does it? why
tells a reader not to edit yes a human reads it
names the generator yes so it can be re-run
names the source yes so the real input can be found
prevents an edit no nothing enforces it
detects an edit no the header is unchanged by an edit below it
things it does : 3
things it does not: 2
and the two it cannot do are the two that matterThree useful things and two it cannot do. Chapter 31.6 Section 5 demonstrated the consequence: an edit lived in a generated test and then vanished, and the header was correct and present at every moment.
2. The Annotation
print("// GENERATED by gen_regs.py from regs.yaml - do not edit", file=f)from pathlib import Path
HDR = "// GENERATED by gen_regs.py from regs.yaml - do not edit"
BODY = "module regs;\n localparam ADDR_CTRL = 8'h00;\nendmodule\n"
Path("regs.v").write_text(HDR + "\n" + BODY, encoding="utf-8")
print(Path("regs.v").read_text(encoding="utf-8"), end="")
edited = Path("regs.v").read_text(encoding="utf-8").replace("8'h00", "8'h04")
Path("regs.v").write_text(edited, encoding="utf-8")
print()
print("after a hand edit:")
print(Path("regs.v").read_text(encoding="utf-8"), end="")
print()
print("header still present:", Path("regs.v").read_text(encoding="utf-8")
.startswith("// GENERATED"))
print("header noticed the edit:", False)// GENERATED by gen_regs.py from regs.yaml - do not edit
module regs;
localparam ADDR_CTRL = 8'h00;
endmodule
after a hand edit:
// GENERATED by gen_regs.py from regs.yaml - do not edit
module regs;
localparam ADDR_CTRL = 8'h04;
endmodule
header still present: True
header noticed the edit: FalseThe offset changed from 0x00 to 0x04 and the header is word-for-word intact. It is still worth emitting — the three things it does do are all things a reader needs — but it is not a check.
3. The Mechanism: Regenerate and Compare
The only thing that can detect an edit is the generator itself:
import hashlib
from pathlib import Path
def generate(offset):
return ("// GENERATED by gen_regs.py from regs.yaml - do not edit\n"
"module regs;\n"
f" localparam ADDR_CTRL = 8'h{offset:02X};\n"
"endmodule\n")
SPEC_OFFSET = 0x00
Path("regs.v").write_text(generate(SPEC_OFFSET), encoding="utf-8")
def check(path, expected_text):
on_disk = Path(path).read_text(encoding="utf-8")
return {
"match": on_disk == expected_text,
"disk_sha": hashlib.sha256(on_disk.encode()).hexdigest()[:12],
"fresh_sha": hashlib.sha256(expected_text.encode()).hexdigest()[:12],
}
r = check("regs.v", generate(SPEC_OFFSET))
print("as generated :", r["match"], r["disk_sha"], r["fresh_sha"])
text = Path("regs.v").read_text(encoding="utf-8")
Path("regs.v").write_text(text.replace("8'h00", "8'h04"), encoding="utf-8")
r = check("regs.v", generate(SPEC_OFFSET))
print("after a hand edit:", r["match"], r["disk_sha"], r["fresh_sha"])
print()
print("the check detects the edit:", not r["match"])
print("it needs nothing but the generator and the spec.")as generated : True 673a582b53eb 673a582b53eb
after a hand edit: False f7fc3ac81201 673a582b53eb
the check detects the edit: True
it needs nothing but the generator and the spec.Regenerating and comparing catches the edit, and that is the whole mechanism: a generated file is correct exactly when it equals what the generator would produce from the current spec.
It also catches three other things for free — a spec change nobody regenerated for, a generator change nobody re-ran, and a merge that resolved a conflict inside a generated file. All four are the same condition: the artefact and its inputs disagree.
Which makes the check extremely valuable and extremely fragile, because it is a byte comparison, and the next four sections are about what breaks it.
4. A Timestamp Destroys the Check
from pathlib import Path
def generate(offset, stamp):
return (f"// GENERATED by gen_regs.py from regs.yaml at {stamp} - do not edit\n"
"module regs;\n"
f" localparam ADDR_CTRL = 8'h{offset:02X};\n"
"endmodule\n")
first = generate(0x00, "2026-02-18T22:05:00Z")
second = generate(0x00, "2026-02-18T22:07:13Z")
print("spec changed between the two runs:", False)
print("files identical :", first == second)
print()
import difflib
for line in difflib.unified_diff(first.splitlines(), second.splitlines(),
"committed", "regenerated", lineterm="", n=0):
print(line)
print()
print("lines differing:", 1)
print("lines that matter:", 0)spec changed between the two runs: False
files identical : False
--- committed
+++ regenerated
@@ -1 +1 @@
-// GENERATED by gen_regs.py from regs.yaml at 2026-02-18T22:05:00Z - do not edit
+// GENERATED by gen_regs.py from regs.yaml at 2026-02-18T22:07:13Z - do not edit
lines differing: 1
lines that matter: 0The two files differ and nothing changed. With a timestamp in the header, regenerate-and-diff reports a difference on every run, forever.
The consequence is not that the check gives a wrong answer — it is that the check gets turned off:
DAYS = [
(1, "the check fires", "somebody investigates, finds the timestamp"),
(2, "the check fires", "somebody adds --ignore-matching-lines=GENERATED"),
(9, "the check fires", "somebody marks it non-blocking"),
(30, "the check is skipped", "nobody remembers why"),
(90, "a real edit lands", "nothing notices"),
]
print(f"{'day':>4} {'state':<22} what happens")
for day, state, what in DAYS:
print(f"{day:>4} {state:<22} {what}")
print()
print("the artefact is protected on days:", "1 to 8")
print("the defence was defeated by:", "a field added to make the file more informative") day state what happens
1 the check fires somebody investigates, finds the timestamp
2 the check fires somebody adds --ignore-matching-lines=GENERATED
9 the check fires somebody marks it non-blocking
30 the check is skipped nobody remembers why
90 a real edit lands nothing notices
the artefact is protected on days: 1 to 8
the defence was defeated by: a field added to make the file more informativeThis is Chapter 24.5's switched-off checker and Chapter 29.6's 37.5% false-positive rate arriving for the third time: a check that fires when nothing is wrong does not survive contact with a team. The timestamp was added by somebody trying to make the artefact more traceable, which is why it is worth naming — the damage came from a good intention and a field nobody thought of as load-bearing.
5. Record the Hash, Not the Clock
The traceability the timestamp was reaching for is available without the clock:
import hashlib
from pathlib import Path
SPEC = "ctrl,0x00,32,rw,0\nmode,0x04,32,rw,1\n"
Path("regs.csv").write_text(SPEC, encoding="utf-8")
def spec_hash(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()[:16]
def generate(path):
rows = [l.split(",") for l in
Path(path).read_text(encoding="utf-8").strip().splitlines()]
L = ["// GENERATED by gen_regs.py — do not edit",
f"// source: regs.csv sha256: {spec_hash(path)}",
"// regenerate: python3 gen_regs.py regs.csv > regs.v",
"module regs;"]
for name, off, width, access, reset in rows:
L.append(f" localparam [7:0] ADDR_{name.upper()} = 8'h{int(off, 16):02X};")
L.append("endmodule")
return "\n".join(L) + "\n"
a = generate("regs.csv")
b = generate("regs.csv")
print("two runs, same spec, identical:", a == b)
print()
print(a, end="")
Path("regs.csv").write_text(SPEC + "status,0x08,32,ro,0\n", encoding="utf-8")
c = generate("regs.csv")
print()
print("after adding a register to the spec:")
print(" identical to before:", c == a)
print(" hash line changed :",
a.splitlines()[1] != c.splitlines()[1])
print(" new hash :", c.splitlines()[1].split("sha256: ")[1])two runs, same spec, identical: True
// GENERATED by gen_regs.py — do not edit
// source: regs.csv sha256: 760278dced3654e5
// regenerate: python3 gen_regs.py regs.csv > regs.v
module regs;
localparam [7:0] ADDR_CTRL = 8'h00;
localparam [7:0] ADDR_MODE = 8'h04;
endmodule
after adding a register to the spec:
identical to before: False
hash line changed : True
new hash : b393b57ec2eec7ccThree header lines that carry more information than the timestamp did, and two runs over an unchanged spec produce identical bytes.
The hash is strictly better than a date for the purpose a date was serving. A timestamp answers "when was this written", which nobody actually needs; the hash answers "which version of the input is this file built from", which is the question somebody asks when a generated file looks wrong. And it changes exactly when the input changes — the new register moved it, and the two earlier runs did not.
The third line is the one people omit and the one a reader at 2 a.m. needs: the command that regenerates the file. Chapter 23.10's paste-and-re-run argument, applied to a generator.
6. Three More Sources of Spurious Diff
Ordering is the subtlest of them, because the generated file looks fine:
SPEC_A = {"ctrl": 0x00, "mode": 0x04, "status": 0x08}
SPEC_B = {"status": 0x08, "ctrl": 0x00, "mode": 0x04}
def emit(spec, sort=False):
items = sorted(spec.items()) if sort else list(spec.items())
return "\n".join(f" localparam ADDR_{n.upper()} = 8'h{o:02X};"
for n, o in items) + "\n"
print("same three registers, two insertion orders:")
print("--- unsorted, spec A")
print(emit(SPEC_A), end="")
print("--- unsorted, spec B")
print(emit(SPEC_B), end="")
print()
print("identical contents:", set(SPEC_A.items()) == set(SPEC_B.items()))
print("identical output :", emit(SPEC_A) == emit(SPEC_B))
print()
print("--- sorted, both")
print(emit(SPEC_A, sort=True), end="")
print("identical output when sorted:", emit(SPEC_A, True) == emit(SPEC_B, True))same three registers, two insertion orders:
--- unsorted, spec A
localparam ADDR_CTRL = 8'h00;
localparam ADDR_MODE = 8'h04;
localparam ADDR_STATUS = 8'h08;
--- unsorted, spec B
localparam ADDR_STATUS = 8'h08;
localparam ADDR_CTRL = 8'h00;
localparam ADDR_MODE = 8'h04;
identical contents: True
identical output : False
--- sorted, both
localparam ADDR_CTRL = 8'h00;
localparam ADDR_MODE = 8'h04;
localparam ADDR_STATUS = 8'h08;
identical output when sorted: TrueIdentical contents, different bytes. A dict preserves insertion order, so a spec reordered by a merge, or assembled by two code paths, emits a different file describing the same hardware.
Note what the sort key has to be. Chapter 31.2 Section 4 argued that a filelist must not be sorted, because its order is semantic; here the order of localparam declarations carries no meaning, so sorting is free. The rule is not "always sort" — it is "make the order a stated function of the spec", which for a filelist means first-seen and for a register block means sorted by name or by offset.
Python's set iteration is worse than a dict's and in a way that is invisible locally: it varies between processes under hash randomisation, so a generator that iterates a set of names can emit a different file on the same machine from the same spec on the next invocation. That is stated here rather than demonstrated, because a worked example whose output changed between runs would be exactly the kind of non-deterministic artefact this chapter argues against.
The remaining two are simpler:
from pathlib import Path
FIELDS = [
("timestamp", "2026-02-18T22:05:00Z", "changes every run"),
("absolute source path", "/home/alice/proj/regs.csv", "changes per checkout"),
("generator version", "gen_regs.py 1.4.2", "changes per machine"),
("hostname", "build07", "changes per machine"),
("spec sha256", "ba7a9b40bd0e9f1a", "changes only when the spec does"),
("relative source path", "spec/regs.csv", "stable across checkouts"),
("regenerate command", "python3 gen_regs.py spec/regs.csv", "stable"),
]
print(f"{'header field':<24} {'example':<34} verdict")
for name, example, why in FIELDS:
ok = "changes only" in why or "stable" in why
print(f"{name:<24} {example:<34} {'keep' if ok else 'OMIT'} ({why})")
print()
print("fields safe in the file:", sum(1 for f in FIELDS
if "changes only" in f[2] or "stable" in f[2]))
print("fields that break the check:", sum(1 for f in FIELDS
if not ("changes only" in f[2] or "stable" in f[2])))header field example verdict
timestamp 2026-02-18T22:05:00Z OMIT (changes every run)
absolute source path /home/alice/proj/regs.csv OMIT (changes per checkout)
generator version gen_regs.py 1.4.2 OMIT (changes per machine)
hostname build07 OMIT (changes per machine)
spec sha256 ba7a9b40bd0e9f1a keep (changes only when the spec does)
relative source path spec/regs.csv keep (stable across checkouts)
regenerate command python3 gen_regs.py spec/regs.csv keep (stable)
fields safe in the file: 3
fields that break the check: 4Four fields to omit and three to keep. The generator version is the painful one, because recording it is genuinely useful — a file produced by last year's generator is worth knowing about — and it has to live somewhere other than the compared artefact: a build log, a manifest that is not diffed, or a separate generated.json that the check ignores deliberately.
Chapter 31.2 Section 5 argued for absolute paths in a filelist, and this is the opposite conclusion for a header, for a reason worth keeping straight: a filelist is consumed by a tool from an arbitrary directory, and a header is compared byte-for-byte across checkouts. Same project, two artefacts, opposite rules.
7. The Gate, Assembled — and Proven Able to Fail
import hashlib
import shutil
import subprocess
from pathlib import Path
SPEC = "ctrl,0x00,32,rw,0\nmode,0x04,32,rw,1\nstatus,0x08,32,ro,0\n"
Path("spec").mkdir(exist_ok=True)
Path("spec/regs.csv").write_text(SPEC, encoding="utf-8")
GEN_DIR = Path("gen")
GEN_DIR.mkdir(exist_ok=True)
def spec_hash(p):
return hashlib.sha256(Path(p).read_bytes()).hexdigest()[:16]
def emit_rtl(spec_path):
rows = sorted(l.split(",") for l in
Path(spec_path).read_text(encoding="utf-8").strip().splitlines())
L = ["// GENERATED by gen_regs.py — do not edit",
f"// source: {spec_path} sha256: {spec_hash(spec_path)}",
f"// regenerate: python3 gen_regs.py {spec_path}",
"module regs(input [7:0] addr, output [31:0] rdata);"]
for name, off, width, access, reset in rows:
L.append(f" localparam [7:0] ADDR_{name.upper()} = 8'h{int(off, 16):02X};")
L.append(" assign rdata = 32'd0;")
L.append("endmodule")
return "\n".join(L) + "\n"
def emit_header(spec_path):
rows = sorted(l.split(",") for l in
Path(spec_path).read_text(encoding="utf-8").strip().splitlines())
L = ["/* GENERATED by gen_regs.py - do not edit */",
f"/* source: {spec_path} sha256: {spec_hash(spec_path)} */"]
for name, off, *_ in rows:
L.append(f"#define ADDR_{name.upper()} 0x{int(off, 16):02X}u")
return "\n".join(L) + "\n"
EMITTERS = {"gen/regs.v": emit_rtl, "gen/regs.h": emit_header}
def generate(spec_path):
for path, fn in EMITTERS.items():
Path(path).write_text(fn(spec_path), encoding="utf-8")
def gate(spec_path):
"""Regenerate into a scratch dir and compare, byte for byte."""
tmp = Path("gen_check")
if tmp.exists():
shutil.rmtree(tmp)
tmp.mkdir()
differing, missing = [], []
for path, fn in EMITTERS.items():
fresh = fn(spec_path)
committed = Path(path)
if not committed.exists():
missing.append(path)
continue
if committed.read_text(encoding="utf-8") != fresh:
differing.append(path)
return {"ok": not differing and not missing,
"differing": differing, "missing": missing}
generate("spec/regs.csv")
print("after generating:", gate("spec/regs.csv"))
c = subprocess.run(["iverilog", "-g2005", "-o", "g.vvp", "gen/regs.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
print("generated rtl compiles:", c.returncode == 0)
print()
print("--- now prove the gate can fail, one way per failure mode")
text = Path("gen/regs.v").read_text(encoding="utf-8")
Path("gen/regs.v").write_text(text.replace("8'h04", "8'h44"), encoding="utf-8")
print("hand edit :", gate("spec/regs.csv"))
generate("spec/regs.csv")
Path("spec/regs.csv").write_text(SPEC + "extra,0x0C,32,rw,0\n", encoding="utf-8")
print("spec changed :", gate("spec/regs.csv"))
generate("spec/regs.csv")
Path("gen/regs.h").unlink()
print("artefact missing :", gate("spec/regs.csv"))
generate("spec/regs.csv")
print()
print("clean again :", gate("spec/regs.csv"))
print()
print("failure modes the gate detects:", 3)
print("and it passes a correct tree :", gate("spec/regs.csv")["ok"])after generating: {'ok': True, 'differing': [], 'missing': []}
generated rtl compiles: True
--- now prove the gate can fail, one way per failure mode
hand edit : {'ok': False, 'differing': ['gen/regs.v'], 'missing': []}
spec changed : {'ok': False, 'differing': ['gen/regs.v', 'gen/regs.h'], 'missing': []}
artefact missing : {'ok': False, 'differing': [], 'missing': ['gen/regs.h']}
clean again : {'ok': True, 'differing': [], 'missing': []}
failure modes the gate detects: 3
and it passes a correct tree : TrueThree failure modes, each detected, and a clean tree passing. That last pairing is the standing rule from Chapter 30.7 Section 7: a check that has never rejected anything is a check with no evidence behind it, so the gate's own test suite breaks the tree one way per failure mode and requires a rejection each time.
Read the spec changed row carefully, because it is the most useful of the three: both artefacts differ, because both are derived from the spec. A row where only one artefact differed would mean something else entirely — one emitter changed, or one file was edited — and the list of differing paths is what distinguishes them.
8. Where the Gate Runs
PLACES = [
("in the generator, after writing", "catches nothing",
"it just wrote the file"),
("in the build, before compiling", "catches a stale artefact",
"fails the build on an edit"),
("in CI, on every merge request", "catches a committed edit",
"the one that protects the repository"),
("in a pre-commit hook", "catches it earliest",
"bypassable, and bypassed"),
]
print(f"{'where':<34} {'what it catches':<26} note")
for where, what, note in PLACES:
print(f"{where:<34} {what:<26} {note}")
print()
print("places worth having:", 2)
print("the minimum :", "CI, because it cannot be skipped locally")where what it catches note
in the generator, after writing catches nothing it just wrote the file
in the build, before compiling catches a stale artefact fails the build on an edit
in CI, on every merge request catches a committed edit the one that protects the repository
in a pre-commit hook catches it earliest bypassable, and bypassed
places worth having: 2
the minimum : CI, because it cannot be skipped locallyThe first row is worth stating because it is a mistake people make: a generator that verifies its own output immediately after writing it has verified nothing, since the comparison is between two things it just produced. The check has to run against an artefact somebody else produced — a previous commit, a different machine, a colleague's edit.
And the alternative to the whole arrangement deserves one line: if the generated files are not committed at all — Chapter 31.6 Section 5's build-directory option — then there is nothing to drift and no gate to run. That is the better answer wherever the build can regenerate them, and the gate exists for the cases where it cannot: artefacts a firmware team consumes from the repository, or files a vendor flow expects to find on disk.
9. Line Endings Defeat a Byte Comparison
The gate compares bytes, and there is one way for bytes to change that has nothing to do with the generator, the spec or an edit.
from pathlib import Path
BODY = "// GENERATED — do not edit\nmodule regs;\nendmodule\n"
Path("unix.v").write_bytes(BODY.encode("utf-8"))
Path("dos.v").write_bytes(BODY.replace("\n", "\r\n").encode("utf-8"))
u = Path("unix.v").read_bytes()
d = Path("dos.v").read_bytes()
print("unix bytes:", len(u))
print("dos bytes:", len(d))
print("identical as bytes:", u == d)
print()
print("identical after splitlines():",
u.decode().splitlines() == d.decode().splitlines())
print("identical as text with universal newlines:",
Path("unix.v").read_text(encoding="utf-8")
== Path("dos.v").read_text(encoding="utf-8"))unix bytes: 52
dos bytes: 55
identical as bytes: False
identical after splitlines(): True
identical as text with universal newlines: TrueThree bytes different, three lines, no content change. A checkout on a machine configured to translate line endings, or an editor that "fixed" the file on save, produces an artefact that is semantically identical and byte-different — and the gate from Section 7 rejects it.
The three interesting rows are the second and third. read_text with universal newline translation reports them equal, and so does comparing splitlines(), which means the fix is to decide what the comparison is over rather than to abandon it:
from pathlib import Path
BODY = "// GENERATED — do not edit\nmodule regs;\nendmodule\n"
Path("a.v").write_bytes(BODY.encode("utf-8"))
Path("b.v").write_bytes(BODY.replace("\n", "\r\n").encode("utf-8"))
Path("c.v").write_bytes(BODY.replace("endmodule", "endmodule ").encode("utf-8"))
Path("d.v").write_bytes(BODY.replace("regs", "regz").encode("utf-8"))
def compare(p, fresh_text, *, strict_bytes=False):
if strict_bytes:
return Path(p).read_bytes() == fresh_text.encode("utf-8")
disk = Path(p).read_text(encoding="utf-8").splitlines()
fresh = fresh_text.splitlines()
return [l.rstrip() for l in disk] == [l.rstrip() for l in fresh]
CASES = [("identical", "a.v"), ("crlf line endings", "b.v"),
("trailing whitespace", "c.v"), ("a real content change", "d.v")]
print(f"{'case':<24} {'byte-exact':<12} normalised")
for label, p in CASES:
print(f"{label:<24} {str(compare(p, BODY, strict_bytes=True)):<12} "
f"{compare(p, BODY)}")
print()
print("cases the byte comparison rejects :", 3)
print("cases the normalised one rejects :", 1)
print("and the one it rejects is the one that matters.")case byte-exact normalised
identical True True
crlf line endings False True
trailing whitespace False True
a real content change False False
cases the byte comparison rejects : 3
cases the normalised one rejects : 1
and the one it rejects is the one that matters.The byte comparison rejects three of four cases and the normalised one rejects exactly the case that matters. Comparing line lists with trailing whitespace stripped keeps the check sensitive to content and blind to the two transformations that a checkout, an editor or a merge tool applies on its own.
That is a deliberate weakening of the gate, so it is worth being explicit about what it gives up:
GIVES_UP = [
("trailing whitespace changes", "invisible in every editor", "acceptable"),
("line-ending changes", "invisible, and tool-inflicted", "acceptable"),
("a changed indent", "still caught — leading space is kept", "kept"),
("a changed blank line count", "still caught — splitlines keeps them", "kept"),
("a one-character identifier change", "still caught", "kept"),
]
print(f"{'what normalising hides':<36} {'why':<40} verdict")
for what, why, verdict in GIVES_UP:
print(f"{what:<36} {why:<40} {verdict}")
print()
print("hidden:", sum(1 for g in GIVES_UP if g[2] == "acceptable"))
print("kept :", sum(1 for g in GIVES_UP if g[2] == "kept"))what normalising hides why verdict
trailing whitespace changes invisible in every editor acceptable
line-ending changes invisible, and tool-inflicted acceptable
a changed indent still caught — leading space is kept kept
a changed blank line count still caught — splitlines keeps them kept
a one-character identifier change still caught kept
hidden: 2
kept : 3Two things hidden and three kept, and the two hidden ones are precisely the two no human can see in an editor. rstrip() rather than strip() is the load-bearing detail: stripping both ends would hide an indentation change, which in generated Verilog is cosmetic but in generated Python or YAML would be a semantic change.
The alternative is to prevent the transformation rather than tolerate it — a .gitattributes entry pinning the generated files to LF, and an editor configuration that does not trim. That is the better answer where it is enforceable, and the normalised comparison is what makes the gate survive the day somebody checks out the repository on a machine nobody configured.
10. Predict, Run, Observe
import hashlib
A = "// generated at 10:00\nbody\n"
B = "// generated at 10:07\nbody\n"
# A
print("A:", A == B)
# B
print("B:", hashlib.sha256(b"spec-v1").hexdigest()[:8],
hashlib.sha256(b"spec-v1").hexdigest()[:8])
# C
d1 = {"a": 1, "b": 2}
d2 = {"b": 2, "a": 1}
print("C:", list(d1) == list(d2), sorted(d1) == sorted(d2))
# D
print("D:", "the artefact and its inputs disagree")The verified output:
A: False
B: ffcdb813 ffcdb813
C: False True
D: the artefact and its inputs disagreeA — two files differing only by a clock. The check fires and nothing is wrong.
B — the same input hashes the same twice, which is the property a timestamp lacks.
C — two dicts with identical contents iterate differently and sort identically.
D — the single condition all four of the gate's findings reduce to.
11. Exercises
Exercise 1 — what your header does. List the three things your do-not-edit header achieves and the two it cannot.
Exercise 2 — edit under the header. Edit a generated file and confirm the header is unchanged.
Exercise 3 — regenerate and compare. Implement the check for one generated file in your project.
Exercise 4 — four conditions, one check. Name the four situations the comparison detects.
Exercise 5 — find a timestamp. Search your generated files for a value that changes every run.
Exercise 6 — two runs, same spec. Generate twice without changing anything and diff the results.
Exercise 7 — the thirty-day story. Describe what happened to the last always-failing check in your flow.
Exercise 8 — the one question. Apply "would this value differ if I regenerated right now?" to every field in your header.
Exercise 9 — hash the spec. Add a spec hash to your header and confirm two runs agree.
Exercise 10 — the hash changes when it should. Modify the spec and confirm only the hash line and the affected content move.
Exercise 11 — when versus which. Explain why "which input version" is more useful than "when was this written".
Exercise 12 — the regenerate command. Add the command line to your header and test that pasting it reproduces the file.
Exercise 13 — reorder a spec. Emit from two insertion orders of the same data and diff.
Exercise 14 — stated order. Decide the ordering rule for each artefact you generate, and say why it differs from a filelist's.
Exercise 15 — a set in a generator. Find a generator that iterates a set, and say what could change between runs.
Exercise 16 — absolute versus relative. Explain why a filelist wants absolute paths and a header wants relative ones.
Exercise 17 — the generator version. Decide where to record it so that it is available and not compared.
Exercise 18 — a manifest that is not diffed. Design the file that holds the unstable metadata.
Exercise 19 — prove the gate fails. Break your tree three ways and confirm a rejection each time.
Exercise 20 — and passes. Confirm the gate accepts a clean tree, and say why that test matters as much.
Exercise 21 — one artefact or all. Explain what it means when only one of two derived artefacts differs.
Exercise 22 — the useless placement. Say why checking immediately after writing catches nothing.
Exercise 23 — where yours runs. Decide where your gate runs and what it cannot catch there.
Exercise 24 — do not commit them. For one generated artefact, decide whether it should be in the repository at all, and justify the answer.
12. Summary
- A header does three useful things and cannot do the two that matter: it neither prevents nor detects an edit, as Chapter 31.6 Section 5 measured.
- Regenerate and compare is the mechanism. A generated file is correct exactly when it equals what the generator would now produce.
- One comparison detects four conditions — a hand edit, an unregenerated spec change, an un-rerun generator change, and a conflict resolved inside a generated file — all of which are the artefact disagreeing with its inputs.
- The check is a byte comparison and therefore fragile, which is the whole difficulty.
- A timestamp destroys it. Two runs over an unchanged spec differ by one line, the check fires every time, and within a month it is skipped.
- The defence was defeated by a field added to make the file more traceable, which is why the failure is worth naming rather than mocking.
- Ask of every header field: would this differ if I regenerated right now? Timestamps, build numbers, hostnames, usernames and absolute paths all answer yes.
- Record the spec's hash instead of the clock. It answers the better question — which input version is this built from — and moves only when the input moves.
- Include the regenerate command, which is the line a reader needs at 2 a.m.
- Unordered iteration emits different bytes for identical data. Make the order a stated function of the spec: sorted for a register block, first-seen for a filelist — the rule is not "always sort".
setiteration varies between processes, so a generator that iterates a set is non-deterministic in a way local testing will not show.- A filelist wants absolute paths and a header wants relative ones, because one is consumed from an arbitrary directory and the other is compared across checkouts.
- Record the generator version somewhere that is not compared — a build log or an undiffed manifest.
- Prove the gate can fail, one broken tree per failure mode, and prove it passes a clean one.
- Checking immediately after writing catches nothing. The gate must run against an artefact somebody else produced — in the build, and in CI.
- Not committing the generated files is the better answer where the build can regenerate them; the gate is for artefacts other teams consume from the repository.
- Line endings defeat a byte comparison. CRLF and trailing whitespace change the bytes and not the content, and the gate rejects both.
- Compare line lists with trailing whitespace stripped: the byte comparison rejected three of four cases and the normalised one rejected exactly the case that mattered.
rstrip()notstrip()— stripping both ends would hide an indentation change, which is cosmetic in Verilog and semantic in Python or YAML.- Two things hidden, three kept, and the two hidden are precisely the two no human can see in an editor.
- Better still, prevent the transformation with a
.gitattributesentry pinning the generated files to LF; the normalised comparison is what makes the gate survive an unconfigured machine.
Module 32 moves from generating a flow's inputs to the seams inside the flow itself — where Python hands off to Tcl and Bash, and where a failure stops propagating.
Where this fits
Part of the Python curriculum.
