161 lines
5.6 KiB
Python
161 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""CHECK: re-derive the stackchain catalog from the raw source JSONs and diff
|
|
against the committed artifact (stackchain_catalog.json + stackchain_catalog.md).
|
|
|
|
Exit 0 only if the committed artifact is exactly what the sources produce, plus
|
|
the markdown summary table is sane. This is a real regeneration test, not a
|
|
tautology: it re-reads the source files and recomputes every row.
|
|
"""
|
|
import json
|
|
import re
|
|
import sys
|
|
|
|
SRC_RAW = '/root/stackchain_catalog_raw.json'
|
|
SRC_B2 = '/root/stackchain_catalog_batch2.json'
|
|
SRC_B3 = '/root/stackchain_catalog_batch3.json'
|
|
ACTUAL_JSON = '/root/troupe-standup/wizard-work/ledger/stackchain_catalog.json'
|
|
ACTUAL_MD = '/root/troupe-standup/wizard-work/ledger/stackchain_catalog.md'
|
|
|
|
TYPE_RE = re.compile(r'"type"\s*:\s*"(.*?)"')
|
|
TEXT_RE = re.compile(r'"text"\s*:\s*"([\s\S]*?)"')
|
|
ERROR_RE = re.compile(r'ERROR: (.*?)(?:, full=|$)')
|
|
|
|
|
|
def load(p):
|
|
with open(p, encoding='utf-8') as f:
|
|
return json.load(f)
|
|
|
|
|
|
def clean_text(v):
|
|
return v.replace('\\n', '\n').replace('\\n', '\n')
|
|
|
|
|
|
def parse_entry_value(v):
|
|
if not isinstance(v, str) or not v.strip():
|
|
return None
|
|
try:
|
|
d = json.loads(v)
|
|
if isinstance(d, dict):
|
|
return d
|
|
except Exception:
|
|
pass
|
|
m = TYPE_RE.search(v)
|
|
text_m = TEXT_RE.search(v)
|
|
return {
|
|
'type': m.group(1) if m else '(unknown)',
|
|
'text': clean_text(text_m.group(1)) if text_m else '',
|
|
}
|
|
|
|
|
|
def reextract():
|
|
raw = load(SRC_RAW)
|
|
b2 = load(SRC_B2)
|
|
b3 = load(SRC_B3)
|
|
|
|
entries = []
|
|
for idx, (fname, v) in enumerate(raw.items(), 1):
|
|
d = parse_entry_value(v)
|
|
if d and d.get('text'):
|
|
entries.append({'no': None, 'filename': fname, 'source': 'raw',
|
|
'status': 'transcribed', 'type': d.get('type') or '(unknown)',
|
|
'text': d['text'], 'words': len(d['text'].split())})
|
|
else:
|
|
entries.append({'no': None, 'filename': fname, 'source': 'raw',
|
|
'status': 'empty', 'type': '(not transcribed)',
|
|
'text': '', 'words': 0})
|
|
|
|
for idx, (fname, v) in enumerate(b2.items(), 1):
|
|
d = parse_entry_value(v)
|
|
if d and d.get('text'):
|
|
entries.append({'no': None, 'filename': fname, 'source': 'batch2',
|
|
'status': 'transcribed', 'type': d.get('type') or '(unknown)',
|
|
'text': d['text'], 'words': len(d['text'].split())})
|
|
else:
|
|
entries.append({'no': None, 'filename': fname, 'source': 'batch2',
|
|
'status': 'error', 'type': '(retry failed)',
|
|
'text': '', 'words': 0})
|
|
|
|
for idx, (fname, v) in enumerate(b3.items(), 1):
|
|
d = parse_entry_value(v)
|
|
if d and d.get('text'):
|
|
entries.append({'no': None, 'filename': fname, 'source': 'batch3',
|
|
'status': 'transcribed', 'type': d.get('type') or '(unknown)',
|
|
'text': d['text'], 'words': len(d['text'].split())})
|
|
else:
|
|
entries.append({'no': None, 'filename': fname, 'source': 'batch3',
|
|
'status': 'error', 'type': '(retry failed)',
|
|
'text': '', 'words': 0})
|
|
|
|
entries.sort(key=lambda e: e['filename'])
|
|
for i, e in enumerate(entries, 1):
|
|
e['no'] = i
|
|
return entries
|
|
|
|
|
|
def load_actual():
|
|
with open(ACTUAL_JSON, encoding='utf-8') as f:
|
|
rows = json.load(f)
|
|
md = open(ACTUAL_MD, encoding='utf-8').read()
|
|
return rows, md
|
|
|
|
|
|
def md_summary_rows(md):
|
|
rows = []
|
|
for line in md.splitlines():
|
|
if line.startswith('|') and '---' not in line and 'No' not in line:
|
|
cells = [c.strip() for c in line.strip('|').split('|')]
|
|
if cells:
|
|
rows.append(cells)
|
|
return rows
|
|
|
|
|
|
def main():
|
|
errors = []
|
|
expected = reextract()
|
|
actual, md = load_actual()
|
|
|
|
if not isinstance(actual, list):
|
|
errors.append('artifact JSON is not a list')
|
|
actual = []
|
|
|
|
if len(actual) != len(expected):
|
|
errors.append('row count: actual=%d expected=%d' % (len(actual), len(expected)))
|
|
|
|
for i, (a, e) in enumerate(zip(actual, expected), 1):
|
|
for key in ('no', 'filename', 'source', 'status', 'type', 'text', 'words'):
|
|
if a.get(key) != e[key]:
|
|
errors.append('row %d field %r: actual=%r expected=%r' % (i, key, a.get(key), e[key]))
|
|
|
|
# markdown sanity: every entry must appear in the summary table with matching No/File/Type/Words.
|
|
seen = set()
|
|
for cells in md_summary_rows(md):
|
|
if len(cells) == 6 and cells[1].startswith('`'):
|
|
try:
|
|
no = int(cells[0])
|
|
except ValueError:
|
|
continue
|
|
seen.add(cells[1].strip('`'))
|
|
if no < 1 or no > len(actual):
|
|
errors.append('md table row No out of range: %s' % no)
|
|
continue
|
|
e = actual[no - 1]
|
|
if cells[2] != e['source'] or cells[3] != e['type'] or cells[5] != str(e['words']):
|
|
errors.append('md row %d: %r vs %r' % (no, cells[2:], (e['source'], e['type'], e['words'])))
|
|
expected_files = {r['filename'] for r in expected}
|
|
if seen != expected_files:
|
|
errors.append('md table missing/extra files: %s' % sorted(seen ^ expected_files))
|
|
|
|
if errors:
|
|
print('CHECK FAILED:')
|
|
for e in errors:
|
|
print(' -', e)
|
|
sys.exit(1)
|
|
|
|
print('CHECK OK: %d rows re-derived from source JSONs and identical to the '
|
|
'committed artifact.' % len(actual))
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|