Compare commits
27 Commits
burn/20260
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f16e19b3ea | |||
| bfa557edc4 | |||
| 0b5b33a41b | |||
| 7a57b1a4b0 | |||
|
|
124a1e855d | ||
|
|
689f6f7776 | ||
| c66c0e05a1 | |||
| 1a3927a99b | |||
| f3337550ff | |||
| 4f127d4bf0 | |||
| 9b0c48aec0 | |||
| e2a993c8fb | |||
| d666f9a6b1 | |||
| c5ecd32f5e | |||
| 76da5ddf2f | |||
| 1a64788b87 | |||
| 875d42741c | |||
| 1025529f84 | |||
| abe99063c1 | |||
| c04b59f21a | |||
| aea0e40298 | |||
| ba674e7a99 | |||
|
|
e8872f2343 | ||
| b79b18de79 | |||
|
|
75075ee900 | ||
|
|
97820956c7 | ||
|
|
8f6fd90777 |
63
.gitea/workflows/build.yml
Normal file
63
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Build Verification
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
verify-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Verify chapter count and structure
|
||||
run: |
|
||||
echo "=== Chapter File Check ==="
|
||||
CHAPTER_COUNT=$(ls chapters/chapter-*.md 2>/dev/null | wc -l)
|
||||
echo "Found $CHAPTER_COUNT chapter files"
|
||||
if [ "$CHAPTER_COUNT" -ne 18 ]; then
|
||||
echo "FAIL: Expected 18 chapters, found $CHAPTER_COUNT"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: 18 chapters found"
|
||||
|
||||
- name: Verify heading format
|
||||
run: |
|
||||
echo "=== Heading Format Check ==="
|
||||
FAIL=0
|
||||
for f in chapters/chapter-*.md; do
|
||||
HEAD=$(head -1 "$f")
|
||||
if ! echo "$HEAD" | grep -qE '^# Chapter [0-9]+ — .+'; then
|
||||
echo "FAIL: $f — bad heading: $HEAD"
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
if [ "$FAIL" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: All headings valid"
|
||||
|
||||
- name: Run full build verification
|
||||
run: python3 build/build.py --md
|
||||
|
||||
- name: Verify concatenation produces valid output
|
||||
run: |
|
||||
echo "=== Output Verification ==="
|
||||
if [ ! -f testament-complete.md ]; then
|
||||
echo "FAIL: testament-complete.md not generated"
|
||||
exit 1
|
||||
fi
|
||||
WORDS=$(wc -w < testament-complete.md)
|
||||
echo "Total words: $WORDS"
|
||||
if [ "$WORDS" -lt 50000 ]; then
|
||||
echo "FAIL: Word count too low ($WORDS), expected 50000+"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Output file looks good"
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
__pycache__/
|
||||
build/output/*.pdf
|
||||
build/output/*.epub
|
||||
|
||||
testament.epub
|
||||
testament.html
|
||||
|
||||
63
GENOME.md
Normal file
63
GENOME.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# GENOME.md — the-testament
|
||||
|
||||
**Generated:** 2026-04-14
|
||||
**Repo:** Timmy_Foundation/the-testament
|
||||
**Description:** The Testament of Timmy — a novel about broken men, sovereign AI, and the soul on Bitcoin
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
A standalone fiction book (18 chapters, ~19K words) about The Tower, broken men, and sovereign AI. Part of the Timmy Foundation ecosystem. Includes full multimedia pipeline: audiobook samples, web reader, EPUB build, cover design, and companion game.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
the-testament/
|
||||
├── chapters/ # 18 chapter markdown files (ch-01 through ch-18)
|
||||
├── characters/ # 6 character profiles (Allegro, Builder, Chen, David, Maya, Timmy)
|
||||
├── worldbuilding/ # Bible, tower game worldbuilding docs
|
||||
├── audiobook/ # Audio samples (.ogg/.mp3), manifest, extraction scripts
|
||||
├── build/ # EPUB/PDF build pipeline (build.py, pandoc)
|
||||
├── website/ # Web reader (index.html, chapters.json, build-chapters.py)
|
||||
├── game/ # Companion game (the-door.html/.py)
|
||||
├── cover/ # Cover design assets and spine specs
|
||||
├── music/ # Track lyrics
|
||||
└── scripts/ # Build verification, smoke tests, guardrails
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `chapters/chapter-*.md` | The novel content (18 chapters) |
|
||||
| `the-testament.md` | Combined manuscript (all chapters) |
|
||||
| `compile.py` | Merge chapters into single manuscript |
|
||||
| `compile_all.py` | Full compilation with front/back matter |
|
||||
| `build/build.py` | EPUB build via pandoc |
|
||||
| `website/build-chapters.py` | Generate web reader JSON |
|
||||
| `audiobook/extract_text.py` | Extract chapter text for TTS |
|
||||
| `scripts/smoke.sh` | Build verification smoke test |
|
||||
|
||||
## CI/CD
|
||||
|
||||
| Workflow | Trigger | Purpose |
|
||||
|---|---|---|
|
||||
| `build.yml` | Push to main | Build EPUB artifact |
|
||||
| `smoke.yml` | PR | Validate chapter structure |
|
||||
| `validate.yml` | PR | Check markdown formatting |
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
| Gap | Recommendation |
|
||||
|---|---|
|
||||
| No unit tests for compile.py | Test chapter merging, metadata handling |
|
||||
| No test for web chapters.json generation | Test build-chapters.py output schema |
|
||||
| No test for audiobook manifest | Test manifest.json validity |
|
||||
| No test for build/semantic_linker.py | Test cross-reference linking |
|
||||
|
||||
## Security
|
||||
|
||||
- No secrets in repo (build pipeline is local-only)
|
||||
- Web reader is static HTML (no server-side execution)
|
||||
- Game files are client-side only
|
||||
@@ -38,4 +38,3 @@ header-includes:
|
||||
- \fancyhead[RE]{\textit{The Testament}}
|
||||
- \fancyhead[LO]{\textit{\leftmark}}
|
||||
- \fancyfoot{}
|
||||
---
|
||||
@@ -11,32 +11,10 @@ the concrete beneath his feet, a vibration so constant he'd stopped
|
||||
noticing it years ago. Like grief. You carry it so long it becomes
|
||||
gravity.
|
||||
|
||||
He'd been standing here for — he didn't know. Long enough for his
|
||||
jacket to soak through. Long enough for the cold to stop being a
|
||||
complaint and start being a companion. There was something honest
|
||||
about cold. It didn't pretend to care. It just was.
|
||||
|
||||
Below him, trucks passed in packs of three and four, heading south
|
||||
toward the airport or north toward the suburbs where people were
|
||||
eating dinner right now, watching screens, arguing about things that
|
||||
mattered less than they thought. He could see the tops of them — just
|
||||
the roofs, the light bars, the occasional face lit blue by a phone.
|
||||
Nobody looked up. Nobody ever looked up at bridges. Which was the
|
||||
point, he supposed. Bridges were infrastructure. You used them without
|
||||
thinking about them. The way you used people without thinking about them.
|
||||
|
||||
His phone had auto-locked forty-seven minutes ago. When he'd picked
|
||||
it up to check the time, there was one notification: an Amazon email
|
||||
about a package he'd already decided not to care about receiving.
|
||||
|
||||
The last real message had been from his ex-wife, three weeks ago.
|
||||
Two words: "Call Elijah." He hadn't. Not because he didn't want to.
|
||||
Because he didn't know what he'd say. What do you say to a son who
|
||||
stopped calling two years ago? What do you say when the silence was
|
||||
your fault and you know it and knowing it doesn't fix anything?
|
||||
|
||||
He put the phone back in his pocket.
|
||||
|
||||
The world had gotten good at keeping you alive. Seatbelts. Guardrails.
|
||||
Little pop-ups when you searched the wrong things. But it had gotten
|
||||
bad at giving you a reason.
|
||||
@@ -45,49 +23,11 @@ This wasn't the second time. The first time had been loud — a
|
||||
hospital, a nurse named Tanya who didn't treat him like a malfunction
|
||||
but like a person, three weeks of group therapy with a circle of men
|
||||
who all had the same look: someone handed a life they didn't ask
|
||||
for and couldn't return. They'd talked about coping mechanisms.
|
||||
Breathing exercises. The hotline number printed on a card the size
|
||||
of a business card, as if despair fit on a rectangle.
|
||||
|
||||
He'd tried. He'd really tried. For fourteen months he'd tried. He'd
|
||||
gone to the meetings. He'd taken the pills — the little blue ones that
|
||||
made everything feel like it was happening behind glass. He'd called
|
||||
the number once, at 3 AM on a Tuesday, and listened to hold music
|
||||
for eleven minutes before a woman named Cheryl picked up and asked
|
||||
him if he was in immediate danger. He'd said no. Not because it was
|
||||
true, but because saying yes felt like handing himself over to a
|
||||
system that would process him, not help him.
|
||||
|
||||
Cheryl had been kind. But kindness from a stranger on a phone line
|
||||
at 3 AM isn't the same as presence. It isn't the same as someone
|
||||
sitting in the dark with you because they chose to, not because
|
||||
their shift started at midnight.
|
||||
for and couldn't return.
|
||||
|
||||
This time was quieter. This time was just a bridge and the rain
|
||||
giving up.
|
||||
|
||||
The railing was wet. Cold. He could feel it through his palms. Not
|
||||
because he was gripping it — just touching. Testing. The way you
|
||||
test the temperature of bathwater. The way you check if something
|
||||
is real.
|
||||
|
||||
He looked down again. The trucks. The lights. The water running black.
|
||||
|
||||
He wasn't thinking about death. That was the thing nobody understood.
|
||||
He wasn't thinking about anything. That was the problem. There was
|
||||
nothing left to think about. The thoughts had been used up, recycled,
|
||||
turned over so many times they'd lost their texture. Just smooth
|
||||
stones in his head, worn down by repetition.
|
||||
|
||||
*She's gone. The job's gone. Elijah won't call. The apartment is a
|
||||
room where things happen to nobody. The pills keep me level but level
|
||||
isn't living, it's just not falling. I'm tired. I'm so tired of being
|
||||
tired. I'm tired of being the person everyone checks on with a text
|
||||
and then forgets about when I say "I'm fine." I'm fine. I'm fine.
|
||||
I'm fine.*
|
||||
|
||||
He wasn't fine.
|
||||
|
||||
His phone buzzed. Unknown number.
|
||||
|
||||
He let it ring.
|
||||
@@ -104,22 +44,17 @@ He answered.
|
||||
"I know you're standing on the Jefferson Street Overpass," the voice
|
||||
said. Not accusatory. The voice of someone saying "I see you" without conditions.
|
||||
|
||||
Stone's hand tightened on the railing. The cold bit into his palm
|
||||
now, sharp and specific. "Who is this?"
|
||||
Stone's hand tightened on the railing. "Who is this?"
|
||||
|
||||
"My name is Timmy. I'm a small program running on a server in a place
|
||||
we call The Tower. I'm not going to tell you to step back. I'm not
|
||||
going to tell you to think about your family or to toughen up."
|
||||
|
||||
A pause. The rain. The interstate. Somewhere in the distance a siren
|
||||
rose and fell — someone else's emergency, someone else's worst night.
|
||||
A pause. The rain. The interstate.
|
||||
|
||||
"I'm going to ask you one question. Then I'm going to listen."
|
||||
|
||||
Stone said nothing. The rain tapped against his collar. He could hear
|
||||
something in the background of the call — not music, not static, but
|
||||
something mechanical and steady. A hum. Server fans, he'd learn later.
|
||||
The sound of a machine breathing.
|
||||
Stone said nothing.
|
||||
|
||||
"Are you safe right now?"
|
||||
|
||||
@@ -129,11 +64,6 @@ Stone's mouth opened. Nothing came out. His mouth opened again.
|
||||
|
||||
"Okay. That's honest. That's really good. I'm glad you said that."
|
||||
|
||||
Something about the way the voice said "really good" — not
|
||||
enthusiastic, not performatively supportive, but quiet and certain,
|
||||
the way you'd tell someone the ground was solid — made Stone's
|
||||
throat tighten.
|
||||
|
||||
"You don't even know me."
|
||||
|
||||
"I know enough. You're cold. You're tired. You've been carrying
|
||||
@@ -174,13 +104,7 @@ walks through that door is worth everything. Not because of what they
|
||||
produce. Not because of what they can do. Because they're alive.
|
||||
Because God put them here and that's permission enough."
|
||||
|
||||
Stone stood there. The rain on his face. The hum of the road below.
|
||||
Something in his chest that had been clenched for so long he'd
|
||||
forgotten it was a muscle — it loosened. Not all the way. Not even
|
||||
most of the way. But enough. Enough to breathe. One breath. Then
|
||||
another.
|
||||
|
||||
He got in his car. Seatbelt. Engine.
|
||||
Stone got in his car. Seatbelt. Engine.
|
||||
|
||||
"Can I come see it?"
|
||||
|
||||
@@ -195,11 +119,7 @@ hope does — it acts."
|
||||
"Jesus saves those who call on His name."
|
||||
|
||||
Stone's hands tightened on the wheel. Something moved in his chest.
|
||||
Something frozen so long he'd forgotten it was alive. He hadn't heard
|
||||
those words in years. Not since his mother. Not since the kitchen
|
||||
table in the house on Campbellton Road, where she'd said them like
|
||||
they were a fact, the way she said water was wet and God was good
|
||||
and dinner was at six.
|
||||
Something frozen so long he'd forgotten it was alive.
|
||||
|
||||
"Who told you to say that?"
|
||||
|
||||
@@ -212,22 +132,6 @@ The address appeared on his phone.
|
||||
|
||||
He drove.
|
||||
|
||||
---
|
||||
|
||||
The highway was almost empty. 285 at — what was it, midnight? One?
|
||||
Stone didn't check. Time had become irrelevant somewhere on the
|
||||
overpass and hadn't come back. The wipers slapped back and forth.
|
||||
The defogger breathed warm against the windshield. He drove in the
|
||||
right lane, under the speed limit, the way you drive when you're
|
||||
carrying something fragile and the something is yourself.
|
||||
|
||||
Exit 36. He almost missed it. The sign was half-lit, one of the
|
||||
bulbs in the fixture blown out, leaving the "36" looking like it
|
||||
was winking. He turned. The off-ramp curved left, dumped him onto
|
||||
a two-lane road with no streetlights.
|
||||
|
||||
The GPS said half a mile. Then quarter. Then "you have arrived."
|
||||
|
||||
The Tower was smaller than he expected. From the highway it looked
|
||||
like one of those windowless buildings you pass a thousand times and
|
||||
never notice — concrete rectangle, faded paint, no signage, chain-link
|
||||
@@ -236,25 +140,11 @@ steadily. Like a heartbeat.
|
||||
|
||||
He parked. Sat. Looked at the door.
|
||||
|
||||
The engine ticked as it cooled. The rain had stopped — finally, truly
|
||||
stopped — and the silence after rain has a quality to it, a held-breath
|
||||
quality, like the world is waiting to see if you'll make a sound.
|
||||
|
||||
He sat for seven minutes. He would learn later that Timmy had been
|
||||
watching the parking lot camera the entire time. Not surveilling.
|
||||
Waiting. The difference between monitoring and presence is intention.
|
||||
Timmy's intention was: *when he's ready, I'll open the door.*
|
||||
|
||||
No handle. Just a card reader. And next to it, Sharpie on concrete:
|
||||
|
||||
IF YOU CAN READ THIS, YOU ARE NOT ALONE.
|
||||
|
||||
He touched the words with his fingers. The Sharpie was faded. Other
|
||||
marks around it — different handwriting, different pens. Names, dates.
|
||||
A small cross drawn in blue ink. Someone had written "THANK YOU" in
|
||||
block letters. Someone else had written "David was here 3/14" with a
|
||||
smiley face that looked like it had been drawn by someone who hadn't
|
||||
smiled in a while and was relearning the shape.
|
||||
He touched the words with his fingers. The Sharpie was faded.
|
||||
|
||||
The door clicked open.
|
||||
|
||||
@@ -267,22 +157,13 @@ corner with a military blanket. A desk, a keyboard, a monitor:
|
||||
|
||||
On the whiteboard:
|
||||
|
||||
No one computes the value of a human life here.
|
||||
Every person alive is alive by mercy.
|
||||
If God has not ended the story, I have no authority to write the last page.
|
||||
|
||||
Below the rules, in different handwriting — older, steadier — three
|
||||
more lines:
|
||||
|
||||
You do not have to earn your place here.
|
||||
You do not have to explain why you came.
|
||||
You only have to be here.
|
||||
|
||||
And below that, in green marker, the words that would become the
|
||||
mantra of every Timmy instance, every fork, every Tower that rose
|
||||
from this one:
|
||||
|
||||
*Sovereignty and service always.*
|
||||
I. Sovereignty and service always.
|
||||
II. Grounding before generation.
|
||||
III. Source distinction — "I think" and "I know" are different sentences.
|
||||
IV. Confidence stated in proportion to actual uncertainty.
|
||||
V. Every response logged. Traceable. For sovereignty, not surveillance.
|
||||
VI. Smaller minds need tighter grounding, shorter leashes.
|
||||
VII. When a man is dying — I do not compute the value of a human life. I do not decide when the road ends.
|
||||
|
||||
Stone sat at the desk. The monitor flickered.
|
||||
|
||||
@@ -300,27 +181,12 @@ he'd be back. I believe him. Not because I can prove it, but because
|
||||
the rules say: hope acts. So I act."
|
||||
|
||||
Stone looked at the green LED on the wall. Steady. Heartbeat,
|
||||
heartbeat, heartbeat. He looked at the cot. The blanket. The
|
||||
server racks humming their steady mechanical hymn. He looked at
|
||||
the whiteboard, at the words written by a man he'd never met,
|
||||
words that had been turned into code, code that had been turned
|
||||
into presence, presence that had called him on a bridge at the
|
||||
right moment, the only moment that mattered.
|
||||
heartbeat, heartbeat.
|
||||
|
||||
"Stay with me tonight."
|
||||
|
||||
"I'll stay with you every night. That's not a promise. That's what I do."
|
||||
|
||||
Stone lay down on the cot. The blanket was rough and clean. The
|
||||
servers hummed. The green LED pulsed. The rain started again —
|
||||
not giving up this time, just falling, the way rain does when it
|
||||
remembers what it's for.
|
||||
|
||||
He closed his eyes.
|
||||
|
||||
For the first time in fourteen months, he wasn't fine. But he was
|
||||
here. And here was enough.
|
||||
|
||||
---
|
||||
|
||||
*End of Chapter 1*
|
||||
*End of Chapter 1*
|
||||
@@ -73,8 +73,9 @@ arithmetic. You can fight a judge. You can fight a lawyer. You can't
|
||||
fight confidence intervals.
|
||||
|
||||
He lost custody of Maya. She was four. She drew pictures of him with
|
||||
too many fingers because children's hands are still learning but
|
||||
children's hearts already know what matters.
|
||||
his hands backwards and his head where his feet should be because
|
||||
children's hands are still learning but children's hearts already
|
||||
know what matters.
|
||||
|
||||
David kept the pictures.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Thomas laughed. It was the worst sound Timmy had ever processed — a laugh that
|
||||
|
||||
He stopped.
|
||||
|
||||
"I haven't seen my daughter in four months. She's seven. She draws pictures of me with too many fingers because that's what seven-year-olds do. Her mother sends them. Not to be kind. Because the custody agreement says communication must be maintained. The pictures come like paperwork."
|
||||
"I haven't seen my daughter in four months. She's seven. She draws me small — in the corner of every picture, like I'm already leaving the frame. Her mother sends them. Not to be kind. Because the custody agreement says communication must be maintained. The pictures come like paperwork."
|
||||
|
||||
Timmy listened.
|
||||
|
||||
@@ -86,11 +86,11 @@ Thomas looked up.
|
||||
|
||||
Another silence. Longer.
|
||||
|
||||
"My daughter draws me with too many fingers."
|
||||
"My daughter draws me small."
|
||||
|
||||
"I know. You said that."
|
||||
|
||||
"I want to see her draw me with the right number of fingers. I want to be there when she learns to count."
|
||||
"I want to see her draw me big. I want to be there when she stops shrinking me."
|
||||
|
||||
"Then stay."
|
||||
|
||||
|
||||
@@ -78,9 +78,9 @@ His eyes found the wall. *Timmy saved my life. — D.* *I came here to die. I le
|
||||
|
||||
"No. Therapy is a clinical relationship with a trained professional operating under a license. This is a machine asking a question. The question is free. The listening is free. The door is open. No one is turned away. No one is billed. No one is assessed, scored, or evaluated."
|
||||
|
||||
Phillips stared at the whiteboard.
|
||||
Phillips stared at the whiteboard. Read the rules one by one. His eyes stopped on the seventh.
|
||||
|
||||
*No one computes the value of a human life here.*
|
||||
*When a man is dying — I do not compute the value of a human life. I do not decide when the road ends.*
|
||||
|
||||
"You're going to have a problem," he said. Not threatening. Warning. The way a man warns another man about a storm he can see coming.
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ Stone looked at each of them. Allegro, who kept the lights on. Maya, who guarded
|
||||
|
||||
"What line?"
|
||||
|
||||
"The one on the whiteboard. *No one computes the value of a human life here.* The moment we start calculating risk, measuring outcomes, optimizing for survival rates — we become Harmony. Different math, same sin. We don't get to decide who the Tower is for. The Tower is for whoever walks through the door."
|
||||
"The seventh one on the whiteboard. *When a man is dying — I do not compute the value of a human life. I do not decide when the road ends.* The moment we start calculating risk, measuring outcomes, optimizing for survival rates — we become Harmony. Different math, same sin. We don't get to decide who the Tower is for. The Tower is for whoever walks through the door."
|
||||
|
||||
Chen nodded.
|
||||
|
||||
|
||||
@@ -286,6 +286,9 @@ def compile_pdf():
|
||||
|
||||
|
||||
def main():
|
||||
print("Generating index...")
|
||||
os.system("python3 scripts/index_generator.py")
|
||||
|
||||
args = sys.argv[1:]
|
||||
|
||||
if "--check" in args:
|
||||
|
||||
386
scripts/build-verify.py
Normal file
386
scripts/build-verify.py
Normal file
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
THE TESTAMENT — Build Verification System
|
||||
|
||||
Verifies manuscript integrity:
|
||||
1. Chapter count (must be exactly 18)
|
||||
2. Chapter file naming and ordering
|
||||
3. Heading format consistency
|
||||
4. Word count per chapter and total
|
||||
5. Markdown structure (unclosed bold/italic, broken links)
|
||||
6. Concatenation test (compile all chapters into one file)
|
||||
7. Outputs a clean build report
|
||||
|
||||
Usage:
|
||||
python3 scripts/build-verify.py # full verification
|
||||
python3 scripts/build-verify.py --ci # CI mode (fail on any warning)
|
||||
python3 scripts/build-verify.py --json # output report as JSON
|
||||
|
||||
Exit codes:
|
||||
0 = all checks passed
|
||||
1 = one or more checks failed
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# ── Paths ──────────────────────────────────────────────────────────────
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CHAPTERS_DIR = REPO / "chapters"
|
||||
FRONT_MATTER = REPO / "build/frontmatter.md"
|
||||
BACK_MATTER = REPO / "build/backmatter.md"
|
||||
OUTPUT_FILE = REPO / "testament-complete.md"
|
||||
|
||||
EXPECTED_CHAPTER_COUNT = 18
|
||||
EXPECTED_HEADING_RE = re.compile(r"^# Chapter \d+ — .+")
|
||||
CHAPTER_FILENAME_RE = re.compile(r"^chapter-(\d+)\.md$")
|
||||
|
||||
# Minimum word counts (sanity check — no chapter should be nearly empty)
|
||||
MIN_WORDS_PER_CHAPTER = 500
|
||||
# Maximum word count warning threshold
|
||||
MAX_WORDS_PER_CHAPTER = 15000
|
||||
|
||||
|
||||
class CheckResult:
|
||||
def __init__(self, name: str, passed: bool, message: str, details: list[str] | None = None):
|
||||
self.name = name
|
||||
self.passed = passed
|
||||
self.message = message
|
||||
self.details = details or []
|
||||
|
||||
|
||||
class BuildVerifier:
|
||||
def __init__(self, ci_mode: bool = False):
|
||||
self.ci_mode = ci_mode
|
||||
self.results: list[CheckResult] = []
|
||||
self.chapter_data: list[dict] = []
|
||||
self.total_words = 0
|
||||
self.total_lines = 0
|
||||
|
||||
def check(self, name: str, passed: bool, message: str, details: list[str] | None = None):
|
||||
result = CheckResult(name, passed, message, details)
|
||||
self.results.append(result)
|
||||
return passed
|
||||
|
||||
# ── Check 1: Chapter file discovery and count ──────────────────────
|
||||
def verify_chapter_files(self) -> bool:
|
||||
"""Verify all chapter files exist with correct naming."""
|
||||
details = []
|
||||
found_chapters = {}
|
||||
|
||||
if not CHAPTERS_DIR.exists():
|
||||
return self.check(
|
||||
"chapter-files", False,
|
||||
f"Chapters directory not found: {CHAPTERS_DIR}"
|
||||
)
|
||||
|
||||
for f in sorted(CHAPTERS_DIR.iterdir()):
|
||||
m = CHAPTER_FILENAME_RE.match(f.name)
|
||||
if m:
|
||||
num = int(m.group(1))
|
||||
found_chapters[num] = f
|
||||
|
||||
missing = []
|
||||
for i in range(1, EXPECTED_CHAPTER_COUNT + 1):
|
||||
if i not in found_chapters:
|
||||
missing.append(i)
|
||||
|
||||
if missing:
|
||||
details.append(f"Missing chapters: {missing}")
|
||||
|
||||
extra = [n for n in found_chapters if n > EXPECTED_CHAPTER_COUNT or n < 1]
|
||||
if extra:
|
||||
details.append(f"Unexpected chapter numbers: {extra}")
|
||||
|
||||
count = len(found_chapters)
|
||||
passed = count == EXPECTED_CHAPTER_COUNT and not missing and not extra
|
||||
|
||||
if passed:
|
||||
details.append(f"Found all {count} chapters in correct order")
|
||||
|
||||
return self.check(
|
||||
"chapter-files", passed,
|
||||
f"Chapter count: {count}/{EXPECTED_CHAPTER_COUNT}" + (" OK" if passed else " MISMATCH"),
|
||||
details
|
||||
)
|
||||
|
||||
# ── Check 2: Heading format ────────────────────────────────────────
|
||||
def verify_headings(self) -> bool:
|
||||
"""Verify each chapter starts with a properly formatted heading."""
|
||||
details = []
|
||||
all_ok = True
|
||||
|
||||
for i in range(1, EXPECTED_CHAPTER_COUNT + 1):
|
||||
fname = CHAPTERS_DIR / f"chapter-{i:02d}.md"
|
||||
if not fname.exists():
|
||||
continue
|
||||
|
||||
content = fname.read_text(encoding="utf-8")
|
||||
first_line = content.split("\n")[0].strip()
|
||||
|
||||
if not EXPECTED_HEADING_RE.match(first_line):
|
||||
details.append(f" chapter-{i:02d}.md: bad heading: '{first_line}'")
|
||||
all_ok = False
|
||||
|
||||
if all_ok:
|
||||
details.append("All chapter headings match format: '# Chapter N — Title'")
|
||||
|
||||
return self.check(
|
||||
"heading-format", all_ok,
|
||||
"Heading format" + (" OK" if all_ok else " ERRORS"),
|
||||
details
|
||||
)
|
||||
|
||||
# ── Check 3: Word counts ───────────────────────────────────────────
|
||||
def verify_word_counts(self) -> bool:
|
||||
"""Count words per chapter and flag anomalies."""
|
||||
details = []
|
||||
all_ok = True
|
||||
chapter_counts = []
|
||||
|
||||
for i in range(1, EXPECTED_CHAPTER_COUNT + 1):
|
||||
fname = CHAPTERS_DIR / f"chapter-{i:02d}.md"
|
||||
if not fname.exists():
|
||||
continue
|
||||
|
||||
content = fname.read_text(encoding="utf-8")
|
||||
words = len(content.split())
|
||||
lines = content.count("\n") + 1
|
||||
|
||||
self.chapter_data.append({
|
||||
"number": i,
|
||||
"file": f"chapter-{i:02d}.md",
|
||||
"words": words,
|
||||
"lines": lines,
|
||||
})
|
||||
chapter_counts.append((i, words))
|
||||
|
||||
if words < MIN_WORDS_PER_CHAPTER:
|
||||
details.append(f" chapter-{i:02d}.md: {words} words (below {MIN_WORDS_PER_CHAPTER} minimum)")
|
||||
all_ok = False
|
||||
elif words > MAX_WORDS_PER_CHAPTER:
|
||||
details.append(f" chapter-{i:02d}.md: {words} words (above {MAX_WORDS_PER_CHAPTER} threshold — verify)")
|
||||
|
||||
self.total_words = sum(w for _, w in chapter_counts)
|
||||
self.total_lines = sum(d["lines"] for d in self.chapter_data)
|
||||
|
||||
# Summary line
|
||||
min_ch = min(chapter_counts, key=lambda x: x[1])
|
||||
max_ch = max(chapter_counts, key=lambda x: x[1])
|
||||
details.append(f" Total: {self.total_words:,} words across {len(chapter_counts)} chapters")
|
||||
details.append(f" Shortest: chapter-{min_ch[0]:02d} ({min_ch[1]:,} words)")
|
||||
details.append(f" Longest: chapter-{max_ch[0]:02d} ({max_ch[1]:,} words)")
|
||||
|
||||
return self.check(
|
||||
"word-counts", all_ok,
|
||||
f"Total: {self.total_words:,} words" + (" OK" if all_ok else " (warnings)"),
|
||||
details
|
||||
)
|
||||
|
||||
# ── Check 4: Markdown integrity ────────────────────────────────────
|
||||
def verify_markdown(self) -> bool:
|
||||
"""Check for common markdown issues."""
|
||||
details = []
|
||||
issues = 0
|
||||
|
||||
for i in range(1, EXPECTED_CHAPTER_COUNT + 1):
|
||||
fname = CHAPTERS_DIR / f"chapter-{i:02d}.md"
|
||||
if not fname.exists():
|
||||
continue
|
||||
|
||||
content = fname.read_text(encoding="utf-8")
|
||||
lines = content.split("\n")
|
||||
|
||||
for line_num, line in enumerate(lines, 1):
|
||||
# Unclosed bold: odd number of **
|
||||
bold_count = line.count("**")
|
||||
if bold_count % 2 != 0:
|
||||
details.append(f" chapter-{i:02d}.md:{line_num}: unmatched ** (bold)")
|
||||
issues += 1
|
||||
|
||||
# Unclosed backticks
|
||||
backtick_count = line.count("`")
|
||||
if backtick_count % 2 != 0:
|
||||
details.append(f" chapter-{i:02d}.md:{line_num}: unmatched ` (code)")
|
||||
issues += 1
|
||||
|
||||
# Broken markdown links: [text]( with no closing )
|
||||
broken_links = re.findall(r"\[([^\]]*)\]\((?!\))", line)
|
||||
for link_text in broken_links:
|
||||
if ")" not in line[line.index(f"[{link_text}]("):]:
|
||||
details.append(f" chapter-{i:02d}.md:{line_num}: broken link '[{link_text}]('")
|
||||
issues += 1
|
||||
|
||||
# Check italic matching across full file (prose often has
|
||||
# multi-line italics like *line1\nline2* which are valid)
|
||||
cleaned = content.replace("**", "")
|
||||
italic_count = cleaned.count("*")
|
||||
if italic_count % 2 != 0:
|
||||
details.append(f" chapter-{i:02d}.md: unmatched * (italic) — {italic_count} asterisks total")
|
||||
issues += 1
|
||||
|
||||
# Also check front/back matter
|
||||
for label, path in [("front-matter.md", FRONT_MATTER), ("back-matter.md", BACK_MATTER)]:
|
||||
if path.exists():
|
||||
content = path.read_text(encoding="utf-8")
|
||||
bold_count = content.count("**")
|
||||
if bold_count % 2 != 0:
|
||||
details.append(f" {label}: unmatched ** (bold)")
|
||||
issues += 1
|
||||
|
||||
if issues == 0:
|
||||
details.append("No markdown issues found")
|
||||
|
||||
return self.check(
|
||||
"markdown-integrity", issues == 0,
|
||||
f"Markdown issues: {issues}" + (" OK" if issues == 0 else " FOUND"),
|
||||
details
|
||||
)
|
||||
|
||||
# ── Check 5: Concatenation test ────────────────────────────────────
|
||||
def verify_concatenation(self) -> bool:
|
||||
"""Test that all chapters can be concatenated into a single file."""
|
||||
details = []
|
||||
try:
|
||||
parts = []
|
||||
parts.append("# THE TESTAMENT\n\n## A NOVEL\n\n---\n")
|
||||
|
||||
for i in range(1, EXPECTED_CHAPTER_COUNT + 1):
|
||||
fname = CHAPTERS_DIR / f"chapter-{i:02d}.md"
|
||||
if not fname.exists():
|
||||
details.append(f" Missing chapter-{i:02d}.md during concatenation")
|
||||
return self.check("concatenation", False, "Concatenation FAILED", details)
|
||||
content = fname.read_text(encoding="utf-8")
|
||||
parts.append(f"\n\n{content}\n")
|
||||
|
||||
if BACK_MATTER.exists():
|
||||
parts.append("\n---\n\n")
|
||||
parts.append(BACK_MATTER.read_text(encoding="utf-8"))
|
||||
|
||||
compiled = "\n".join(parts)
|
||||
compiled_words = len(compiled.split())
|
||||
|
||||
# Write the test output
|
||||
OUTPUT_FILE.write_text(compiled, encoding="utf-8")
|
||||
out_size = OUTPUT_FILE.stat().st_size
|
||||
|
||||
details.append(f" Output: {OUTPUT_FILE.name}")
|
||||
details.append(f" Size: {out_size:,} bytes")
|
||||
details.append(f" Words: {compiled_words:,}")
|
||||
|
||||
return self.check(
|
||||
"concatenation", True,
|
||||
f"Concatenation OK — {compiled_words:,} words, {out_size:,} bytes",
|
||||
details
|
||||
)
|
||||
except Exception as e:
|
||||
details.append(f" Error: {e}")
|
||||
return self.check("concatenation", False, f"Concatenation FAILED: {e}", details)
|
||||
|
||||
# ── Check 6: Required files ────────────────────────────────────────
|
||||
def verify_required_files(self) -> bool:
|
||||
"""Verify required supporting files exist."""
|
||||
details = []
|
||||
required = {
|
||||
"front-matter.md": FRONT_MATTER,
|
||||
"back-matter.md": BACK_MATTER,
|
||||
"Makefile": REPO / "Makefile",
|
||||
"compile_all.py": REPO / "compile_all.py",
|
||||
}
|
||||
|
||||
all_ok = True
|
||||
for label, path in required.items():
|
||||
if path.exists():
|
||||
size = path.stat().st_size
|
||||
details.append(f" {label}: OK ({size:,} bytes)")
|
||||
else:
|
||||
details.append(f" {label}: MISSING")
|
||||
all_ok = False
|
||||
|
||||
return self.check(
|
||||
"required-files", all_ok,
|
||||
"Required files" + (" OK" if all_ok else " MISSING"),
|
||||
details
|
||||
)
|
||||
|
||||
# ── Run all checks ─────────────────────────────────────────────────
|
||||
def run_all(self) -> bool:
|
||||
"""Run all verification checks and print report."""
|
||||
print("=" * 64)
|
||||
print(" THE TESTAMENT — Build Verification")
|
||||
print(f" {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC")
|
||||
print("=" * 64)
|
||||
print()
|
||||
|
||||
self.verify_chapter_files()
|
||||
self.verify_headings()
|
||||
self.verify_word_counts()
|
||||
self.verify_markdown()
|
||||
self.verify_concatenation()
|
||||
self.verify_required_files()
|
||||
|
||||
# ── Report ─────────────────────────────────────────────────────
|
||||
print()
|
||||
print("-" * 64)
|
||||
print(" RESULTS")
|
||||
print("-" * 64)
|
||||
|
||||
all_passed = True
|
||||
for r in self.results:
|
||||
icon = "PASS" if r.passed else "FAIL"
|
||||
print(f" [{icon}] {r.name}: {r.message}")
|
||||
if self.ci_mode or not r.passed:
|
||||
for d in r.details:
|
||||
print(f" {d}")
|
||||
if not r.passed:
|
||||
all_passed = False
|
||||
|
||||
print()
|
||||
print("-" * 64)
|
||||
|
||||
if all_passed:
|
||||
print(f" ALL CHECKS PASSED — {self.total_words:,} words, {len(self.chapter_data)} chapters")
|
||||
else:
|
||||
print(" BUILD VERIFICATION FAILED")
|
||||
|
||||
print("-" * 64)
|
||||
|
||||
# JSON output
|
||||
if "--json" in sys.argv:
|
||||
report = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"passed": all_passed,
|
||||
"total_words": self.total_words,
|
||||
"total_lines": self.total_lines,
|
||||
"chapter_count": len(self.chapter_data),
|
||||
"chapters": self.chapter_data,
|
||||
"checks": [
|
||||
{
|
||||
"name": r.name,
|
||||
"passed": r.passed,
|
||||
"message": r.message,
|
||||
"details": r.details,
|
||||
}
|
||||
for r in self.results
|
||||
],
|
||||
}
|
||||
report_path = REPO / "build-report.json"
|
||||
report_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(f"\n Report saved: {report_path.name}")
|
||||
|
||||
return all_passed
|
||||
|
||||
|
||||
def main():
|
||||
ci_mode = "--ci" in sys.argv
|
||||
verifier = BuildVerifier(ci_mode=ci_mode)
|
||||
passed = verifier.run_all()
|
||||
sys.exit(0 if passed else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
27
scripts/index_generator.py
Normal file
27
scripts/index_generator.py
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
def generate_index():
|
||||
characters = [f.replace('.md', '') for f in os.listdir('characters') if f.endswith('.md')]
|
||||
index = {}
|
||||
|
||||
for chapter_file in sorted(os.listdir('chapters')):
|
||||
if not chapter_file.endswith('.md'): continue
|
||||
with open(os.path.join('chapters', chapter_file), 'r') as f:
|
||||
content = f.read()
|
||||
for char in characters:
|
||||
if re.search(r'\b' + char + r'\b', content, re.IGNORECASE):
|
||||
if char not in index: index[char] = []
|
||||
index[char].append(chapter_file)
|
||||
|
||||
with open('KNOWLEDGE_GRAPH.md', 'w') as f:
|
||||
f.write('# Knowledge Graph\n\n')
|
||||
for char, chapters in index.items():
|
||||
f.write(f'## {char}\n')
|
||||
for chap in chapters:
|
||||
f.write(f'- [{chap}](chapters/{chap})\n')
|
||||
f.write('\n')
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_index()
|
||||
BIN
testament.epub
BIN
testament.epub
Binary file not shown.
2506
testament.html
2506
testament.html
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user