diff options
| author | Agando <roduccomm@gmail.com> | 2026-11-06 11:37:11 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-11-06 11:37:11 +0100 |
| commit | 1983ce5796e45b312b142554cb717db7f8978bc8 (patch) | |
| tree | a8cef803729c1eb422a550a29257510d7daab937 /scripts/build-changelog.py | |
| parent | d5dc2e1cb2f1463d001c3b10d1637298997f8559 (diff) | |
fix(changelog): render the commit body, not just the subject
Symptom: the mirrored CHANGELOG.md (and the page) showed one-line commit
summaries while README promises "every shipped fix, symptom -> cause -> fix ->
verified". A reviewer measured the gap from the public mirror and could only
conclude the claim overpromised.
Cause: scripts/build-changelog.py:59 formatted commits with %s only; %b was
never read, so the verification prose every fix commit carries (5b55af9 is a
typical one) was discarded before rendering. The MD writer then replaced every
tag with a backtick, so nothing richer could have survived anyway.
Fix: read %b with a %x02 terminator (line-based parser, multi-line body), split
it into paragraph/bullet blocks capped at 900 chars with a visible marker, and
render it twice from one source: as a native <details> per item on the page
(its renderer already assigns item HTML, so no JS change; CSS injected once
behind a sentinel) and as nested bullets under the line in CHANGELOG.md. The
"every build" gap note and the page dek were rewritten to describe what the
file now contains.
Verified: page 100 details render, first opens 23 -> 232 px with 3 paragraphs,
no horizontal overflow, island parses (83 entries); two consecutive runs are
byte-identical for CHANGELOG.md; the double-escape bug this surfaced (code
spans shipped as &lt;sha&gt;) is fixed and grep for &lt; is 0.
Diffstat (limited to 'scripts/build-changelog.py')
| -rw-r--r-- | scripts/build-changelog.py | 186 |
1 files changed, 170 insertions, 16 deletions
diff --git a/scripts/build-changelog.py b/scripts/build-changelog.py index 8973761..29d8e83 100644 --- a/scripts/build-changelog.py +++ b/scripts/build-changelog.py @@ -54,20 +54,44 @@ def git(*args): def commits(): - """Oldest first: [{sha, date, subject, files:[...]}]""" + """Oldest first: [{sha, date, subject, body, files:[...]}] + + `%b` is read, not just `%s`: the shipped fixes carry their symptom -> cause -> fix -> + verified prose in the commit BODY (5b55af9 is a typical one), so a subject-only changelog + rendered "one-line summaries" for every entry and made README's claim about this file + false as served. The body is terminated by %x02 because it is multi-line and the parser + below is line-based; without a terminator a body line is indistinguishable from a path. + """ raw = git('log', '--reverse', '--date=iso-strict', - '--format=%x01%H%x01%aI%x01%s', '--name-only', '--', APP_REL) - out, cur = [], None + '--format=%x01%H%x01%aI%x01%s%x01%b%x02', '--name-only', '--', APP_REL) + out, cur, where = [], None, 'body' for line in raw.split('\n'): if line.startswith('\x01'): - _, sha, iso, subject = line.split('\x01', 3) - cur = {'sha': sha, 'iso': iso, 'date': iso[:10], 'subject': subject, 'files': []} + parts = line.split('\x01', 4) + if len(parts) < 5: + continue + _, sha, iso, subject, line = parts + cur = {'sha': sha, 'iso': iso, 'date': iso[:10], 'subject': subject, + 'body': [], 'files': []} out.append(cur) - elif line.strip() and cur is not None: + where = 'body' + if cur is None: + continue + if where == 'body': + if '\x02' in line: + head = line.split('\x02', 1)[0] + if head.strip(): + cur['body'].append(head) + where = 'files' + else: + # Blank lines are kept: they are the paragraph boundary the renderer splits on. + cur['body'].append(line.rstrip()) + elif line.strip(): cur['files'].append(line.strip()) + for row in out: + row['body'] = '\n'.join(row['body']).strip() return out - _TAGMAP = None @@ -132,8 +156,84 @@ def clean(subject): """ body = subject.split(':', 1)[1].strip() if ':' in subject else subject body = body[0].upper() + body[1:] if body else subject - return re.sub(r'`([^`]+)`', lambda m: '<code>' + html.escape(m.group(1)) + '</code>', - html.escape(body)) + return inline(body) + + +def inline(text): + """Escape, then backticks -> <code>. Shared by subjects and body prose. + + Escape ONCE, before wrapping: escaping the captured group again double-encodes any + `&`/`<`/`>` that sits inside a code span (measured: the "run `git show <sha>`" marker + shipped as &lt;sha&gt; and reached CHANGELOG.md as <sha>). + """ + return re.sub(r'`([^`]+)`', lambda m: '<code>' + m.group(1) + '</code>', html.escape(text)) + + +# A commit body can run to 2 KB and this page lists every build, so body detail is capped and +# the cut is MARKED: a reader must be able to tell a summary from a full transcript. +DETAIL_MAX_CHARS = 900 +DETAIL_MAX_BLOCKS = 8 + + +def detail_blocks(text): + """Commit body -> [('p'|'li', plain text)], blank-line separated. + + Bullet runs become list items (they read as the symptom/cause/fix/verified lines this + project writes); wrapped prose lines are rejoined into one paragraph. Without %b the + changelog could only ever show a subject, whatever the commit actually documented. + """ + blocks = [] + for chunk in re.split(r'\n\s*\n', (text or '').strip()): + lines = [ln.strip() for ln in chunk.splitlines() if ln.strip()] + if not lines: + continue + if all(re.match(r'^[-*\u2022]\s+', ln) for ln in lines): + for ln in lines: + blocks.append(('li', re.sub(r'^[-*\u2022]\s+', '', ln))) + continue + prose = [] + for ln in lines: + if re.match(r'^[-*\u2022]\s+', ln): + if prose: + blocks.append(('p', ' '.join(prose))) + prose = [] + blocks.append(('li', re.sub(r'^[-*\u2022]\s+', '', ln))) + else: + prose.append(ln) + if prose: + blocks.append(('p', ' '.join(prose))) + + out, used = [], 0 + for kind, body in blocks: + if len(out) >= DETAIL_MAX_BLOCKS or used + len(body) > DETAIL_MAX_CHARS: + out.append(('p', '\u2026 run `git show <sha>` for the rest')) + break + out.append((kind, body)) + used += len(body) + return out + + +def detail_html(blocks): + """Collapsible detail inside the list item: native <details>, so the page's renderer + (item.innerHTML = html) needs no change and a reader can ignore it.""" + if not blocks: + return '' + out, in_ul = ['<details><summary>detail</summary>'], False + for kind, body in blocks: + if kind == 'li': + if not in_ul: + out.append('<ul>') + in_ul = True + out.append('<li>%s</li>' % inline(body)) + else: + if in_ul: + out.append('</ul>') + in_ul = False + out.append('<p>%s</p>' % inline(body)) + if in_ul: + out.append('</ul>') + out.append('</details>') + return ''.join(out) def build(): @@ -169,8 +269,9 @@ def build(): for ver, group in releases.items(): items = [] for row in group[:MAX_ITEMS]: - items.append('<strong>%s</strong> <code>%s</code>' % ( - clean(row['subject']), html.escape(row['sha'][:7]))) + items.append('<strong>%s</strong> <code>%s</code>%s' % ( + clean(row['subject']), html.escape(row['sha'][:7]), + detail_html(detail_blocks(row.get('body'))))) if len(group) > MAX_ITEMS: items.append('…and %d more commit(s) in this build' % (len(group) - MAX_ITEMS)) kinds = Counter(kind_of(r['subject']) for r in group) @@ -206,9 +307,11 @@ def build(): 'builds whose tree reached a commit can appear here. Read a jump in the ' 'version chip as "those bumps were superseded before commit".'}) gaps.append({'range': 'every build', - 'why': 'Entries summarise commit subjects, not diffs. A build with one squashed ' - 'commit shows one line even if it changed ten files; run ' - '`git show <sha>` for the change itself.'}) + 'why': 'Entries carry the commit subject and, where the commit body has ' + 'substance, its symptom / cause / fix / verification text (folded open ' + 'per line on the page, nested under the line in CHANGELOG.md). A build ' + 'whose work was squashed into one commit still shows one line; the diff ' + 'itself is always `git show <sha>`.'}) now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') prev = existing_island() @@ -232,7 +335,8 @@ def build(): DEK = ('Every change that has a commit behind it. Each line names the build it landed in and the ' - 'commit it came from, so any claim here can be checked with <code>git show</code>. Where ' + 'commit it came from, and folds open to the commit body — the symptom, cause, fix and how ' + 'it was verified — so any claim here can be checked with <code>git show</code>. Where ' 'the repository has no record, the gap is written down instead of filled in.') GAP_INTRO = ('This page is generated from the repository\'s own history for ' '<code>spaces/newos4</code> — the same source the running build comes from, so ' @@ -257,6 +361,21 @@ def existing_island(): return None +# Rendered as native <details> inside each list item, so the page's own JS needs no change. +# Injected once (sentinel-guarded) because the page is a static document: a builder that only +# rewrites its JSON island cannot restyle anything else. +DETAIL_CSS = """ /* wx11-changelog-detail: collapsible commit body */ + .entry li>details{margin:.3rem 0 0} + .entry li>details>summary{ + cursor:pointer; font-family:var(--mono); font-size:.66rem; letter-spacing:.08em; + text-transform:uppercase; color:var(--ink-3); padding:.15rem 0; + } + .entry li>details>summary:hover{color:var(--cyan)} + .entry li>details p{margin:.35rem 0 0; font-size:.88rem; color:var(--ink-2); line-height:1.55} + .entry li>details ul{margin:.3rem 0 0; padding-left:1rem; font-size:.88rem; color:var(--ink-2); line-height:1.55} +""" + + def write_page(page): with open(PAGE, encoding='utf-8') as fh: doc = fh.read() @@ -271,6 +390,9 @@ def write_page(page): new = re.sub(r'(<section class="panel" aria-labelledby="gaps-h">[\s\S]*?<h2 id="gaps-h">[^<]*</h2>\s*<p>)[\s\S]*?(</p>)', lambda m: m.group(1) + GAP_INTRO + m.group(2), new, count=1) new = re.sub(r'(<b id="cur-ver">)[^<]*(</b>)', lambda m: m.group(1) + html.escape(page['currentVersion']) + m.group(2), new, count=1) + if '/* wx11-changelog-detail' not in new: + new, n = re.subn(r'(\n\s*</style>)', lambda m: '\n' + DETAIL_CSS + m.group(1), new, count=1) + assert n == 1, 'no </style> to inject the detail CSS before - page layout changed' write_atomic(PAGE, new) return len(new) @@ -286,6 +408,38 @@ PREAMBLE_RE = re.compile(re.escape('# newOS4 changelog') + r'do not edit by hand between the markers\.\n+') +DETAIL_RX = re.compile(r'<details[^>]*>\s*<summary>[^<]*</summary>(.*?)</details>\s*$', re.S) + + +def md_inline(markup): + """Our own generated item markup -> markdown. + + The old line replaced EVERY tag with a backtick, so `<strong>` became a stray tick and a + body's paragraph tags would have shredded the file. Explicit rules beat a generic strip: + <code>/<strong> survive as markdown, everything else becomes a space. + """ + text = re.sub(r'<code>(.*?)</code>', lambda m: '`' + m.group(1) + '`', markup, flags=re.S) + text = re.sub(r'<strong>(.*?)</strong>', lambda m: '**' + m.group(1) + '**', text, flags=re.S) + text = re.sub(r'<[^>]+>', ' ', text) + return re.sub(r'\s+', ' ', html.unescape(text)).strip() + + +def md_item(item): + """One changelog line, with the commit body as nested bullets. + + This is the file the public mirror serves, so the README claim "symptom -> cause -> fix -> + verified" has to be true HERE, not only on the rendered page. + """ + m = DETAIL_RX.search(item) + lines = ['- ' + md_inline(item[:m.start()] if m else item)] + if m: + for _tag, body in re.findall(r'<(p|li)>(.*?)</\1>', m.group(1), re.S): + body = md_inline(body) + if body: + lines.append(' - ' + body) + return lines + + def write_md(page, rows): """App-side Keep-a-Changelog history; only the region between the markers is generated.""" region = ['<!-- generated:start -->', ''] @@ -295,7 +449,7 @@ def write_md(page, rows): if entry['note']: region.extend(['_' + re.sub(r'<[^>]+>', '', entry['note']) + '_', '']) for item in entry['items']: - region.append('- ' + re.sub(r'<[^>]+>', '`', item).replace('``', '`').replace('``', '`')) + region.extend(md_item(item)) region.append('') region.extend(['<!-- generated:end -->', '']) region_text = '\n'.join(region) |