ADFA-4739: Kotlin docs DB pipeline + Build Kotlin Docs GitHub Action - #24
ADFA-4739: Kotlin docs DB pipeline + Build Kotlin Docs GitHub Action#24alexmmiller wants to merge 44 commits into
Conversation
…, generate and add templates to display it to database, optimize and insert Kotlin website media. Script to sync current kotlin-stdlib documentation against a newly-generated documentation set (for now, used to do pruning for ADFA-4737 https://appdevforall.atlassian.net/browse/ADFA-4737)
- README: templates/assets are already included in the repo, not left for the user to place separately. - sync_kdoc_json_to_db.py: default --db to documentation.db in cwd instead of a personal /home/alex path; add explicit BEGIN to match sibling scripts' transaction style. - insert_optimized_media.py: rewrite_pages now substitutes renamed image references in a single regex pass over each row's original text, instead of sequential str.replace calls on a mutating buffer, which could double-rewrite a reference if one rename's new name chained into another rename's old name. - run_e2e_pipeline_test.sh: add a verification step that reuses populate_db.py's own pruning logic to confirm blacklisted topic pages are actually absent from the database, replacing a weaker proxy check that only tested for k/html/wasm% paths. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Brings in the updated KDoc-to-JSON Dokka plugin (sourceSet whitelisting, package-index/structure verification scripts) so this branch has the current plugin used to generate the kotlin-stdlib docs consumed by sync_kdoc_json_to_db.py.
- run_e2e_pipeline_test.sh: replace the manually-supplied STDLIB_ALL_LIBS
path with STDLIB_DOCS_DIR (a kotlin repo's libraries/tools/kotlin-stdlib-docs
checkout). New Step 4/5 derives the repo root and calls
build-stdlib-json-docs.sh to freshly build/publish the kdoc-to-json plugin
and generate kotlin-stdlib/-reflect/-test JSON docs (common+jvm source
sets only, per the existing plugin config) before Step 5/5 syncs them into
the database.
- Add build-stdlib-json-docs.sh: builds a fresh copy of the plugin, swaps in
the JSON-plugin-enabled build.gradle.kts for the duration of the build
(restored on exit via trap), and prints only the resulting all-libs path
to stdout so it composes as STDLIB_ALL_LIBS="$(build-stdlib-json-docs.sh
<kotlin-repo-root>)" - every gradlew invocation inside it is redirected to
stderr so that capture isn't polluted by build console output.
- build.gradle.kts: re-add the Dokka dev-snapshot Maven repo (same
dokka_repository property/default kotlin-stdlib-docs' own
settings.gradle.kts uses) - kotlin-stdlib-docs' own
dokka-samples-transformer-plugin subproject needs a Dokka dev build that
isn't on Maven Central, and the allprojects{} repositories block here had
removed access to it for every subproject.
Verified end-to-end against a real kotlin checkout: fresh plugin build,
JSON generation (common+jvm only), and sync into a scratch documentation.db
all succeed, with blacklist pruning verification passing.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Documents what OfflineDocumentationTools is (the offline tooling that builds/edits the documentation database consumed by Code on the Go), how its pieces relate to each other, and — most importantly — the gap between the schema this repo's tools expect and the schema the current production documentation.db actually has (added templateId/Templates/ Bookshelf/BookCategories/PUCC_* tables, dropped the legacy ide_tooltip_table). Co-Authored-By: Claude Sonnet 5 <[email protected]>
CI counterpart to run_e2e_pipeline_test.sh: builds kotlin-stdlib/-reflect/-test JSON docs and the Kotlin website docs, then loads both into a Drive-hosted copy of documentation.db and uploads the result back to the same file. Google Drive file IDs (database and Writerside's webHelpImages.zip) resolve from secrets/inputs by default, with hard-codeable TEST_*_FILE_ID overrides for one-off manual testing. Posts "Grabbing baton"/"...Dropping baton" to Slack (SLACK_WEBHOOK_URL) around the run, since it mutates a single shared Drive file. Defaults to dry_run so a first run never touches production. Co-Authored-By: Claude Sonnet 5 <[email protected]>
…739) Adds the code that loads converted Kotlin website content (build_nav.py, populate_db.py, media insertion) and kotlin-stdlib/-reflect/-test JSON content (sync_kdoc_json_to_db.py) into documentation.db, the local e2e test script for that pipeline, and the CI workflow that runs it end-to-end against a Drive-hosted copy of the database. Split out of the larger Kotlin-docs pipeline PR (#21) so the DB-manipulation side (this ticket) can be reviewed separately from producing the raw JSON data for the Kotlin website (ADFA-5039, PR #23). This PR depends on ADFA-5039 merging first - populate_db.py, build_nav.py, and find_missing_assets.py all import md_to_json.py, which isn't included here. Co-Authored-By: Claude Sonnet 5 <[email protected]>
hal-eisen-adfa
left a comment
There was a problem hiding this comment.
Review scoped to the files that are NEW in this PR. I deliberately skipped populate_db.py, build_nav.py, insert_optimized_media.py, sync_kdoc_json_to_db.py and build-kotlin-docs.yaml — I verified by blob hash that those are byte-identical to #21, where I've already left 11 findings. Everything below is specific to this PR.
Blocking (2):
CLAUDE.mdmakes a factual claim abouttemplateIdthat the same commit falsifies — and reasons from it to a scope conclusion.nav.pebemits anav-hiddenclass that nothing consumes, so entries meant to be hidden render visible.
Non-blocking (3) — real, but lower severity or arguably intentional; flagged for your judgment rather than as merge blockers.
Context worth stating plainly: this PR is a clean split from #21, and the split itself is well executed — README.md correctly documents the ADFA-5039 dependency, and I checked that this PR is not shipping a stale pre-fix version of anything. A number of things I went looking for came back clean: build-stdlib-json-docs.sh's restore trap genuinely fires on both failure and SIGINT (verified with timeout -s INT) and is registered before the file swap, so there's no race window; optimize_media.py handles pngquant failures and animated images with explicit fallback-and-log rather than silent data loss, and returns a non-zero exit on errors; docs.css/sidebar.js/tabs.js/nav.peb agree on breakpoints and class names; and the README's documented CLI invocations match the argparse definitions exactly.
I also considered and did not file the page.peb "lone <tab>" workaround here — the root cause is md_to_json.py's tag regex, which isn't in this PR, and I've already filed it as blocking on #23.
| scope for this repo. (A repo-wide search for `templateId`, `Templates`, `Bookshelf`, | ||
| `BookCategories`, or `PUCC` turns up zero matches outside `WebServer.kt` itself, which is | ||
| consistent with that division of responsibility.) Concretely, relative to the schema above: |
There was a problem hiding this comment.
Blocking — this claim is false as of the commit that adds it, and it's load-bearing.
The parenthetical asserts a repo-wide search for templateId "turns up zero matches outside WebServer.kt", and the surrounding sentence uses that to conclude the concern "is not a gap to fill; it's out of scope for this repo."
But this same commit adds populate_db.py, which references templateId at lines 87, 98, 145, 261, 266, 276 and 286 — including the actual insert:
INSERT INTO Content (path, languageID, content, contentTypeID, templateId) VALUES (...)and insert_optimized_media.py, which filters on it at lines 214-215 and 256 (... WHERE ... AND templateId != 0). sync_kdoc_json_to_db.py:13 mentions it too.
So the grep this sentence invites the reader to trust returns numerous in-repo hits the moment this PR lands.
What makes this worth blocking rather than a doc nit: CLAUDE.md exists to orient future readers and agents, and this isn't a stale aside — the false premise is used to justify a scope boundary. An agent reading this will conclude the repo doesn't touch templateId and may "helpfully" strip it from exactly the INSERT that needs it.
Suggest either dropping the parenthetical or narrowing it to the Templates/Bookshelf/BookCategories/PUCC names, which I did not find outside WebServer.kt.
There was a problem hiding this comment.
Fixed in 66da59d - dropped templateId from the "zero matches outside WebServer.kt" list, keeping only Templates, Bookshelf, BookCategories, PUCC (the names that actually are absent), and added a clarifying note that populate_db.py/insert_optimized_media.py do read/write templateId directly since it's a plain column on Content they populate.
| </nav> | ||
|
|
||
| {% macro renderNavNode(node) %} | ||
| <li class="nav-item{% if node.hidden %} nav-hidden{% endif %}"> |
There was a problem hiding this comment.
Blocking — the nav-hidden class is inert, so hidden nav entries render fully visible.
This line emits nav-hidden for nodes the tree marks hidden, and the header comment at line 10 documents hidden as "true for entries Writerside hides from the primary tree." But nothing anywhere consumes the class. Verified:
grep -c 'nav-hidden' assets/docs.css -> 0
grep -n 'hidden' assets/sidebar.js -> (no matches)
grep -c 'nav-hidden' templates/nav.html -> 29
I read docs.css in full (227 lines): the only display: none rules are for .nav-item > .nav-subtree, .nav-toggle and .nav-backdrop — none for .nav-hidden. And page.peb links only /assets/docs.css, so there's no other stylesheet that could supply the rule, and nothing injects CSS elsewhere.
Concrete effect: the committed nav.html already carries 29 nav-hidden entries — the Kotlin tour steps, e.g. "Hello world" — and every one of them shows in the sidebar identically to a normal entry. Writerside hides those individual tour steps from the primary nav by design, so the shipped sidebar gets cluttered with dozens of entries that were never meant to be listed.
One .nav-hidden { display: none; } rule in docs.css closes it. Worth deciding deliberately though — if these should actually be visible, the class and its comment are the thing to remove.
There was a problem hiding this comment.
Fixed in 66da59d - added .nav-hidden { display: none; } to assets/docs.css, matching the documented intent ("entries Writerside hides from the primary tree"). Went with hiding them rather than removing the class.
| design plus a flowchart image; there is no code here yet. The actual implementation (the Dokka | ||
| `JsonRenderer`/`ModelMapper`/`LinkPostProcessor` plugin, its test suite, and the | ||
| `kotlin-stdlib-docs` build scripts) exists only on the unmerged branch **`fix/ADFA-4514`**. That | ||
| branch's diff against `main` also shows it removing recent `docdb-studio` work and all of | ||
| `scripts/pdfjs/` — almost certainly because the branch was cut before those were added and hasn't | ||
| been rebased, not because it intends to delete them. **Flagged: rebase `fix/ADFA-4514` onto |
There was a problem hiding this comment.
Non-blocking — this section describes a branch state that is already out of date.
The text says Dokka-plugin-kdoc2json/ on main is "just a README.md describing the intended design plus a flowchart image; there is no code here yet", that the implementation "exists only on the unmerged branch fix/ADFA-4514", and flags "rebase fix/ADFA-4514 onto current main before merging". Line 183 repeats the rebase item in the decisions log.
fix/ADFA-4514 is already merged — 4c6b8aef ("Merge pull request #18 from appdevforall/fix/ADFA-4514") is on main, and git ls-tree -r main shows JsonOutputPlugin.kt, JsonRenderer.kt, ModelMapper.kt, LinkPostProcessor.kt, the test suite and the kotlin-stdlib-docs build scripts all present on main today.
So a reader is told a whole plugin implementation is missing from main and that a rebase is still outstanding, when both are resolved.
I've marked this non-blocking because it's a point-in-time note that was presumably true when drafted, and unlike the templateId claim above nothing reasons from it. But it's the kind of staleness that a repo-orientation doc is specifically supposed to avoid, and it'll mislead the next agent that reads it.
There was a problem hiding this comment.
Fixed in 66da59d - rewrote the Dokka-plugin-kdoc2json/ bullet to describe it as merged (fix/ADFA-4514, 4c6b8aef) with the actual files present on main, and removed the now-resolved rebase item from the decisions log (was line 183).
| - `pip install markdown-it-py Pillow scour brotli` | ||
| - `cairosvg` (only needed if an optimized SVG exceeds `--svg-rasterize-threshold`): `pip install cairosvg` |
There was a problem hiding this comment.
Non-blocking — bare pip install / python3, against the repo's own established pattern.
This is the same objection I left as blocking on #23's review_build_json.sh, so flagging it here for consistency rather than re-litigating it.
Our convention (~/.claude/CLAUDE.md) is explicit: "Always use uv for Python project management. Never use pip, pip3, or the uv pip compatibility shim", and "never invoke python/python3 directly outside a uv run context."
In this PR that shows up in several places:
- these two lines (
pip install markdown-it-py Pillow scour brotli,pip install cairosvg) - README lines 53, 56, 59, 72, 90, 109, 115 — every usage example is
python3 <script>.py ... optimize_media.py:47-48(docstring) and:302(a runtimeRuntimeErrortelling the user topip install cairosvg)run_e2e_pipeline_test.shlines 100, 105, 125, 141, 145, 175 — six barepython3invocations
This isn't only a global-preference thing: docdb-studio/ and check-tools/ in this same repo each ship a pyproject.toml + uv.lock and document uv sync / uv run, and docdb-studio/CLAUDE.md says "Dependencies are managed with uv. Always use uv run."
Related and independently worth fixing: scour and cairosvg are not in requirements.txt (unchanged by this PR), so they exist only in these prose instructions.
I've left this non-blocking because these are developer-facing helper scripts and you may have deliberately kept them dependency-light — but if so, that's worth a line in the README saying so.
There was a problem hiding this comment.
Non-blocking, addressed in 66da59d rather than just documented as intentional - converted every pip install/bare python3 reference (README usage examples, optimize_media.py's docstring + RuntimeError message, and all 6 invocations in run_e2e_pipeline_test.sh) to uv run --with-requirements <repo-root>/requirements.txt, matching the review_build_json.sh fix on #23. Also added scour/cairosvg to requirements.txt since they were only ever in prose.
| except Exception as exc: # noqa: BLE001 - surface which file broke, keep auditing the rest | ||
| print(f"error scanning {md_path}: {exc}", file=sys.stderr) |
There was a problem hiding this comment.
Non-blocking — per-file scan failures are swallowed and the script still exits 0.
The except catches everything from converter.convert_file(...), prints to stderr, and continues — but nothing counts the failures, and main() has no sys.exit(1) path except the directory-validation check at line 82. So if every file in the corpus raised, this still exits 0 and prints a report claiming nothing is broken.
That matters because of how the script is positioned: it's step 1/5 in run_e2e_pipeline_test.sh:100, and the README describes it as the pre-flight gate where you "fix anything broken in the source before converting it." set -euo pipefail in the shell script can't help — exit 0 is exit 0.
Same shape I flagged as blocking on populate_db.py:483 (#21) and md_to_json.py:607 (#23). I've scored it lower here because this tool only writes an advisory markdown report and doesn't mutate the database, so a bad run misleads rather than corrupts.
Counting failures and exiting non-zero — or at minimum printing a N files failed to scan line into the report itself — would make a broken run distinguishable from a clean one.
There was a problem hiding this comment.
Fixed in 66da59d - added a failure counter and --allow-failures flag mirroring md_to_json.py's pattern from #23: sys.exit(1) if any file failed to scan (unless the flag is passed), and a "N file(s) failed to scan" line (with an explicit "this report is incomplete" marker when non-zero) in the emitted report itself. Covered by new tests in tests/test_find_missing_assets.py, which stub the not-yet-merged md_to_json import via PYTHONPATH since that module isn't on this branch until #23 lands.
Blocking: CLAUDE.md's templateId "out of scope" claim was falsified by this same PR (populate_db.py/insert_optimized_media.py both read/write it) - narrowed the claim to the names that are actually absent. The nav-hidden class nav.peb emits was inert (no consuming CSS rule), rendering Writerside-hidden nav entries (e.g. individual tour steps) visible - added the missing docs.css rule. Non-blocking: rewrote the Dokka-plugin-kdoc2json bullet (and decisions log) to reflect that fix/ADFA-4514 is merged, rather than describing it as an outstanding rebase. find_missing_assets.py swallowed per-file scan failures and always exited 0, so a totally broken corpus still looked clean - added a failure counter, --allow-failures flag, and a report line, mirroring md_to_json.py's pattern from #23. Converted README.md, optimize_media.py, and run_e2e_pipeline_test.sh from bare pip/python3 to uv run --with-requirements, and added scour/cairosvg to requirements.txt, matching the repo's established uv convention. Co-Authored-By: Claude Sonnet 5 <[email protected]>
|
Pushed 66da59d addressing your review. Replied inline to each of the 5 line comments individually; summary here. Blocking (2)
Non-blocking (3)
TestsAdded Verified the |
Sent prematurely, apologies
# Conflicts: # ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/README.md # requirements.txt
Its own read_text(encoding="utf-8") sat outside any try/except, so a malformed file raised uncaught and killed the whole process - bypassing --allow-failures entirely, unlike every other file read in this script. Now catches, counts, and reports it the same way the main conversion loop already does; find_include_warnings returns (warnings, failed) and main() folds that count into the existing failure total. Co-Authored-By: Claude Sonnet 5 <[email protected]>
populate_db.py trains a zstd fast-cover dictionary (256 KiB) from this run's own pages/nav on first use and stores it in a new CompressionDictionary table, then compresses every page/nav/image/asset row against it via the brotli CLI's -D flag (the installed Python brotli package has no dictionary API). Never retrains an existing dictionary: a dictionary-compressed row is only decodable against the exact dictionary it was compressed with, verified empirically to fail silently-wrong rather than loudly on a mismatch, so retraining would orphan every already-migrated row. insert_optimized_media.py rewrites the same rows populate_db.py writes (image optimization, in-place URL rewrites), so it now loads and reuses the same dictionary instead of the old plain-Brotli calls it would otherwise silently corrupt those rows with. ADFA-5153.
populate_db.py and insert_optimized_media.py only ever touch their own subset of Content (k/html/%, assets/%). Every other Content row -- reference docs, tooltip-linked pages, whatever else -- was still plain Brotli, no dictionary. migrate_content_to_dictionary_brotli.py recompresses every remaining 'brotli' row against the shared CompressionDictionary (training one from a representative whole-corpus sample if none exists yet), so the "every brotli row uses the dictionary" assumption WebServer.kt's reader depends on actually holds. Idempotent by construction: a plain decode reliably fails once a row is already dictionary-compressed (verified over 200 trials), so re-running is always a safe no-op. Backs up first (VACUUM INTO), runs in one transaction. Run against the real documentation.db: 29,748/29,751 brotli rows migrated, 131.1MB -> 85.6MB compressed, 299.0MB -> 255.3MB overall. ADFA-5153.
Every 'brotli' Content row in the real database is now compressed against the shared CompressionDictionary (see the prior two commits), but docdb_studio.py still read and wrote plain Brotli in three places: get_html_anchors_for_path, fetch_content_for_path (both decode), and compress_for_storage via import_content_files (encode). Against the migrated database this wasn't a latent risk -- it was already broken: a plain decode of dictionary-compressed content reliably fails, so anchor validation and content preview were silently erroring on every real page, and any new import would have written dictionary-incompatible plain Brotli back into a database that assumes there is none left. get_compression_dictionary(db_path) reads and caches a database's CompressionDictionary (or None, for a database that predates ADFA-5153) -- docdb-studio never creates or retrains one itself, only ever reads whatever another tool already produced. compress_for_storage/decompress_brotli shell out to the brotli CLI's -D flag when a dictionary is present, matching populate_db.py's approach, and fall back to the plain brotli package otherwise. decompress_brotli deliberately raises brotli.error on failure so the two existing call sites' `except brotli.error:` handling didn't need to change. Verified against the real (migrated) documentation.db: anchor lookup and content fetch both now work on real pages that previously would have errored. ADFA-5153.
Each row's recompress spawns its own `brotli` subprocess, so the ~30,000-row real migration was dominated by process-spawn overhead running strictly sequentially. Retrospective feedback: this should have been parallelized from the start rather than accepting a slow serial run. migrate() now runs reassemble+plain-decompress+dictionary-recompress on a ThreadPoolExecutor (defaults to ThreadPoolExecutor's own min(32, cpu_count+4), tuned for exactly this I/O/subprocess-bound shape); each worker opens its own read-only connection (a single sqlite3.Connection isn't safe across threads) and reuses one DictionaryCompressor per thread rather than one per row. The actual delete+insert writes stay serialized on the caller's connection, which SQLite requires anyway. Measured 3-6x faster than sequential on synthetic benchmarks. DictionaryCompressor gets an atexit safety-net close(), since a per-thread instance has no single call site that can cleanly scope a `with` block around it the way populate_db.py's/insert_optimized_media.py's own single-threaded usage already does. Test fixture switched from :memory: to a real temp file, since worker threads need an actual db_path to open their own connections against - an in-memory database has none and can't be shared across connections at all. ADFA-5153.
This is the pipeline that actually produces the live documentation.db (scripts/DocumentationDatabase.py, fixed earlier on this ticket, turned out to be dead code -- its tag-triggered workflow hasn't fired since db-2025-07-16b). populate_db.py has always run its own bare VACUUM with no page_size pin, so the real fix belongs here. Extracted vacuum_and_pin_page_size(), mirroring docdb_studio.py's vacuum_database(): pins page_size via PRAGMA before VACUUM, and works around WAL journal mode silently preventing PRAGMA page_size from taking effect (this file's own backup_database docstring already anticipates a live/WAL-mode database). Co-Authored-By: Claude Sonnet 5 <[email protected]>
vacuum_and_pin_page_size (commit b203500) mirrored docdb-studio.py's original vacuum_database(): in-place VACUUM + a journal_mode round-trip, which requires exclusive access to db_path. SQLite refuses to switch a WAL-mode database away from WAL while ANY other connection has it open -- even one from a function that has already returned, since Python's `with sqlite3.connect(...) as conn:` does not close conn on exit. Empirically reproduced and fixed the identical bug in docdb-studio.py's vacuum_database (PR #25); this mirrors that fix here since this pipeline's own VACUUM is the one actually run against the live documentation.db. Rewritten on VACUUM INTO: rebuild into a temp file next to db_path (read-only snapshot of the source, no exclusive access needed), then atomically swap it into place with os.replace. journal_mode=WAL is reapplied to the new file's final path (VACUUM INTO always produces a plain rollback-journal file), and stale sidecars from the replaced file are cleaned up. Two new tests: the fix succeeds with both an unrelated open connection and an unclosed caller-style connection present at once (the actual scenario the old design was fragile against), and the original file is left untouched if VACUUM INTO fails partway (temp file cleaned up, no partial swap). Co-Authored-By: Claude Sonnet 5 <[email protected]>
tempfile.mkstemp() always creates its file mode 0600 regardless of the original's mode or the process umask. The VACUUM INTO rewrite swaps that temp file into db_path's place via os.replace, which never restored the original permissions -- alexmmiller's QA of the mirrored docdb-studio.py fix caught this silently dropping documentation.db from 644 to 600 on every vacuum; same bug here since this pipeline's vacuum_and_pin_page_size uses the identical mkstemp+replace pattern. Capture db_path's mode before the rewrite and os.chmod it back after the swap. New test confirms a 644 file stays 644 across vacuum_and_pin_page_size (and fails against the pre-fix code, dropping to 600). Co-Authored-By: Claude Sonnet 5 <[email protected]>
…nal_mode-read connection Same fix as the mirrored docdb-studio.py version: VACUUM INTO's target accepts a bound parameter (already used by this file's own backup_database for the same reason), sidestepping SQL string-literal escaping for a path containing a single quote (e.g. "David's Docs") rather than hand-rolling it. Also explicitly closes the journal_mode -read connection instead of relying on it being reassigned by the next `with` block. New test: a quote in db_path's parent directory no longer breaks the statement. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Same findings as the mirrored docdb-studio.py fix's third self-review: - chmod the temp file to the original permissions before os.replace, not after -- fixing it up afterward left a real window where db_path was visible at mkstemp's 0600, and left permissions permanently wrong if the chmod itself failed. - Explicitly close the VACUUM INTO and WAL-reapply connections, and give the WAL-reapply connection the same 30s timeout as its siblings in the same function. 19/19 local tests pass. Co-Authored-By: Claude Sonnet 5 <[email protected]>
…to ADFA-5153 Benchmarking showed page_size=1024 vs 2048 has essentially the same performance and a negligible size difference before compression (and likely less after this PR's dictionary compression) -- adding complexity without benefit. ADFA-5141 is declined; this PR is only about the Brotli dictionary compression (ADFA-5153) and the page_size work rode along on this branch by coincidence of timing, not by scope. Restores populate_db.py's original plain VACUUM call and removes vacuum_and_pin_page_size, SQLITE_PAGE_SIZE_BYTES, the now-unused os/stat imports, and their dedicated test file. Co-Authored-By: Claude Sonnet 5 <[email protected]>
WebServer.kt's reassembly loop always probes "<path>-1" first, but 14 of 19 chunked Content rows in the real documentation.db number their continuations starting at "-2" instead, with no "-1" row at all. The first lookup misses, the loop stops after the base 1 MB chunk, and the row is served short: a corrupt image (compression='none', silent 200) or a decode failure (compression='brotli', 500) - confirmed against a local copy of the shipped database (md5 34c879595bd6fb87e5b68989369680a8). No writer in this tool ever produced that numbering - populate_db.py, insert_optimized_media.py, and migrate_content_to_dictionary_brotli.py all go through insert_chunked_content, which has always started fragments at -1. This is inherited data older than this pipeline, not something it can regenerate correctly by re-running existing tools. renumber_misnumbered_fragments.py finds base rows whose fragment chain (via LIKE, sorted on the parsed numeric suffix rather than assumed paths) doesn't start at 1, and renumbers it to a contiguous run starting at -1, lowest-suffix first so each rename's target is the path just vacated by the previous one. A chain with an actual gap (a genuinely missing chunk, a different failure) is reported and left alone rather than guessed at. Content bytes are never touched, only paths, so it's safe regardless of a row's compression. Verified against a scratch copy of the real database: renumbers exactly the 14 chains the ticket found, and the two example rows (the devsite gif, the Javadoc index) reassemble and decode correctly afterward.
…bering ADFA-5171: Repair chunked Content rows misnumbered from -2
Every finding from the three reviews on PR #26 that lands in files this PR touches, plus the one dictionary-consistency problem outside it that this PR itself creates. Data loss, both silent: * The migration deleted and re-inserted each base row. Content carries AddBook/DeleteBook triggers on '%.pdf' paths, so that cycle replaced every curated Bookshelf entry with 'CURRENT_TIMESTAMP || id' under a fresh Content.id -- verified on the real database: (53507, category 5, "Android Notes for Professionals") became (53508, category NULL, "2026-08-21 22:37:5553508"). 15 brotli-typed .pdf rows and all 7 Bookshelf rows are in scope. Writes are now UPDATE in place, with continuation rows reconciled by exact path. * delete_content interpolated a path straight into LIKE, where `_` is a wildcard and the `-%` suffix was not restricted to digits, so unrelated rows could be deleted permanently (hal-eisen-adfa). No write path goes through LIKE any more. Rows silently skipped while the run reported success: * reassemble_content probed "<path>-1", so an ADFA-5171 chain numbered from -2 reassembled truncated, failed to decode, and was counted as "already dictionary-compressed". The corpus has 29,751 base rows and exactly 3 with continuations; the run reported 29,748 migrated and 3 already-migrated in a first-ever migration, which is precisely those 3. Chain discovery is now shared with the repair script (populate_db.fragment_chain), so the two cannot drift apart again. * Any decode failure counted as "already migrated" (hal-eisen-adfa). Rows are now classified by decoding both ways: identical either way means the encoder never referenced the dictionary and there is nothing to gain (~0.5% of the real corpus, and the reason a second run used to re-migrate them -- alexmmiller); plain-only means migrate; dictionary-only means done; neither is an error, never a success. * Recompressed bytes are verified to round-trip before being written. Concurrency and memory: * Each worker opened its own read connection while the caller held one write transaction over the whole run, which deadlocks under journal_mode=delete -- documentation.db's actual mode (alexmmiller). All database access is now on the calling thread; workers receive bytes. Commits are batched, so an interrupted run keeps finished batches and resumes. * Blobs are no longer selected for every row up front (~130 MB held at once). Dictionary training, measured on the real corpus with only the sampling varied: first 300 rows by path (all under "a/") 36.2% smaller than plain 300 rows stratified across doc sets 33.2% <- worse stratified, 32 MiB plaintext budget 48.3% <- best first-by-path, same 32 MiB budget 36.4% <- volume alone: nil The docstring promised "a random sample drawn across the WHOLE Content table" and delivered the first 300 paths alphabetically -- 299 of them under "a/", while j/ (10,326 rows) and k/ (3,757) trained nothing (hal-eisen-adfa). Fixing it by stratifying alone makes things worse: quotas drawn from smaller doc sets starve the trainer, which then cannot even fill a 256 KiB dictionary. Both halves are needed, so sampling is now stratified by stored bytes and bounded by a plaintext budget, seeded for reproducibility since a stored dictionary is never retrained. renumber_misnumbered_fragments: * A chain numbered from -0 passed the "starts at 1?" guard and renamed onto an occupied slot, tripping UNIQUE(path) and rolling back every other repair in the pass (hal-eisen-adfa). Such a chain is repaired rather than skipped -- the app probes "-1", finds it, and serves the chain with "-0" dropped -- via a parking pass that is correct in either shift direction. docdb-studio: * sqlite3.OperationalError covers "database is locked", and caching that as "no dictionary" downgraded the whole session to plain Brotli (hal-eisen-adfa). Only definitive answers are cached now. * The new `brotli` CLI dependency raised RuntimeError/OSError out of paths that guard only `brotli.error` (hal-eisen-adfa). Missing-binary now raises a BrotliCliMissing subclass of brotli.error, with an actionable message. * decompress_brotli decoded dictionary-only, so it could not read plain rows -- which a dictionary database always contains: anything a plugin contributes on-device, anything written outside populate_db.py, and everything mid- migration. It now falls back to a plain decode, as WebServer.kt does. sync_kdoc_json_to_db (outside this PR's diff, but this PR is what makes documentation.db a dictionary database): * compress_for used plain brotli.compress, leaving every k/kotlin-stdlib row plain inside a dictionary database (hal-eisen-adfa). It now compresses against the database's dictionary when there is one. * "Source file missing => delete the row" had no floor: a Dokka layout change makes every lookup miss, and the script would delete every stdlib row plus its parent Tooltips and exit 0 (hal-eisen-adfa). Sources are resolved up front and a wholesale miss aborts. Corrected in populate_db's DictionaryCompressor docstring, because two reviews reasoned from it: the two mismatch directions are not alike. Decoding a dictionary row with NO dictionary is loud (398 of 400 real rows raised, 2 returned identical bytes, none wrong), which is what makes both this script's idempotency check and WebServer.kt's fallback sound. Decoding with the WRONG dictionary is the silent case (50% raised, 38% returned different bytes with no error, 12% identical). The test asserting a wrong-dictionary decode does not raise was asserting that coin flip; it now asserts the invariant that holds. Tests: 25 in ProcessKotlinWebsiteJSON (up from 21) and 173 in docdb-studio (up from 170) pass. New coverage for the -2 chain, an undecodable row, Bookshelf survival through the triggers, a never-referenced-dictionary row across two runs, stratified sample determinism and spread, zero-based renumbering, one bad chain not blocking other repairs, a locked database not being cached, a plain row in a dictionary database, and a missing brotli CLI. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Review: database-insertion pathFocused pass over the files that write to
Two things that are right, up frontContent types are handled correctly throughout. Every type is resolved by value through The big Findings1. HIGH — a conversion failure silently drops a live page and leaves a normal-looking nav link to it.
This is a regression against the standalone path — Minimum fix: 2. MEDIUM-HIGH — duplicate
3. MEDIUM — It deletes every Related blind spot: only page/nav rows are scanned, so an image referenced from 4. MEDIUM — It does 5. MEDIUM — unknown
6. LOW-MEDIUM — delete-after-insert in the rename loop can remove a just-inserted row. Inserts run first, then 7. LOW — unescaped LIKE wildcards.
8. LOW —
9. LOW — the three scripts back up two different ways. Nits
Merge orderThe PR body's caveat holds — |
Validated against ~/documentation.db (schema 2.0.0), now the source of truth.
populate_db.py
- A failed .md conversion left its stem in topic_index_db, so nav rendered
an ordinary, normally-styled link to a page the run had just deleted and
not replaced. Drop the stem (matching the blacklist path, so references
render as styled-broken) and refuse to modify the database at all unless
--allow-conversion-failures is passed - CI uploads this database straight
to production.
- Two same-stem .md files in different topics/ subdirectories both mapped to
k/html/<stem>, colliding on Content.path's UNIQUE constraint and aborting
the transaction mid-run. Defer to the keep-first choice build_topic_index
already makes and warns about.
- image_index_db keyed on the full zip entry name while Converter looks
images up by bare filename, so any nested zip entry would silently resolve
as a missing image. Key on the basename, matching Converter and
insert_optimized_media.py's own flattening, and warn on collisions.
insert_optimized_media.py
- delete_unreferenced_media deleted every image no page referenced, with no
floor check: run against a database whose k/html pages don't exist yet and
it wiped the entire image corpus, including rows inserted seconds earlier
in the same transaction. Raise instead when images are stored but nothing
references any of them, and document that CSS/template references are not
scanned.
- Added --dry-run (the most destructive of the three scripts was the only
one without one): does the whole run, then rolls back.
- Moved the renamed-away delete loop above the insert loop. With inserts
first, a rename whose new name equals another rename's old name deleted
the row just written - the chain-rename hazard rewrite_pages already
guards against for text substitution.
- delete_content built a LIKE pattern from a path without escaping, so "_"
and "%" acted as wildcards; NAV_CONTENT_PATH ("k/html/_nav.html") already
contains one. Escape via a new like_escape() and ESCAPE '\'.
sync_kdoc_json_to_db.py
- Wrote plain Brotli into a database whose every brotli row is compressed
against the shared CompressionDictionary (schema 2.0.0, ADFA-5153),
producing content the server cannot decode. Read the dictionary and
compress against it, falling back to plain Brotli only for older
databases; never create or retrain one. Needs the brotli CLI, now
installed in both workflows.
- Ignored the CHUNK_SIZE fragmentation contract: UPDATEd the full blob into
one row and deleted existing fragments individually. Split oversized
results into "<path>-N" continuations the way populate_db.py does, and
treat existing fragments as part of their base row.
- An unresolvable contentTypeID fell back to "uncompressed" and committed,
writing bytes that contradict the row's declared type. Now fatal.
- Backup used shutil.copy2; switched to VACUUM INTO, matching the other two
scripts and safe against a live database.
Also: corrected the now-stale claims that documentation.db ships without an
image/webp ContentTypes row (it has one, id 26) and that scour/cairosvg are
absent from requirements.txt; gitignored the timestamped *.db.backup-*/
*.db.bak.* files the three scripts write.
CLAUDE.md records the one review finding NOT fixed here: populate_db.py and
insert_optimized_media.py are still plain-Brotli and so broken against a
2.0.0 database. That fix already exists on fix/ADFA-4737 via merged PRs #26
and #27; reconciling with that branch is the right way to pick it up rather
than hand-porting it into a conflict.
Adds 30 regression tests covering each fix, including a dictionary
round-trip. Verified end-to-end on a copy of ~/documentation.db: 3,238 rows
rewritten, 12/12 sampled rows decode against the dictionary, untouched rows
unaffected.
Co-Authored-By: Claude Opus 5 <[email protected]>
…line) Brings in the ADFA-5153/ADFA-5171 work merged to fix/ADFA-4737 via PRs #26 and #27, which this branch forked from #21 too early to receive. Without it the pipeline cannot run against the current production database at all: ~/documentation.db is schema 2.0.0, every "brotli" Content row is compressed against the shared 256 KiB raw LZ77 dictionary in CompressionDictionary, and plain Brotli cannot decode any of it (measured: 0 of 24 sampled rows). Conflict resolution - all twelve were add/add, so each was decided per file rather than 3-way merged: Took theirs (the dictionary lineage is strictly ahead on these three), then re-applied this branch's review fixes on top: - populate_db.py: DictionaryCompressor, train/load_or_create_dictionary, fragment_chain, page_size pinning. Re-applied the conversion-failure abort, the same-stem dedupe, and the basename-keyed image index. - insert_optimized_media.py: dictionary-aware reads/writes. Re-applied the delete_unreferenced_media floor check, the delete-before-insert ordering, and --dry-run. - sync_kdoc_json_to_db.py: DictionaryBrotli, load_compression_dictionary, MAX_DELETE_FRACTION. Re-applied CHUNK_SIZE fragmentation, the fatal unknown-contentTypeID, and the VACUUM INTO backup. Took ours (PR #23/#24 refined these after the split): md_to_json.py, find_missing_assets.py, optimize_media.py, assets/docs.css, README.md, run_e2e_pipeline_test.sh, .gitignore. Hand-merged: build-kotlin-docs.yaml (our corrected requirements/webp comments plus their brotli-CLI rationale); CLAUDE.md (ours, with the 2.0.0 blocker note rewritten as a description of how the three writers now handle the dictionary, since the merge resolves it). Two of this branch's own fixes were dropped as superseded: - like_escape/ESCAPE '\' is replaced by fragment_chain, which does the over-matching LIKE once and re-checks each candidate's digit suffix. That also handles ADFA-5171 chains numbered from -2, which escaping does not. sync_kdoc_json_to_db.fragment_paths was rewritten to match rather than probing "-1" and stopping at the first gap. - The hand-rolled DictionaryCompressor added to the sync script last commit is replaced by theirs. Tests updated for the merged APIs (collect_referenced_media and delete_unreferenced_media now take a compressor; DictionaryBrotli is compress-only, so its tests decode through the brotli CLI). 105 pass: 78 in ProcessKotlinWebsiteJSON, 27 in scripts/sync_kotlin_stdlib_docs. Verified against a copy of ~/documentation.db: 3,238 stdlib rows rewritten, 12/12 sampled decode against the dictionary, untouched trees unaffected, row count unchanged at 30,649. Co-Authored-By: Claude Opus 5 <[email protected]>
Brings this branch's copy of md_to_json.py up to PR #23's tip. It had been stale since the 1cf41d2 merge: 4fddb34 ("Fix 10 issues from Hal's automated corpus review") and 1d7f6a7 ("Fix 6 latent defects from the md_to_json.py code review") both landed on fix/ADFA-5039 afterwards, and populate_db.py, build_nav.py and find_missing_assets.py all import this module directly. md_to_json.py and tests/test_md_to_json.py are now identical on both branches. One conflict, in requirements.txt, resolved as the union: this branch added scour/cairosvg for optimize_media.py, #23 pinned markdown-it-py>=2.0. 158 tests pass (131 in ProcessKotlinWebsiteJSON, 27 in sync_kotlin_stdlib_docs). Co-Authored-By: Claude Opus 5 <[email protected]>
The workflow had never executed anywhere (0 runs on GitHub, and its own test
plan unchecked). Running it under act surfaced two blockers that stopped
step 4/5 dead, plus a batch of smaller issues from a review pass.
Step 4/5 could not build, for two independent reasons:
* kotlin-stdlib-docs' plugins:dokka-samples-transformer-plugin and
plugins:dokka-version-filter-plugin each hardcode jvmToolchain(8), and
dokkaGenerateModuleJson depends on dokkaGeneratePublicationHtml, so their
dependencies must resolve even though this build only wants JSON. With
only the JDK 17 the workflow installs, Gradle died at task-graph
resolution ("Cannot find a Java installation ... {languageVersion=8}").
The injected build.gradle.kts now retargets those subprojects at the
running JVM, so the pipeline needs exactly one JDK.
* kotlin_big resolved kotlin-stdlib at the checkout's own
defaultSnapshotVersion from <kotlin-root>/build/repo - artifacts that only
exist after a full local build of the kotlin repo, published nowhere
public. -PkotlinLibsRepo/-PdeployVersion were honoured only under
TeamCity; they are now ordinary Gradle properties, plumbed through
build-stdlib-json-docs.sh, both workflows, run_e2e_pipeline_test.sh and
the act runner as kotlin_libs_repo/kotlin_libs_version (default 2.4.10).
kotlin_big already declares mavenCentral(), so a released version needs no
repo override. Documenting the stdlib now takes ~3m instead of a Kotlin
rebuild.
Also:
* run-build-kotlin-docs-with-act.sh drove build-kotlin-docs.yaml, whose WIF
auth can never succeed under act, and demanded all five Drive/Slack
secrets up front. It now drives build-kotlin-docs-local.yaml, needs no
secrets, takes host paths and bind-mounts them, and passes
--container-daemon-socket - (colima cannot bind-mount its socket).
* New skip_stdlib_docs input, the mirror of skip_website_docs; setting both
is rejected. Skips the slowest half when iterating on website content.
* The ADFA-4737 blacklist was spelled out twice per workflow - once applied,
once verified - so the verification could drift onto a different list and
still report PASS. Now one BLACKLISTED_ELEMENT_TITLES job env var read via
mapfile at both sites, matching what run_e2e_pipeline_test.sh already did.
* populate_db.py took its ~250MB VACUUM INTO backup before conversion, i.e.
before the last step that can still refuse to write, so every failed run
left a full copy behind. Moved below the refusal.
* populate_db.py ran pngquant over every PNG on insert, all of which
insert_optimized_media.py replaces moments later - measured on the live
corpus, 0 PNG rows survive step 3 (161 become .webp, the rest are
.svg/.gif). Removed; step 2 went 35.4s -> 14.5s.
* Documented that act does not apply workflow_dispatch input defaults, which
inverts dry_run and would write the database back over db_path.
Verified under act against a copy of the real 248MB documentation.db:
website half 267 pages / 161 webp / blacklist PASS, stdlib half 3132
kotlin-stdlib rows, sampled rows decode against the shared Brotli dictionary
and fail under plain brotli. 158 + 189 tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
| Website half | 267 pages, 217 image rows (161 .webp), 3 assets, page.peb/nav.peb upserted, PASS: all 38 blacklisted topic page(s) confirmed absent |
| Stdlib half | Step 4/5 3m15s, Step 5/5 37s, 3132 k/kotlin-stdlib/* rows |
| Compression | sampled page, nav and stdlib rows all decode with brotli -D against the stored 256 KiB dictionary and fail under plain brotli — i.e. genuinely dictionary-compressed, and the existing dictionary was reused, never retrained |
| Tests | 158 (ProcessKotlinWebsiteJSON + sync_kotlin_stdlib_docs) + 189 (docdb-studio) pass |
Workflow parity is unchanged: 14 byte-identical shared steps, 2 differing only in Drive-specific packages (zip, google-api-python-client & friends), and 14 one-side-only steps confined to the Drive boundary. kotlin_libs_version/kotlin_libs_repo are shared with identical defaults, and Step 4/5's run block is identical in both files.
Worth a reviewer's eye
kotlin_libs_versiondefaults to2.4.10whilekotlin_refdefaults to master, so the out-of-the-box pairing documents 2.4.10 binaries against master's build scripts. It resolves and builds — that's what the run above did — but the coherent production setting pins both together. Deliberate default, flagging it so it's a decision rather than a surprise.- The Java-8 fix compiles those two Dokka plugins for the running JVM instead of 8. They're only ever loaded in-process by Dokka under that same JVM, so this should be inert, but it is a change to how upstream's subprojects get built.
- All five findings from @hal-eisen-adfa's earlier review remain fixed; I re-verified each against the current head while working through this.
hal-eisen-adfa
left a comment
There was a problem hiding this comment.
Code review: 15 findings — 2 critical, 3 high, 10 medium
Reviewed the full main...HEAD diff (38 files, ~10,150 insertions). Inline comments follow, tagged F01–F15. Where a comment says "Reproduced" or "Verified", the failure was actually run against a scratch database or temp directory and the numbers are from that run — not inferred from reading.
The two criticals are the ones I'd want fixed before this merges. Both destroy content and then exit 0 with truthful-looking statistics:
- F01 — two images that collide on a rewritten extension (
logo.png+logo.jpg→logo.webp) silently become one; the loser is gone and its pages render the survivor. On the live path via--webp. - F02 — migrating page
XDELETEs an unrelated page that merely happens to be namedX-1, then reportserrors 0. 122 rows in, 121 out, no diagnostic.
Test coverage for these 15 findings: zero
All 158 tests on this branch pass — 131 in ProcessKotlinWebsiteJSON, 27 in sync_kotlin_stdlib_docs — and not one of the 15 findings above is caught by any of them. That's a deduction, not a guess: each bug reproduces on demand, so if any test constructed the triggering input, that test would be red.
What the gaps have in common is that nothing in the suite feeds these functions data they didn't expect:
| Finding | Input no test constructs |
|---|---|
| F01 | Two source images in one directory that collide on a rewritten extension |
| F02 | An unrelated X-1 page sitting alongside X in the migration input |
| F03 | A second run of build_nav.py over a directory containing its own nav.json |
| F04 | Any assertion on final file size or freelist pages, so a missing VACUUM is invisible |
| F06 / F08 | A fragment chain starting at -2, or one with an interior gap (p-1, p-2, p-4) |
| F07 | An invocation where the input and output directories are the same path |
| F09 | Dokka output containing a page with no pre-existing Content row |
| F10 | A tooltip URI carrying ?query rather than #fragment |
F06 and F08 stand out: renumber_misnumbered_fragments.py was added in this PR specifically to repair chains that start at -2 or have gaps, yet no test builds either shape. The repair tool and the bug it should have caught arrived in the same changeset.
For the two criticals especially, the regression tests are cheap — both reproduce in a handful of lines against a tmp SQLite DB or tmp dir — and both failure modes are permanent, silent data loss, which is exactly where a test earns the most.
Separately: no workflow in this repo runs pytest at all
This PR adds roughly 1,300 lines of regression tests that cover the data-loss paths, and none of them execute in CI. Whatever coverage exists today holds only as long as each person remembers to run it locally. Worth adding a pytest job before the suite grows further — otherwise the tests this PR contributes will quietly rot.
Lower-severity observations (not commented inline)
build_nav.py:64resolves duplicate stems last-wins over an unsortedrglob, whilemd_to_json.build_topic_indexdoes first-sorted-wins with a warning — nav output isn't reproducible across machines.build_nav.py:140render_nodedoes no HTML escaping despite claiming to be "byte-identical to nav.peb's renderNavNode" (Pebble autoescapes); atoc-titlecontaining"breaks out of thearia-labelattribute.populate_db.py:694popstopic_index_dbmid-loop whileConverterholds the same dict by reference, so under--allow-conversion-failuresonly pages converted after the failure get broken-link styling.sync_kdoc_json_to_db.py:276takes theVACUUM INTObackup before theMAX_DELETE_FRACTIONprecheck can abort, so runs that refuse to proceed still write a full-size copy.optimize_media.py:267defaultsloopto0, turning a play-once GIF into an infinite loop.docdb_studio.py:1317's dictionary cache is never invalidated, whichremint_dictionary.py(added in this same PR) can stale out.CLAUDE.mdships stale: says "three workflows" while adding two, points at ascripts/kotlin/build-stdlib-json-docs.shpath that doesn't exist, and claims nothing here writes toTemplateswhilepopulate_db.py:482does.
Checked and cleared
Recording these so nobody re-treads them — all were plausible suspects that held up:
renumber_chain's two-pass parking rename is genuinely collision-free againstUNIQUE(path)in both shift directions.fragment_chain's LIKE over-match plus regex re-check is exact (the_/%tests pass).upsert_template'sON CONFLICT(name)works against the realUNIQUE('name')quirk — confirmed in sqlite3; SQLite parses it as a column constraint.insert_chunked_contentandsync_kdoc'swrite_contentchunk identically and are idempotent across repeated runs.- The two workflow files haven't diverged in any way that weakens the local one (full
diff -u). - The blacklist survives YAML →
mapfile→parse_blacklist_pathintact. - The
build.gradle.ktsDSL changes compile and were exercised on Gradle 8.14.4. PAGE_PEB_STATIC_ASIDEstill matchespage.pebbyte-for-byte, and the DB template rewrite produces the rightdata-nav-src.- Nav ids,
data-nav-id, andsidebar.js's pathname matching line up correctly on the database-served path.
|
|
||
| img = resize_if_needed(img, max_width) | ||
|
|
||
| if webp: |
There was a problem hiding this comment.
F01 · critical · silent data loss
encode_raster rewrites the destination to dst.with_suffix(".webp") (and optimize_svg does the same to .png at L350) with no detection that two different sources now map to one output. logo.png and logo.jpg in the same directory both become logo.webp; whichever one the rglob loop at L438 reaches second silently clobbers the first.
Reproduced end-to-end: exit 0, no warning, both DB rows marked [REMOVED], a single logo.webp inserted, and both pages rewritten to point at it. The JPEG is permanently gone and its pages render the PNG. The same collapse happens when an oversized diagram.svg rasterizes onto an existing diagram.png.
This is on the live path — --webp is passed at .github/workflows/build-kotlin-docs.yaml:301 and run_e2e_pipeline_test.sh:145. The seen_names guard in insert_optimized_media.py:469 cannot help, because the collision already happened in the work dir before that code runs.
Suggested fix: track claimed output paths inside the loop and either disambiguate (logo-jpg.webp) or hard-fail on collision. Silently dropping a source image shouldn't be reachable.
There was a problem hiding this comment.
Fixed in d9df0f0. Reproduced first (errors 0, both DB rows [REMOVED], one logo.webp, the JPEG gone) — thank you for the precise repro.
Went with disambiguation rather than hard-fail: a future upstream image pair shouldn't be able to break the whole pipeline, and the rename flows out through optimize_directory's existing renamed map, so rewrite_pages repoints the stored URLs automatically. Since optimize_raster/optimize_svg own the final extension, I de-conflict the stem up front instead of predicting the suffix: first in sorted order keeps it, later ones fold their original extension in (logo.jpg -> logo-jpg.webp), with a numeric suffix if that still collides. A warning is logged either way.
Checked the real webHelpImages.zip: 299 entries, 0 colliding stems — so this was latent, not live. Covered by test_sources_differing_only_by_extension_both_survive, a 3-way variant, and test_non_colliding_names_keep_their_own_stems so the de-confliction can't start renaming things that didn't collide.
| "VALUES (?, ?, ?, ?, ?)", | ||
| (fragment_path, language_id, blob, content_type_id, template_id), | ||
| ) | ||
| for surplus in sorted(existing - set(wanted)): |
There was a problem hiding this comment.
F02 · critical · silent data loss
write_item deletes everything in existing - wanted, and wanted comes from load_base_rows (L301), which classifies any path matching X-<digits> as a continuation chunk of X whenever X also exists — with no chunk-size test.
So two independent pages that merely happen to be named X and X-1 (neither one chunked) get misread as a single chunked item. Migrating X produces wanted = {}, and this line DELETEs X-1 outright. Worse, the victim was already excluded from base_rows, so it is never scanned, never counted, and never reported.
Reproduced: 122 rows in, 121 rows out, and the run printed scanned 121, migrated 121, errors 0.
Two things widen the blast radius: remint_dictionary.py imports this same write_item, and verify_remint_dictionary.py can't catch it because verify() enumerates from load_base_rows too — same blind spot.
The correct test already exists in this PR: renumber_misnumbered_fragments.py:19 keys on "content length is exactly CHUNK_SIZE". And load_base_rows already SELECTs LENGTH(C.content) without using it. Gating the continuation classification on that length should be a small change.
There was a problem hiding this comment.
Fixed in d9df0f0. Reproduced exactly as you described — 122 rows in, 121 out, scanned 121, migrated 121, errors 0, and the victim never even appeared in load_base_rows.
Both sites needed it, not just the classifier: write_item's existing came from fragment_chain, which matches every X-<digits>, so the DELETE would still have hit the row even with load_base_rows fixed. Both now gate on is_chunked_base — the base row actually being CHUNK_SIZE bytes.
One correction worth recording: my first attempt also required a contiguous walk from -1, which broke test_chain_numbered_from_minus_two_is_migrated_not_miscounted. An ADFA-5171 chain is still a real chain, so the gate is the base length only, exactly as you suggested. Chain membership stays with fragment_chain so -2 still resolves. write_item now resolves existing before the UPDATE, since it's the base row's current length that marks it chunked.
Covered by test_independent_page_named_like_a_fragment_is_not_a_continuation, test_write_item_does_not_delete_an_unrelated_lookalike_page, and test_genuinely_chunked_base_still_owns_its_continuations (the -2 case).
| """Returns (stem -> id, id -> title) built from every generated page JSON.""" | ||
| stem_to_id = {} | ||
| id_to_title = {} | ||
| for json_path in docs_json_dir.rglob("*.json"): |
There was a problem hiding this comment.
F03 · high
load_page_index runs json.loads(...) then page.get("id") over every *.json under docs_json_dir. But main() writes nav.json — a top-level array — into output_dir, and the README documents the invocation (L82) with the output dir as the scan dir:
build_nav.py <docs-root> <output-dir> <output-dir>
So the second run reads its own previous output. Reproduced: AttributeError: 'list' object has no attribute 'get'. Either skip the generated nav.json by name, or guard on isinstance(page, dict).
There was a problem hiding this comment.
Fixed in d9df0f0 — isinstance(page, dict) guard, so anything that isn't a page object is skipped rather than crashing. Covered by test_load_page_index_skips_the_generated_nav_json, which writes both a page and a nav.json array into one directory.
Also took the build_nav.py:64 point from your lower-severity list while I was here: the rglob is now sorted() and duplicate stems resolve first-wins with a warning, matching md_to_json.build_topic_index — which is what the pages themselves were converted against, so the two now agree and the output is reproducible.
| "row(s)). No changes made." | ||
| ) | ||
| else: | ||
| conn.commit() |
There was a problem hiding this comment.
F04 · high
This script rewrites every k/kotlin-stdlib* blob, deletes Content rows plus their fragment chains, and deletes Tooltips/TooltipButtons rows — then just commits. There's no final VACUUM, which every other writer in this repo does: populate_db.py:851, insert_optimized_media.py:507, renumber_misnumbered_fragments.py:166, docdb_studio.py:694.
Verified: a run that shrank content down to 27 bytes left the file at 3,178,496 bytes with 768 of 776 pages sitting on the freelist.
This is step 5/5 of the pipeline, so nothing downstream reclaims it — the bloat ships in the on-device database.
There was a problem hiding this comment.
Fixed in d9df0f0 — trailing VACUUM on its own connection after the transaction closes, same shape as populate_db.py and insert_optimized_media.py, skipped on --dry-run (nothing was committed, so there's nothing to reclaim). Module docstring updated to say so.
| dict_path = _dictionary_temp_path(db_path, dictionary_data) | ||
| try: | ||
| result = subprocess.run( | ||
| [_find_brotli_cli(), "-d", "-D", str(dict_path), "-c"], |
There was a problem hiding this comment.
F05 · high · regression
_find_brotli_cli() is called here while building the argv — i.e. before the plain-Brotli fallback at L1457 can run — and the BrotliCliMissing it raises isn't caught by the surrounding except OSError.
Result: on any database that has a CompressionDictionary, plain-Brotli rows become unreadable without the CLI installed. Those are exactly the rows the docstring calls the "load-bearing, not defensive" fallback: plugin-contributed rows and partially-migrated DBs. brotli.decompress reads them fine, and did read them before this PR. The user gets told to install brotli, which won't fix that row.
Resolve the CLI lazily (or catch BrotliCliMissing alongside OSError) so the fallback stays reachable.
There was a problem hiding this comment.
Fixed in d9df0f0. Confirmed the mechanism: BrotliCliMissing subclasses brotli.error, not OSError, so it went straight past the except OSError and the fallback below was unreachable.
Narrower fix than "catch it alongside OSError", though — that would have made a genuinely dictionary-compressed row fall back to plain decoding and fail with a confusing error, and it breaks test_missing_brotli_cli_is_reported_not_swallowed, which deliberately asserts the missing CLI is reported. So: resolve the CLI first; on BrotliCliMissing, try plain decoding and return it if it works (your case — the plain row), and re-raise the original BrotliCliMissing if it doesn't (a real dictionary row, where "install brotli" is the right advice). Verified both directions by hand.
| # rather than through this repo's own version catalog, so it has to be | ||
| # supplied explicitly - pulled from the same catalog entry the rest of the | ||
| # kotlin repo's Dokka usage is pinned to, so it never drifts out of sync. | ||
| DOKKA_VERSION="$(grep -m1 '^dokka[[:space:]]*=' "$KOTLIN_ROOT/gradle/libs.versions.toml" | sed -E 's/^dokka[[:space:]]*=[[:space:]]*"([^"]*)".*/\1/')" |
There was a problem hiding this comment.
F11 · medium
Under set -euo pipefail (L49), this command substitution kills the script when grep matches nothing — so the friendly if [ -z "$DOKKA_VERSION" ] message at L107-110 is dead code and can never print.
A kotlin ref that renames the dokka catalog key fails "Step 4/5" with exit 1 and zero diagnostic output. Appending || true, or splitting the grep and sed into separate steps, restores the intended message.
There was a problem hiding this comment.
Fixed in d9df0f0 — || true on the substitution, so the if [ -z "$DOKKA_VERSION" ] message below is now reachable instead of the script dying with exit 1 and no output.
| cp "$ORIGINAL_BUILD_GRADLE" "$STDLIB_DOCS_DIR/build.gradle.kts" | ||
| rm -f "$ORIGINAL_BUILD_GRADLE" | ||
| } | ||
| trap restore_build_gradle EXIT |
There was a problem hiding this comment.
F12 · medium
trap ... EXIT doesn't fire on an untrapped fatal signal, which contradicts the header's promise at L15-18 that "the kotlin checkout is left exactly as it was found."
Ctrl-C during the multi-hour Gradle build leaves the swapped-in build.gradle.kts sitting in the developer's kotlin clone and orphans the mktemp original. trap restore_build_gradle EXIT INT TERM HUP covers it.
There was a problem hiding this comment.
Fixed in d9df0f0 — trap restore_build_gradle EXIT INT TERM HUP, so the header's promise that the checkout is left exactly as found now holds for Ctrl-C during the Gradle build.
| for w in nav_warnings: | ||
| print(f"warning: {w}", file=sys.stderr) | ||
|
|
||
| flat_nav = flatten_nav_ids(nav_tree) |
There was a problem hiding this comment.
F13 · medium
flat_nav / id_to_index include nav nodes whose id was synthesized by build_node for an unconverted *.topic (build_nav.py:122), and which therefore have no Content row.
This is already visible in the committed output — templates/nav.html:1285 carries data-nav-id="api-references". Nav rendering colours that node as a non-link, but the pager doesn't: its two neighbours in document order get prev/next rendered by page.peb:41-42 as ordinary pager links to a hard 404.
Filtering synthesized ids out of flat_nav before building the pager would make the two agree.
There was a problem hiding this comment.
Fixed in d9df0f0 — flat_nav is filtered to ids that actually have a page before the pager is built, so prev/next and the sidebar agree on what's reachable. Confirmed your example: api-references is in the committed nav.html as data-nav-id, and its neighbours were getting pager links to a 404.
| rm -f documentation.zip | ||
| echo "DB_SIZE=$(stat -c%s documentation.db 2>/dev/null || stat -f%z documentation.db)" >> "$GITHUB_ENV" | ||
|
|
||
| - name: 'Notify Slack: build started' |
There was a problem hiding this comment.
F14 · medium
The lock signalling is asymmetric. This "Grabbing baton" notification is ungated and fires on every run, while 'Notify Slack: build complete' at L427 carries if: ${{ !inputs.dry_run }} plus the implicit success().
Since dry_run defaults to true (L119), an ordinary run grabs the baton and never drops it — and so does any run that fails somewhere in the 3-hour build. The channel ends up showing a baton held forever by a dead run, which is the exact failure the convention exists to prevent.
Gate the two notifications identically, and consider if: always() on the release so a failed build still drops the baton.
There was a problem hiding this comment.
Fixed in d9df0f0 in both workflows. You're right that the asymmetry was the bug and that dry_run defaulting to true made it the normal case.
Went with if: always() on the release rather than matching the grab's gate: a failed build has to drop the baton too, which a plain !dry_run wouldn't do. Since it now always fires, the message reports the actual outcome — failed / dry run / updated — instead of always claiming an update.
| that migration. Anything else passes through unchanged.""" | ||
| if compression != "brotli": | ||
| return data | ||
| dictionary_data = get_compression_dictionary(db_path) |
There was a problem hiding this comment.
F15 · medium · silent corruption
get_compression_dictionary catches sqlite3.OperationalError — which covers "database is locked" — and returns None. compress_for_storage then reads that None as "this database has no dictionary" and writes a plain Brotli row into a dictionary database. The only signal is a stderr line no GUI user will see. (Not caching the failure limits later calls, but does nothing for the call that's writing right now.)
This is reachable from import_content_files itself: phase 1's orphan DELETE holds a write transaction on connection #1 while the dictionary read opens connection #2 against the same file.
Either propagate the error instead of returning None, or warm the dictionary before opening the write transaction.
There was a problem hiding this comment.
Fixed in d9df0f0. get_compression_dictionary takes strict=, and compress_for_storage passes it: an indeterminate answer now raises instead of being read as "no dictionary" and writing a plain row into a dictionary database.
Kept the lenient default for the read paths deliberately — the existing docstring's reasoning still holds there, and decoding a dictionary row without the dictionary raises loudly rather than returning wrong bytes, so a retry is safe. It's only the write path that can't tell the two apart.
Two of these silently destroyed content and then exited 0 with truthful-looking
statistics; both are reproduced by new tests that fail without the fix.
Critical:
* F01 optimize_media.py: two sources whose names differ only by extension
(logo.png + logo.jpg) both resolved to logo.webp once the encoder rewrote the
suffix, and whichever ran second silently clobbered the first - source gone,
both pages repointed at the survivor, errors 0. Output stems are now
de-conflicted up front ("logo.jpg" -> "logo-jpg.webp"), which flows out
through the existing rename map so stored URLs follow it. Live path: --webp
is passed by both workflows and the e2e script.
* F02 migrate_content_to_dictionary_brotli.py: load_base_rows classified any
"X-<digits>" path as a chunk of X whenever X existed, with no size test, so
two independent pages named X and X-1 read as one chunked item - the victim
was never scanned, never counted, and then deleted as surplus by write_item.
Reproduced at 122 rows in, 121 out, "errors 0". Both sites now gate on the
base row actually being CHUNK_SIZE bytes. Deliberately only that, not a
contiguous walk from -1, so ADFA-5171 chains numbered from -2 still work.
High:
* F03 build_nav.py: load_page_index crashed on its own nav.json (a top-level
array) on a second run, which the documented invocation produces.
* F04 sync_kdoc_json_to_db.py: no trailing VACUUM, so as step 5/5 the space it
freed shipped as bloat in the on-device database.
* F05 docdb_studio.py: _find_brotli_cli() was resolved inside the argv list, so
BrotliCliMissing (a brotli.error, not an OSError) escaped past the plain-Brotli
fallback - a plain row in a dictionary database became unreadable without the
CLI. It now falls through to that fallback, while a genuinely
dictionary-compressed row still reports the missing CLI as before.
Medium: F06 reassemble_content probed from -1 instead of using fragment_chain
(truncating an ADFA-5171 chain); F07 nothing stopped optimize_media from writing
into the directory it was reading, destroying the originals in place; F08 a chain
like p-1,p-2,p-4 short-circuited as healthy instead of being reported as gapped;
F09 Dokka pages with no existing Content row were dropped silently, now counted
and reported; F10 orphan-tooltip cleanup stripped only "#fragment", not "?query",
against this repo's own normalizer; F11 the dokka_version grep killed the script
under set -e, making its own error message unreachable; F12 the restore trap
didn't cover INT/TERM/HUP, so Ctrl-C left a swapped-in build.gradle.kts in the
kotlin checkout; F13 nav ids synthesized for unconverted *.topic pages became
pager links to a 404; F14 "Grabbing baton" was ungated while "Dropping baton" was
gated on !dry_run, so with dry_run defaulting true the baton was never dropped -
both are now symmetric, the release runs on always(), and the text reports the
actual outcome; F15 a lock-induced None from get_compression_dictionary read as
"no dictionary" and wrote a plain row into a dictionary database, now strict on
the write path.
Also from the lower-severity list: build_nav resolves duplicate stems
first-sorted-wins with a warning (was last-wins over an unsorted rglob, so nav
output wasn't reproducible); render_node HTML-escapes its interpolations, which
is what makes it actually byte-identical to autoescaping nav.peb; the
sync_kdoc backup is taken after the MAX_DELETE_FRACTION precheck rather than
before it; and CLAUDE.md's workflow list, build-stdlib-json-docs.sh path and
"nothing here writes to Templates" claim are corrected.
Adds tests/test_review_findings.py: 11 tests over the inputs the review
identified as unconstructed. 8 fail against the pre-fix code; the other 3 are
must-not-regress guards.
Adds .github/workflows/python-tests.yaml, because no workflow in this repo ran
pytest at all - the suites only ran when someone remembered to. Verified under
act: 169 + 189 tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
All 15 findings fixed, with regression tests and a CI job — d9df0f0Thanks for this — the two criticals were both real and both reproduced before I touched anything. Replies are on each thread; this covers the two structural points and what I did not do. On "test coverage for these 15 findings: zero"Fair, and the deduction was right. Added I checked the claim rather than assuming it. Stashing the source fixes and running the new tests against the pre-fix code: The 3 that pass both ways are deliberate must-not-regress guards: non-colliding names keep their stems, a contiguous chain from That last one earned its place immediately. My first cut at F02 gated the continuation test on a contiguous walk from On "no workflow in this repo runs pytest at all"Added Verified under act rather than assuming it works: From the lower-severity listFixed: Not fixed, deliberately, so they don't look handled:
Happy to take any of the three in a follow-up if you'd rather they land here. State358 tests pass (169 + 189, up from 347). Both Kotlin-docs workflows still parse and plan under act, and the parity between them is unchanged: 14 byte-identical shared steps, 2 differing only in Drive-specific packages, 14 one-side-only steps at the Drive boundary. Still outstanding and not from this review: the PR is |
Resolves the six conflicts by keeping this branch's versions, which are the newer side in every case: main last touched all six in e83bb9d (ADFA-5153, #30, 2026-08-24), this branch in d9df0f0 (2026-09-01). Checked before discarding main's side that nothing was actually lost - every main-only line is an earlier form of something this branch has since changed: build-kotlin-docs.yaml the twice-stated blacklist (now one job env var), the pre-kotlin_libs step 4/5 call, and the "Dropping baton" step gated on !dry_run populate_db.py the pngquant pass (removed as redundant - 0 of its PNG rows survive step 3) and the backup taken before the conversion-failure refusal insert_optimized_media.py reassemble_content probing from "-1"; the rest of its diff is indentation from wrapping optimize_directory in try/except ValueError migrate_content_...brotli.py the unguarded "X-<digits>" continuation test sync_kdoc_json_to_db.py the pre-chunking version: copy2 backup, no fragment handling, "#fragment"-only URI split docdb_studio.py get_compression_dictionary without strict=, and _find_brotli_cli resolved inside the argv The merged tree is byte-identical to d9df0f0, and every file main changed without conflicting (docdb-regression-test.yaml, docdb-studio/README.md, remint_dictionary.py, the four dictionary test modules) is already identical here, so this merge records the shared history without changing content. 358 tests pass (169 + 189). Co-Authored-By: Claude Opus 5 <[email protected]>
hal-eisen-adfa
left a comment
There was a problem hiding this comment.
Automated review pass (/code-review xhigh) over the full PR diff vs c627c1b.
14 inline findings below: 1 critical, 5 high, 8 medium. Two follow-up comments on the thread group them by shared root cause — F01/F02/F08/F13 (one chunking protocol, four implementations) and F04/F05/F10 (optimize_media name-planning pre-pass).
A 15th, low-severity finding is not worth an inline comment: renumber_misnumbered_fragments.is_contiguous_from_one is dead code after find_chains was rewritten to check contiguity inline — one grep hit repo-wide (its own definition), no test.
|
|
||
| # Reported, not inserted - see unmatched_source_pages. Printed before the | ||
| # transaction so it shows up even on a dry run. | ||
| unmatched = unmatched_source_pages(args.plugin_output_root, {row[0] for row in all_rows}) |
There was a problem hiding this comment.
F01 · High — unmatched_source_pages() is handed row IDs, not paths
all_rows is SELECT id, path, contentTypeID, languageID, templateId, so {row[0] for row in all_rows} is a set of integer ids. Inside unmatched_source_pages, candidate is a string like k/kotlin-stdlib/kotlin.text/index.html, which can never be in a set of ints — the membership test always fails and every source page is reported unmatched.
Run against normal Dokka output, this step prints warning: 38412 page(s) ... have no Content row and will NOT be inserted, listing pages that are in fact present and being updated. A genuinely new page — the exact condition this check was added to surface — is indistinguishable in that noise.
all_paths ({row[1] for row in all_rows}) is already computed 38 lines above and is what should be passed here.
There was a problem hiding this comment.
Fixed in 46235fb. Straightforwardly my bug — all_rows is SELECT id, path, ..., so that set was integer ids and the membership test could never match. all_paths was right there.
Covered by TestUnmatchedSourcePages, including test_row_ids_would_have_matched_nothing, which pins the actual regression (given ids, every page comes back unmatched) rather than only the fixed behaviour.
| continuations, each carrying the original row's languageID/contentTypeID/ | ||
| templateId. Appends (path, total size, chunk count) to chunked_log for | ||
| anything that needed more than one row.""" | ||
| stale = fragment_paths(cur, path) |
There was a problem hiding this comment.
F02 · High — unindexed full-table LIKE scan for every row updated
write_content() is called once per matched Content row (tens of thousands for kotlin-stdlib), and stale = fragment_paths(cur, path) issues SELECT path FROM Content WHERE path LIKE '<path>-%' every single time. SQLite's default LIKE is case-insensitive, so the UNIQUE(path) index cannot serve it — each call scans the whole Content table. Roughly 30k updated rows over a ~40k-row table is >1e9 row comparisons added by this PR, where the previous code was a single indexed UPDATE ... WHERE id = ?.
The scan is also unconditional: stale can only be non-empty when the existing base row was exactly CHUNK_SIZE bytes, which is essentially never for these pages. Gate it the way migrate_content_to_dictionary_brotli.is_chunked_base does, or reuse the all_paths set main() has already built.
See the standalone comment on F01/F02/F08/F13 — this is one of four divergent copies of the chunking rule.
There was a problem hiding this comment.
Fixed in 46235fb. fragment_paths now delegates to content_chunking.owned_fragment_paths, which resolves the base row's length first — an indexed lookup on UNIQUE(path) — and only reaches the LIKE when that length is exactly CHUNK_SIZE. For these pages it essentially never is, so the scan is gone from the common path entirely.
Your framing of F02 and F08 as the same misunderstanding pointing opposite directions is what made the shared module obviously right rather than just tidier.
| @@ -699,7 +743,17 @@ def main(): | |||
| for w in nav_warnings: | |||
| print(f"warning: {w}", file=sys.stderr) | |||
|
|
|||
There was a problem hiding this comment.
F03 · Medium — the justifying comment is wrong about nav.peb, so the 404 link is only half removed
build_node synthesizes page_id = 'k/html/api-references' for api-references.topic and sets noLinkColor. But nav.peb's renderNavNode branches on {% if node.id %} and emits <a class="nav-link" style="color: ..." href="/k/html/api-references.html"> — a coloured link, not a non-link. build_nav.render_node does the identical thing.
So "nav.peb colours those as non-links" is not accurate, and this fix only removes such nodes from prev/next. A reader who clicks "API reference" in the sidebar still gets a 404 from WebServer.kt.
Either drop the synthesized id for nodes with no Content row (the pager filter right below already computes exactly that set), or make nav.peb render a .nav-group-title when noLinkColor is set.
There was a problem hiding this comment.
Fixed in 46235fb, and thank you for reading the template rather than my comment — the comment was wrong. nav.peb branches on {% if node.id %} and emits a coloured <a href>; only an id-less node reaches <span class="nav-group-title">. So the previous fix removed the 404 from the pager and left it in the sidebar, which is the half a reader actually clicks.
Took your first option: clear the synthesized id for any node with no Content row. That makes nav.peb render it as a group title, and since flatten_nav_ids skips id-less nodes the pager filter falls out for free. noLinkColor is untouched, so the styling is unchanged.
Verified against the live corpus: one such node (k/html/api-references), and afterwards zero nav ids in the stored _nav.html have no Content row.
| rel = src.relative_to(input_dir) | ||
| stem = rel.stem | ||
| candidate = stem | ||
| if (rel.parent, candidate) in claimed: |
There was a problem hiding this comment.
F04 · Critical — de-confliction is scoped per source subdirectory, but the output namespace is flat
claimed is keyed (rel.parent, candidate), so this guard only de-conflicts within one source subdirectory. insert_optimized_media.py addresses every image by bare basename.
Concrete failure: media_dir contains sub-a/logo.png and sub-b/logo.jpg. Both keep the stem logo and, with --webp (what both workflows pass), become sub-a/logo.webp and sub-b/logo.webp. insert_optimized_media's seen_names guard then skips the second with a warning and writes a single row at k/html/images/logo.webp — while build_rename_map maps both logo.png and logo.jpg to logo.webp, and rewrite_pages repoints both pages at the survivor. One image permanently gone, one page silently rendering the wrong picture, exit 0.
populate_db.py was updated in this same PR to flatten and warn on exactly this nested-zip case; optimize_media was not. Key claimed on the predicted output basename, not (parent, stem).
See the standalone comment on F04/F05/F10 for the shared root cause.
There was a problem hiding this comment.
Fixed in 46235fb. Reproduced first — with --webp, sub-a/logo.png and sub-b/logo.jpg both produced logo.webp, and insert_optimized_media's seen_names skipped the second while build_rename_map had already pointed both pages at the survivor.
De-confliction is now keyed on the predicted output basename, matching the flat namespace insert_optimized_media actually addresses — the same namespace populate_db was already modelling. Covered by test_same_basename_in_different_directories_deconflicts.
| sources = [p for p in sorted(input_dir.rglob("*")) if not p.is_dir()] | ||
| dst_rel_for = {} | ||
| claimed = {} | ||
| for src in sources: |
There was a problem hiding this comment.
F05 · Medium — de-confliction fires for collisions that cannot occur
The rename is unconditional, including in runs and for file types where no extension rewrite is possible.
Without --webp (the default), over a directory holding logo.png and logo.jpg: neither encoder changes the extension, so the outputs would be logo.png and logo.jpg with no collision at all — but the stem de-confliction fires anyway and writes logo-jpg.jpg. That lands in renamed, so build_rename_map records logo.jpg -> logo-jpg.jpg, rewrite_pages rewrites every stored /k/html/images/logo.jpg reference, the old row is deleted and a new one inserted. All for a non-collision. Same for two passthrough non-images sharing a stem (notes.txt + notes.md), which are only ever copy2'd.
Condition the guard on whether the extension can actually change (cfg["webp"], or the svg-rasterize path), or predict the output name per file rather than blanket-applying the rename.
There was a problem hiding this comment.
Fixed in 46235fb by the same change as F04 — which is the useful part of your observation that the two are mirror images: keying on the predicted output name fixes both at once, where any source-path key gets one of them wrong.
New possible_output_names() returns the set of basenames process_file could write, given cfg["webp"] and the svg-rasterize path. Without --webp a .png and a .jpg have disjoint possible outputs, so nothing is renamed and renamed stays empty — no stored-URL rewrites, no row churn. Covered by test_no_rename_when_the_extension_cannot_change, which also includes the notes.txt/notes.md passthrough pair.
One deliberate imprecision: the SVG branch genuinely can't be predicted (rasterization depends on the optimized size vs the threshold), so both possible names are treated as claimed. That can de-conflict a pair that wouldn't have collided — which costs a rename, where the reverse costs an image.
| inspecting the return value).""" | ||
| dst.parent.mkdir(parents=True, exist_ok=True) | ||
| suffix = src.suffix.lower() | ||
| original_size = src.stat().st_size |
There was a problem hiding this comment.
F10 · Medium — src.stat() sits outside the per-file try, so one bad entry kills the whole run
optimize_directory collects sources from rglob("*") filtered only by not p.is_dir(), which keeps broken symlinks (is_dir() is False for a dangling link). The first thing process_file does is original_size = src.stat().st_size, outside the try/except that exists precisely to increment stats["errors"] and keep going.
FileNotFoundError propagates out of optimize_directory, and insert_optimized_media's caller only catches ValueError — so the run dies with an unhandled traceback instead of the intended "N file(s) failed to optimize; aborting before touching the database".
Move the stat() inside the try. See the standalone comment on F04/F05/F10.
There was a problem hiding this comment.
Fixed in 46235fb. Both parts: collect_sources() now validates the inventory at collection time with is_file() (which is False for a dangling symlink, unlike not is_dir()), reporting and counting each one; and src.stat() moved inside process_file's try, so a source that disappears mid-run is one file's error rather than an unhandled traceback past insert_optimized_media's ValueError-only catch.
Covered by test_broken_symlink_does_not_abort_the_run.
| # Secrets: none are required. SLACK_WEBHOOK_URL is the only secret this | ||
| # workflow reads, and it is optional - the two "Notify Slack" steps print a | ||
| # skip notice and continue when it is unset. Export it if you want to see | ||
| # them actually fire ("build complete" additionally needs --live, since it is |
There was a problem hiding this comment.
F11 · Medium — this script's docs contradict the workflow change in the same PR
build-kotlin-docs-local.yaml:401 is now if: always(), carrying an explicit comment that it is deliberately not gated on dry_run. This script still tells the user — here in the header, and again in the runtime dry-run notice — that "build complete" "is gated on dry_run=false. Pass --live to write back and see it."
A user who exports SLACK_WEBHOOK_URL and does a dry run will fire a real Slack message they were told would not fire, on a channel where the baton messages are a shared lock convention. Update both strings, or re-gate the workflow step.
There was a problem hiding this comment.
Fixed in 46235fb — updated the header and the runtime dry-run notice to match if: always(). Both now say the notification fires regardless and reports the outcome, rather than telling the user to pass --live to see it.
Chose to correct the docs rather than re-gate the step: the baton has to be dropped on a failed build too, which is what always() is buying.
| # google-api-python-client & friends: Drive download/upload, same | ||
| # libraries check-tools/download_database.py already depends on. | ||
| pip install markdown-it-py scour cairosvg \ | ||
| pip install markdown-it-py \ |
There was a problem hiding this comment.
F12 · Medium — pip in CI against the repo's uv convention, plus a dead install
Two things:
-
pipusage. The convention for this project isuvfor Python dependency management — neverpip,pip3, or theuv pipshim. This PR touches three such steps and leaves them all onpip:build-kotlin-docs.yaml:203/208,build-kotlin-docs-local.yaml:218/221, andpython-tests.yaml:56.uvis already wired up in this repo —python-tests.yamlinstalls it for the docdb-studio job — and the ProcessKotlinWebsiteJSON README documentsuv run --with-requirements ...as the supported invocation. -
Dead install + wrong comment. The edited comment says "markdown-it-py: ProcessKotlinWebsiteJSON's own requirement (see its README); scour/cairosvg are in requirements.txt already" — but
markdown-it-py>=2.0is inrequirements.txton main and was installed by the preceding line all along. The extrapip install markdown-it-pyis dead work and the comment's premise is wrong.
There was a problem hiding this comment.
Both fixed in 46235fb.
The dead install is gone — you're right that markdown-it-py>=2.0 is in requirements.txt and the preceding line already installed it, so the comment's premise was wrong as well as the work being wasted.
On pip: converted all three workflows. Each pipeline workflow now has a job-level $UV_RUN (uv run --with-requirements requirements.txt --, with the Drive workflow adding the google-api deps via --with), mirroring run_e2e_pipeline_test.sh's own UV_RUN array, and every python3 ... step goes through it. python-tests.yaml uses uv run directly. The separate install steps are gone.
Verified by running the local workflow end-to-end under act on the converted form: job succeeded, blacklist verification PASS, 473 k/html/* rows. Incidentally this improved workflow parity — the two now share 15 identical steps with only the apt line differing (zip, Drive-only).
| n = 1 | ||
| while True: | ||
| row = conn.execute("SELECT content FROM Content WHERE path = ?", (f"{path}-{n}",)).fetchone() | ||
| for _n, fragment_path in fragment_chain(conn, path): |
There was a problem hiding this comment.
F13 · Medium — reassemble_content no longer mirrors WebServer.kt's reassembly protocol
The old loop stopped as soon as a fragment came back shorter than CHUNK_SIZE, exactly as WebServer.kt does — and the docstring still claims that mirroring. The new loop concatenates every member of fragment_chain unconditionally, and the if row is None: break is dead code, since fragment_chain only returns paths that exist.
For the "gapped" chain shape that renumber_misnumbered_fragments explicitly refuses to repair — e.g. p-1 (CHUNK_SIZE), p-2 (short), p-4 — this tool now reassembles p + p-1 + p-2 + p-4 while the server serves p + p-1 + p-2. rewrite_pages would then re-compress and store a blob the server never had, or fail to decompress with a message that points nowhere.
Keep the short-fragment terminator and use fragment_chain only for suffix discovery. See the standalone comment on F01/F02/F08/F13.
There was a problem hiding this comment.
Fixed in 46235fb — and this one was mine from the previous round: I replaced the probe-from--1 loop with fragment_chain to handle ADFA-5171 numbering and dropped the terminator in doing it, which trades a truncation bug for a fabrication bug.
reassemble_content now delegates to content_chunking.reassemble, which does suffix-agnostic discovery and then stops at the first short fragment, exactly as you describe. The dead if row is None is gone. Covered by test_reassembly_stops_at_the_short_fragment using your p-1 full / p-2 short / p-4 shape, plus test_ownership_still_includes_the_orphaned_tail to pin that deletion keeps taking p-4 — the two questions the module now separates.
| # without them. | ||
| # | ||
| # The suites are separate because their dependencies are: docdb-studio and | ||
| # check-tools each own a pyproject.toml + uv.lock, while ProcessKotlinWebsiteJSON |
There was a problem hiding this comment.
F14 · Medium — this header explains a three-way split, but only two suites actually run
The comment reads "docdb-studio and check-tools each own a pyproject.toml + uv.lock, while ProcessKotlinWebsiteJSON runs against the root requirements.txt", and the workflow's stated purpose is "Runs the repo's pytest suites on every push and PR". There are three suites' worth of tests in the repo and only two are executed.
check-tools/tests/test_db_health_checker.py — covering the health checker wired into the daily docdb-regression-test.yaml cron — continues to run nowhere. Add a uv run --frozen -- python -m pytest -q step with working-directory: check-tools, matching the docdb-studio step.
There was a problem hiding this comment.
Half fixed in 46235fb, and I want to be explicit about the half that isn't.
The header is corrected — it no longer describes a three-way split while running two.
I did not add the check-tools step, because it would make every PR red immediately: 6 of its 13 tests already fail on main, in code this PR doesn't touch. test_db_health_checker.py expects one issue from each of the empty-content, orphaned-tooltip and broken-URI checks and gets zero:
tests/test_db_health_checker.py:23: assert 0 == 1 (empty_content_issues)
tests/test_db_health_checker.py:33: assert 0 == 1 (orphaned_tooltip_issues)
tests/test_db_health_checker.py:43: assert 0 == 1 (broken_uri_issues)
6 failed, 7 passed
So your premise is right — that suite runs nowhere, and it covers the checker behind the daily cron — but wiring it up needs those fixed first, which is its own change rather than something to smuggle in here. The workflow header now records that, with the exact step to add once they pass. Happy to take it as a follow-up if you'd rather it land before this merges.
F01 / F02 / F08 / F13 — one chunking protocol, four implementationsThese four inline findings look unrelated (a wrong variable, a slow query, a missed repair, a dropped terminator) but they are the same defect: the Content chunking protocol is re-derived from scratch at every call site, and each derivation is wrong in a different way. The protocol
The correct implementation
is_chunked_base(lengths, prefix) # gates on length == CHUNK_SIZE
load_base_rows(...) # uses that gateThat is the reference. It encodes rule 1 explicitly and is the only copy in the repo that does. How each site deviates
F02 and F08 are the same misunderstanding pointing opposite directions: F02 does the expensive lookup when rule 1 says it cannot match, F08 skips the repair when rule 1 says it should have matched. Recommended approachExtract the protocol into one module —
The shared module should be the one place that knows |
F04 / F05 / F10 — one root cause in
|
| ID | Facet | What is assumed | What is true |
|---|---|---|---|
| F04 | Namespace | Collisions are per-directory, so (rel.parent, stem) is a sufficient key |
The consumer namespace is flat — insert_optimized_media addresses every image by bare basename, so sub-a/logo.png and sub-b/logo.jpg both land on logo.webp |
| F05 | Extension | Two files sharing a stem will collide | They collide only if the encoder rewrites both extensions to the same thing. Without --webp, logo.png/logo.jpg never collide, yet logo.jpg is still renamed and every stored URL rewritten |
| F10 | Inventory | Every entry from rglob("*") that is not p.is_dir() is a statable file |
Broken symlinks pass that filter (is_dir() is False for a dangling link), and process_file stats outside the try, so one of them takes down the whole run |
F04 and F05 are exact mirror images, which is the tell that the keying is wrong rather than merely too narrow: F04 misses collisions that will happen (different directories, same output name), F05 invents collisions that cannot happen (same stem, different output names). A key computed from the source path cannot get both right, because it is not the thing that collides.
Recommended approach
Compute the predicted output name once, per source, using the same logic process_file will use to decide the destination extension (cfg["webp"], the svg-rasterize path, passthrough copy), then:
- De-conflict on that predicted name, keyed by basename alone — matching the flat namespace
insert_optimized_mediaactually consumes. This fixes F04 and F05 in one change: names that will genuinely collide get de-conflicted regardless of directory, and names that cannot collide are left alone, so no gratuitousrenamedentries and no needless stored-URL rewrites. - Validate the inventory at collection time — drop and report entries that are not readable regular files — and move the
src.stat()insideprocess_file'stryso a mid-run disappearance is still counted instats["errors"]rather than escaping as an unhandled traceback. That fixes F10.
Worth noting: populate_db.py was changed in this same PR to flatten nested media and warn on basename collisions — i.e. the flat-namespace reality was already recognised on one side of the pipeline. optimize_media needs to model the same namespace, or the two halves will keep disagreeing.
Several of these are regressions from the previous round's fixes; the review
grouped them by root cause, and both groups are addressed at that level rather
than site by site.
Group 1 - one chunking protocol, four implementations (F01/F02/F08/F13)
WebServer.kt reassembles a page by reading `path`, and if that blob is exactly
CHUNK_SIZE, appending `path-1`, `path-2`, ... until a fragment comes back
shorter. Two rules follow: a "<base>-<N>" row is a continuation only if the
base is exactly CHUNK_SIZE (a base merely existing proves nothing - "X" and
"X-1" are legal as unrelated pages), and a short fragment terminates the
chain. Both were being re-derived per call site, each wrong differently.
New content_chunking.py owns both rules and distinguishes the two questions
they answer: owned_fragment_paths (ownership - what a delete or replace must
take, including an orphaned tail past a gap) and served_fragment_paths /
reassemble (what the server actually concatenates). populate_db,
insert_optimized_media, migrate_content_to_dictionary_brotli,
renumber_misnumbered_fragments and sync_kdoc_json_to_db all now call it; the
two duplicate CHUNK_SIZE definitions and eight ad-hoc fragment helpers are
gone.
* F01 unmatched_source_pages was handed {row[0] ...} - integer Content ids -
so the membership test never matched and every source page was reported
unmatched. all_paths, already computed 38 lines above, is what it wanted.
* F02 write_content ran an unindexed "LIKE '<path>-%'" scan per updated row
(~1e9 comparisons across kotlin-stdlib) for a lookup that can only return
rows when the base is CHUNK_SIZE. Now gated on that first.
* F08 find_fragment_paths treated "base exists" as sufficient, so an ordinary
page at guide.html hid a genuinely misnumbered chain at guide.html-1 from
the tool written to repair it. Same defect fixed in sync's is_fragment_path.
* F13 reassemble_content had dropped the short-fragment terminator, so on a
gapped chain it rebuilt a blob the server never serves - which rewrite_pages
would then re-compress and store.
Discovering a chain now depends on the base row's length, so three call sites
that deleted the base before resolving its chain had to be reordered.
Group 2 - optimize_media's name-planning pre-pass (F04/F05/F10)
The pre-pass planned from source-side attributes and never reconciled that
against the predicted output name or whether the source was readable.
* F04 keyed collisions on (parent, stem), but insert_optimized_media
addresses images by bare basename - so sub-a/logo.png and sub-b/logo.jpg
both became logo.webp, one image lost, one page rendering the wrong picture.
* F05 the mirror image: it renamed pairs that could not collide (no --webp
means no extension rewrite), churning stored URLs for nothing.
* F10 "not is_dir()" keeps broken symlinks, and process_file stat()'d outside
its try, so one dangling link killed the run with an unhandled traceback.
Now de-conflicted on possible_output_names() keyed by basename alone, and
collect_sources() drops unreadable entries while stat() moved inside the try.
Remaining findings
* F03 the previous round's comment was wrong: nav.peb branches on
`{% if node.id %}` and renders a *coloured link*, not a non-link, so the
sidebar entry for an unconverted *.topic still 404'd. Clearing the id is
what actually fixes it, and drops it from the pager for free. Verified on
the live corpus: one such node (api-references), zero nav ids now without a
Content row.
* F06 the strict= dictionary lookup ran inside the import's own write
transaction, so a self-inflicted lock aborted the whole import. Resolved
and cached before the write connection opens.
* F07 the INT/TERM/HUP handler restored state but never exited, so Ctrl-C
carried on into the rest of the script and the EXIT trap then re-ran a cp
of an already-removed file. Handlers exit 130; restore is idempotent.
* F09 sidebar.js never checked response.ok, so a 404/500 body was injected
and rendered as the navigation.
* F11 the act script still documented "build complete" as gated on dry_run
after the workflow moved to if: always().
* F12 all three workflows moved off pip to the repo's uv convention (a
job-level $UV_RUN mirroring run_e2e_pipeline_test.sh); the dead
"pip install markdown-it-py" is gone, it having been in requirements.txt.
* F15 is_contiguous_from_one was dead after find_chains was rewritten.
F14 (add check-tools to CI) is NOT done, deliberately: 6 of its 13 tests
already fail on main, in code this PR doesn't touch, so the step would make
every PR red for an unrelated reason. The header now says so and carries the
one-liner to add once they pass.
11 new regression tests over the inputs the review identified; the 5
behavioural ones fail against the pre-fix code. 181 + 189 pass. The local
workflow runs green end-to-end under act on the uv conversion.
Co-Authored-By: Claude Opus 5 <[email protected]>
All 14 findings addressed (13 fixed, 1 deliberately not) — 46235fbBoth root-cause groupings were right, and both are fixed at that level rather than site by site. Several of these were regressions from my previous round; noted individually below. F01/F02/F08/F13 — the chunking protocolNew
One consequence worth flagging, because it bit me: once chain discovery depends on the base row's length, any site that deletes the base before resolving its chain silently stops finding fragments. Three did. Existing tests caught all three — but only after I updated fixtures that chained fragments off 1-byte base rows, a shape that cannot occur and that passed either way. F04/F05/F10 — the name-planning pre-passKeying on the predicted output basename fixes F04 and F05 in one change, which is exactly the payoff of your observation that they're mirror images. New One deliberate imprecision: the SVG branch genuinely isn't predictable (rasterization depends on the optimized size vs the threshold), so both possible names are claimed. That can de-conflict a pair that wouldn't have collided — a rename, where the reverse costs an image. The restF03 — you were right to read the template instead of my comment. F06 was mine (the F14 — not done, deliberatelyYour premise is right: that suite runs nowhere and it covers the checker behind the daily cron. But 6 of its 13 tests already fail on Adding the step would make every PR red for an unrelated reason. The header now records that, with the exact step to add once they pass. Happy to take it as a follow-up if you'd rather it land first. Verification11 new regression tests over the inputs you identified; the 5 behavioural ones fail against the pre-fix code: Plus 181 + 189 tests pass. The local workflow runs green end-to-end under act on the converted uv form — blacklist verification PASS, 473 Workflow parity improved as a side effect of the uv conversion: 15 identical shared steps now, with only the apt line differing ( F15 ( |
Two real defects, both in last round's fixes, plus three smaller ones. content_chunking.served_fragment_paths implemented half its own terminator rule. The module docstring says reassembly stops at the first fragment shorter than CHUNK_SIZE "or the first one missing", but only the short- fragment half was coded, so a chain with a hole jumped it and appended a tail the server never reaches. Reproduced with p-1 (full), p-2 (full), p-3 missing, p-4 (short): it returned p-1,p-2,p-4 and reassembled 3,145,744 bytes where WebServer.kt probes p-3, misses, and serves 3,145,728. That is the same fabrication F13 was raised about, reached from the gap side instead of the short-fragment side, and gapped chains are not hypothetical - renumber_misnumbered_fragments has a category for them it deliberately leaves alone. Contiguity is now enforced, from whatever suffix the chain begins at rather than from 1, so an ADFA-5171 chain numbered from -2 is still readable whole by the migration and repair tooling. owned_fragment_paths is unchanged: a delete still has to take the orphaned tail. The dangling-nav-link fix was applied to the database path only. build_nav.py renders the same nodes through the same `if node["id"]` branch, so its standalone output still emitted live links to pages nothing generates - and the committed templates/nav.html shipped two of them, /home.html and /api-references.html. The pass is now a module-level build_nav.drop_unreachable_ids that populate_db imports rather than reimplementing, since the static site and the database have to agree on what is reachable. nav.html is corrected by hand rather than regenerated: kotlin-web-site has moved on (304 topics vs the 303 it was built from), and a full regen would fold unrelated upstream drift into the artifact. The two entries now match what the fixed generator emits, byte for byte. Also: * optimize_media's output de-confliction keyed claimed names as written, so logo.PNG and logo.png never de-conflicted despite being the same file on a case-insensitive filesystem - the F04 namespace mismatch reached through case. Keys are casefolded now. Latent: it needs the source and output filesystems to differ in case sensitivity, and the pipeline runs on Linux end to end. * content_chunking.split_into_chunks had no callers; the three sites that split blobs still do it inline. Removed rather than wired in - the write half already agrees across all three, and an untested fourth copy is the divergence this module exists to prevent. * "Notify Slack: build complete" ran on a bare always(), so a run failing before "build started" (a bad db_path, a failed download) posted a baton release for a lock it never acquired - F14's asymmetry pointing the other way. Now gated on the acquire step's own outcome. 6 new tests; the 2 that pin the gap terminator fail against the pre-fix code. 187 + 189 pass, and the local workflow is green end to end under act. Co-Authored-By: Claude Opus 5 <[email protected]>
Self-review round: 5 findings, all fixed — a59d777No new reviewer feedback here. After the last round I ran 1.
|
hal-eisen-adfa
left a comment
There was a problem hiding this comment.
Automated review pass (/code-review xhigh) over the full PR diff vs c627c1b, after the two rounds of review fixes landed.
11 inline findings below, F25-F35: 1 critical, 3 high, 7 medium. The numbering continues from the previous round rather than restarting, so no id in this PR ever means two different things.
Three of them - F29, F30, F31 - are items already raised in the 2026-09-01 pass, in the "lower-severity observations" section that carried no inline comment. They were not addressed in either fix commit and no reply explains why, so they are re-raised here inline where they can be tracked.
What I verified, and how: every line number and code shape below was read on a59d777. The suites are genuinely green - ProcessKotlinWebsiteJSON + sync_kotlin_stdlib_docs 187 passed, docdb-studio 189 passed - matching the counts the commit messages claim. I also checked the F14 deferral from last round and it is accurate: check-tools is 6 failed / 7 passed on this branch, all in test_db_health_checker.py, in code this PR does not touch.
What I did not verify: F25 is reasoned from the workflow source and act's documented input handling, not reproduced by running act. It is the one finding I would want confirmed by a run before acting on it - see the note in that comment.
Two lower-severity observations not worth an inline comment:
build_nav.render_nodesays it is "kept byte-identical to templates/nav.peb's renderNavNode macro", but it joins on""while nav.peb puts each element on its own line, so the two outputs cannot actually be diffed to check the claim. Itsindentparameter is accepted at line 182, never read, and not passed down to the recursive call at line 210.find_missing_assets.FENCE_REstrips only triple-backtick fences, so an<include from="...">example inside a~~~fence or an indented code block is reported as a broken reference. The module docstring specifically advertises that fenced examples are ignored; links and images get that for free through markdown-it, only this standalone regex scan does not.
| --input kotlin_ref="$KOTLIN_REF" \ | ||
| --input kotlin_libs_version="$KOTLIN_LIBS_VERSION" \ | ||
| --input kotlin_libs_repo="$KOTLIN_LIBS_REPO" \ | ||
| --input skip_website_docs="$SKIP_WEBSITE_DOCS" \ |
There was a problem hiding this comment.
F25 · Critical - act passes these inputs as strings, so every if: gate in the local workflow inverts.
On real GitHub, a workflow_dispatch input declared type: boolean arrives as a boolean. Under act it arrives as the string you typed. In a GitHub expression a non-empty string casts to true, so !"false" is false.
build-kotlin-docs-local.yaml gates on the negation in all thirteen places:
254,261,265,271,275,288,348: if: ${{ !inputs.skip_website_docs }}
302,309,321: if: ${{ !inputs.skip_stdlib_docs }}
401: if: ${{ !inputs.dry_run }}
So a default run through this script - which sends --input skip_website_docs=false at line 231 - skips Steps 1/5 through 3/5 and 4/5's page rewrite: find_missing_assets, populate_db, insert_optimized_media. It does the stdlib half only. And --live, which sends dry_run=false, makes !inputs.dry_run false, so "Write updated database back to db_path" at :401 never runs and the flag documented as "write the rebuilt database back" writes nothing.
Skipped steps report green, so this does not fail - it silently does less. That matters for the "local workflow runs green end-to-end under act" claim in both fix commits: a green act run is consistent with this bug rather than evidence against it.
The workflow header at build-kotlin-docs-local.yaml:21-27 already reasons about these inputs as strings, but only for the unset case ("an input you don't pass arrives empty"). Passing them explicitly, which this script always does and which that comment recommends, is what triggers the inversion.
I have not run act to confirm this - it is read off the workflow source and act's documented behaviour, so it is the one finding here I would reproduce before acting on it. If it does reproduce, if: ${{ inputs.x != 'true' }} (or fromJSON) is the shape that works under both.
There was a problem hiding this comment.
Reproduced as you asked, and it doesn't hold up — the gates evaluate correctly under act in both directions. Two runs, run-build-kotlin-docs-with-act.sh driving the local workflow:
--live --skip-stdlib-docs (i.e. dry_run=false skip_website_docs=false skip_stdlib_docs=true):
✅ Success - Main Step 1/5: find missing assets
✅ Success - Main Step 2/5: convert and populate
✅ Success - Main Step 3/5: optimize and insert media
✅ Success - Main Write updated database back to db_path
| Wrote updated documentation.db back to /mnt/act-inputs/documentation.db
and the input file's md5 changed, 66b76b42… → 3dfc39b6…, so !inputs.dry_run gated on with dry_run=false passed as a string.
--skip-website-docs: zero occurrences of steps 1–3 in the log. So the same expression gates off when the input really is true.
The reason is that ! in a GitHub expression isn't string-truthiness — it's the documented cast to number, and 'false' casts to 0, so !'false' is true. 'true' casts to NaN, !NaN is false. The string case lands the same way the boolean case does, which is why act and GitHub agree here.
Worth flagging the other direction too: the suggested if: ${{ inputs.x != 'true' }} would break on real GitHub, where the input is a boolean. true != 'true' casts both to numbers — 1 != NaN — which is true, so a step gated that way would run when the flag is set. fromJSON works under both; !inputs.x already does.
Nothing changed for this one. I've added the two log excerpts to the run notes so the next person doesn't have to re-derive it.
| if suffix.lower() == SVG_EXTENSION: | ||
| rasterized = ".webp" if cfg["webp"] else ".png" | ||
| return {f"{stem}{SVG_EXTENSION}", f"{stem}{rasterized}"} | ||
| if suffix.lower() in RASTER_EXTENSIONS: |
There was a problem hiding this comment.
F26 · High - possible_output_names mispredicts the one raster case that keeps its extension, so F04's failure mode is still reachable.
This line claims {stem}.webp for every raster source when --webp is set. But optimize_raster does not convert an animated GIF - resize_animated_gif writes it back as a GIF, and the docstring at line 264 says so explicitly ("--webp is intentionally not honored here").
Concrete failure, with --webp (what both workflows pass): media/A/logo.gif (animated) and media/B/logo.gif (static).
A/logo.gifclaimslogo.webp, but actually writesA/logo.gif.B/logo.gifseeslogo.webpalready claimed, is de-conflicted to the stemlogo-gif, and writesB/logo-gif.webp.renamedgetsA/logo.gif -> ...unchanged, andB/logo.gif -> B/logo-gif.webp.build_rename_mapcollapses to basenames:logo.gif -> logo-gif.webp.rewrite_pagesrepoints every stored/k/html/images/logo.gifreference atlogo-gif.webp- including the pages that wanted the animated one.- The row at
k/html/images/logo.gifis now referenced by nothing, anddelete_unreferenced_mediaremoves it.
Animated image permanently gone, its pages rendering the static one, exit 0. That is the same shape as F04, reached through the single output name this pre-pass cannot predict from the suffix alone.
The prediction needs the animated/static distinction, which is knowable up front (Image.open(src).n_frames, or getattr(img, "is_animated", False)) - the same test optimize_raster makes later. Alternatively, return {f"{stem}.webp", f"{stem}{suffix}"} for .gif under --webp, which is the conservative both-possibilities treatment this function already applies to SVG two lines above, and for the same reason.
There was a problem hiding this comment.
Fixed, both halves — you were right that the first one alone isn't enough.
possible_output_names now resolves the animated case rather than guessing: _is_animated_gif(src) opens the file and returns True/False/None, and an animated GIF claims {stem}.gif only. Unreadable (None) claims both names, so a file we can't classify can't have its name taken by something else.
That alone still lost the animation, though. Two sources sharing a basename (a/logo.gif, b/logo.gif) hit the de-confliction loop, which renamed the second and put an entry in rename_map repointing the shared stored name. Since the output namespace is flat, only one of them can be addressed at all — so there's now a duplicate-basename pre-pass that keeps the first and skips the rest with a warning, matching what insert_optimized_media's insert loop already does downstream. No rename, no repoint.
On the real corpus, --webp on: the animated GIF survives with its 3 frames, renamed is empty for it, and F04's cross-directory de-confliction still fires for the cases that genuinely collide.
Three tests: test_animated_gif_survives_a_webp_run, test_still_gif_is_still_converted_to_webp (the exemption is narrow — a single-frame GIF still converts), and test_duplicate_basenames_keep_the_first_without_repointing_it. The first and third fail pre-fix.
| f"[REMOVED] {old_db_path} (renamed to {IMAGES_DB_PATH_PREFIX}{rename_map[old_name]})" | ||
| ) | ||
|
|
||
| for out_path in sorted(work_dir.rglob("*")): |
There was a problem hiding this comment.
F27 · High - the insert loop reads the work dir, not this run's output, so a stale file is inserted as if this run had produced it.
optimize_directory has just returned the set of files it wrote, but the loop below walks sorted(work_dir.rglob("*")) instead. Anything already sitting in that directory is inserted.
work_dir is the documented third positional, so keeping a fixed one between runs to avoid re-optimizing is a supported usage. Run 1 optimizes a media dir containing old-logo.png; before run 2 that source is deleted. optimize_directory never touches it, but rglob still finds work_dir/old-logo.png and inserts a row at k/html/images/old-logo.png.
Whether that row then survives has nothing to do with what this run was asked to insert - it comes down to whether some page still happens to reference the old name, which delete_unreferenced_media decides several steps later. A resurrected image and a deleted one are both reachable from the same stale file.
Driving the loop from optimize_directory's own result closes it. Requiring an empty work_dir (or clearing it first) also works, but changes the documented behaviour of the positional.
There was a problem hiding this comment.
Fixed. optimize_directory now returns an OptimizeResult(renamed, written) NamedTuple, and the insert loop iterates sorted(result.written) instead of rglob-ing the work directory.
That matters exactly as you describe: the work dir is a documented positional, so reusing one between runs to skip re-optimizing is supported — and an rglob then inserts files an earlier run left behind, whose sources are gone. test_written_lists_only_this_run_s_outputs puts a stale file in the output directory and asserts it is absent from written (and still on disk — this doesn't delete it, it just stops treating it as this run's output). Fails pre-fix.
| </table> | ||
|
|
||
| {% elseif b.type == "image" %} | ||
| <img src="{{ b.src }}" alt="{{ b.alt|default('') }}"> |
There was a problem hiding this comment.
F28 · High (latent) - this branch renders an image reference in a form insert_optimized_media's rewriting cannot see.
This renders a standalone b.type == "image" block from a bare b.src field. md_to_json.py never emits one - its own docstring at line 58 states there is no standalone image block type - so today the branch is dead.
The problem is what happens the moment it isn't. Both of insert_optimized_media's reference scanners are anchored on the escaped quote that only ever follows an HTML src="..." attribute in the JSON:
rewrite_pages(line 209):f'{IMAGES_URL_PREFIX}{old_name}\\"'IMAGE_REF_RE(line 268):re.escape(IMAGES_URL_PREFIX) + r'([^\\"]+)\\"'
A block serialized as {"type":"image","src":"/k/html/images/foo.png"} ends in a plain ", and matches neither. So a page using this form would (a) keep pointing at foo.png after a --webp rename, and (b) be invisible to collect_referenced_media, which means delete_unreferenced_media deletes foo.png as unused. Silent data loss, armed by a template branch and disarmed only by the producer's current behaviour.
Either drop the dead branch, or widen both patterns to cover the bare-field form. Dropping it is the smaller change and keeps one way of expressing an image; the anchoring comment above IMAGE_REF_RE should then say that it depends on that.
There was a problem hiding this comment.
Dropped the branch, and said why in both places.
md_to_json.py is explicit that images are inline-only ("There is no standalone "image" block type — CommonMark only ever produces "image" as an inline token"), and its block-level fallback says the same, so nothing this pipeline produces ever reached this branch. page.peb now carries a comment where it was, warning that re-adding one means widening IMAGE_REF_RE and rewrite_pages first — and the comment above IMAGE_REF_RE now states that dependency from its own side.
Verified against a real Pebble 3.2.2 engine rather than by inspection: the edited template compiles, an {"type": "image", "src": ...} block falls through to the {% else %} html fallback, <table>/<hr>/paragraphs still render, and the comment emits nothing.
test_markdown_images_stay_inline_never_a_block pins the invariant the three now share.
| # in-content link to it does the same). Dropping it here is what | ||
| # the blacklist path already does above, and makes every | ||
| # reference render as a styled broken link instead. | ||
| topic_index_db.pop(md_path.stem, None) |
There was a problem hiding this comment.
F29 · Medium - topic_index_db.pop runs mid-loop, so the broken-link styling this comment promises applies only to pages converted after the failure.
Raised in the 2026-09-01 pass as a lower-severity observation (populate_db.py:694 as it was numbered then); not addressed in either fix commit, and no reply explains why, so re-raising it inline.
Converter was handed topic_index_db by reference at line 653 and consults it during convert_file. Popping the stem here only affects conversions that have not happened yet.
With --allow-conversion-failures: topics/a-page.md sorts first, converts fine, and links to z-page. topics/z-page.md then fails. a-page was already rendered with an ordinary <a href="/k/html/z-page.html">, and no Content row is written for z-page, so that link 404s in the app - which is exactly what the comment above says this pop prevents. Alphabetical position decides whether a given link renders as a styled broken link or as a live 404.
The pattern that works is the one drop_unreachable_ids uses for nav a few lines down: resolve against the final pages set after the loop, rather than mutating shared state during it. That needs a second pass over the converted pages' links, which is more than a one-line fix - which may well be why it was skipped. If that is the call, it is worth saying so in the comment, because as written the comment claims a guarantee the code only delivers for part of the corpus.
There was a problem hiding this comment.
Fixed, and the comment corrected.
One correction first: this was answered — the 2026-09-01 summary comment lists it under "Not fixed, deliberately, so they don't look handled", as populate_db.py:694, with the reason ("fixing it properly means knowing which pages fail before converting any, which needs a second pass over the corpus; I didn't want to make that trade quietly"). Easy to miss in a long comment, and it's on me that it wasn't also in the thread.
The second pass turned out to be cheap once scoped correctly, so it's in rather than deferred. After the refusal check — i.e. only on the --allow-conversion-failures path that can still write — pages_linking_to(pages, failed_stems) finds the pages carrying an already-resolved href="/k/html/<stem>.html", and just those are re-converted against the now-correct index. A page converted after the drop never contains that form (resolve_href returns None and leaves the raw <stem>.md, styled broken), so the scan selects exactly the stale ones and nothing else — usually zero, and never the whole corpus.
The comment at the pop now says what the drop actually governs (nav, and conversions that haven't happened yet) and points at the pass that repairs the rest.
test_pages_linking_to_finds_the_pre_failure_pages covers the three shapes: the pre-failure page, a nested block, and the post-failure page that must not be re-converted.
| frame's own disposal method. --webp is intentionally not honored here: | ||
| animated WEBP re-encoding is a separate feature this doesn't attempt.""" | ||
| n_frames = getattr(img, "n_frames", 1) | ||
| loop = img.info.get("loop", 0) |
There was a problem hiding this comment.
F31 · Medium - loop defaults to 0, which turns a play-once GIF into one that loops forever.
Raised in the 2026-09-01 pass as a lower-severity observation; not addressed, and no reply explains why, so re-raising it inline.
A GIF authored with no NETSCAPE application-extension block plays once, and Pillow represents that as the "loop" key being absent from img.info - not as loop=0. 0 is the value that means "loop forever". So img.info.get("loop", 0) reads "play once" and writes "loop forever", and save(..., loop=0) emits an explicit NETSCAPE block that was not in the source.
That contradicts this function's own docstring two lines up, which says it preserves "frame count, each frame's own duration, and the loop count".
The fix is to omit the kwarg entirely when the key is absent, rather than to pick a different default:
save_kwargs = {"duration": durations, "disposal": 2, "optimize": True}
if "loop" in img.info:
save_kwargs["loop"] = img.info["loop"]There was a problem hiding this comment.
Fixed, with the test.
Same correction as on F29: this was answered — the 2026-09-01 summary lists it under "Not fixed, deliberately", as optimize_media.py:267, with the reason being that it needs the source's own loop count read back through Pillow and I'd rather do it with a test than by inspection. That's what's now happened, so the deferral has expired rather than been ignored.
loop is passed to save only when "loop" in img.info. Pillow signals "plays once" by omitting the key, and 0 means "loop forever", so info.get("loop", 0) read one and wrote the other — adding a NETSCAPE application-extension block the source never had.
test_resize_preserves_a_play_once_gif (fails pre-fix: 'loop': 0 appears in the output's info) and test_resize_preserves_an_explicit_loop_count for the other direction. One fixture note that cost me a few minutes: GIF frames that are byte-identical get collapsed on save, so the fixture draws a moving rectangle — otherwise you get a single-frame "animation" that proves nothing.
| print(f"error: {db_path} does not exist", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| print(f"Backing up {db_path}...", file=sys.stderr) |
There was a problem hiding this comment.
F32 · Medium - backup taken before the scan, so every no-op run leaves a full-size copy behind.
This is the pattern the same PR removed from the other two tools. sync_kdoc_json_to_db.py now backs up only after the MAX_DELETE_FRACTION precheck, with a comment saying that taking it earlier "meant a run that correctly aborted on a layout mismatch still left a full-size copy of the database behind, for nothing". populate_db.py was changed the same way.
Here the equivalent "nothing to do" outcome is the common one, not the exceptional one: this script is documented as idempotent ("a second run finds nothing left to fix"), so re-running it against the production database is the expected case, and each such run writes another VACUUM INTO copy of a ~250MB file before discovering there are zero chains to repair.
Moving the backup below find_chains, gated on misnumbered being non-empty, matches what the other two tools now do.
There was a problem hiding this comment.
Fixed — third tool, same pattern as the other two.
main now scans first (find_fragment_paths → find_chains) and backs up only if misnumbered. The VACUUM is gated too, on stats["chains_renumbered"]: a run that moved no rows has no freed space to reclaim, and vacuuming rewrites the whole ~250MB file for nothing.
repair(conn, misnumbered=None, gapped=None) takes the scan results so main can decide about the backup before anything is written; both stay optional, so repair(conn) still does the whole job for callers that just want it done.
test_a_clean_database_is_not_backed_up and test_a_database_needing_repair_is_backed_up.
| # undetected chains and an unrelated page being deleted as a "surplus fragment". | ||
| # This script is otherwise standalone (stdlib + brotli), hence the explicit path | ||
| # rather than a package import. | ||
| sys.path.insert(0, str(Path(__file__).resolve().parents[2] |
There was a problem hiding this comment.
F33 · Medium - sys.path.insert(0, ...) puts ProcessKotlinWebsiteJSON ahead of the standard library.
Position 0 means that directory is searched before everything, stdlib included. Any module added to ProcessKotlinWebsiteJSON/ whose name collides with a stdlib one - types.py, json.py, select.py, io.py are all plausible names for something in there - would be imported in its place, for this script and for everything it imports transitively. The failure surfaces far from the file that caused it, and the directory is one nobody would think to suspect.
This script needs that directory reachable, not preferred: sys.path.append does the same job with none of that. Worth doing now while the import list is short, since the cost of the collision is paid by whoever adds the colliding file rather than by whoever chose the insert.
There was a problem hiding this comment.
Fixed — sys.path.append(...), so the directory is searched last rather than ahead of the standard library.
| past the short-fragment terminator, so on a gapped chain (p-1 full, p-2 | ||
| short, p-4) it reassembles a blob the server never serves, which | ||
| rewrite_pages would then re-compress and store.""" | ||
| return reassemble(conn, path, first_content) |
There was a problem hiding this comment.
F34 · Medium - collect_referenced_media (line 271) repeats every reassembly and decompression rewrite_pages just performed.
Commenting here on the shared helper; the duplicated pass is at line 271.
rewrite_pages selects the k/html page rows, calls reassemble_content on each, and decompresses each one. collect_referenced_media then runs the same query, the same reassemble_content, and the same decompress over the same rows - its docstring says as much ("the same row selection/reassembly rewrite_pages uses") - to build a reference set from text rewrite_pages was already holding.
The cost is not incidental. DictionaryCompressor has no in-process path: decompress shells out to the brotli CLI once per row. Over ~1,000 k/html/*.html pages that is ~1,000 extra subprocess spawns plus a second full reassembly pass, in the step that already dominates the run.
rewrite_pages already has each page's decompressed text in hand and already applies IMAGE_REF_RE-shaped substitutions to it. Having it collect the IMAGE_REF_RE matches it sees and return them alongside changed removes the second pass entirely. The ordering constraint in delete_unreferenced_media's docstring - that it must run after rename-rewriting so it sees post-rename names - is satisfied by construction if the set is built from the rewritten text.
There was a problem hiding this comment.
Fixed — one pass, as you describe.
rewrite_pages now returns RewriteResult(changed, referenced), collecting IMAGE_REF_RE matches from each row's final text (post-substitution where it substituted), and main hands that to delete_unreferenced_media as referenced=. Your point about the ordering constraint is what makes this safe: building the set from the rewritten text satisfies it by construction.
One thing that had to change to make it work in both directions: the if not rename_map: return 0 shortcut is gone, because a run with nothing to rename still needs the reference scan — otherwise the second pass just moves rather than disappears. That meant guarding the pattern, since "|".join(()) is "" and re.compile("") matches at every position; old_ref_pattern is now None when there's nothing to substitute. test_an_empty_rename_map_rewrites_nothing pins that.
collect_referenced_media stays as the reference implementation and as the fallback when delete_unreferenced_media is called without a set — that's how its existing tests call it.
test_pages_are_decompressed_once_per_run counts compressor.decompress calls across both functions with a real rename in play: 4 pages, 4 decompressions. The old shape was 8.
One correction on the size of it, since I measured rather than estimated: the corpus has 268 k/html/*.html page rows, not ~1,000, and a full reference pass over them takes 2.3s. So the saving is 2.3s out of a ~63s step — real, and worth having for a change that also removes the duplicated row-selection logic, but an order of magnitude smaller than the thread suggests. The end-to-end act run is otherwise identical: 298 images inserted, 224 renamed, 47 pages rewritten, 98 unreferenced deleted, 160 .webp rows — same numbers as before the change.
| POSITIONAL=() | ||
| while [ $# -gt 0 ]; do | ||
| case "$1" in | ||
| --kotlin-libs-version) KOTLIN_LIBS_VERSION="$2"; shift 2 ;; |
There was a problem hiding this comment.
F35 · Medium - $2 is read without an arity check, so a trailing flag dies under set -u instead of printing usage.
usage() is defined a few lines above for exactly this class of mistake, but a user who types
build-stdlib-json-docs.sh kotlin-repo --kotlin-libs-version
gets $0: line 60: $2: unbound variable and exit 1, with no hint that the flag needs a value or that a usage message exists.
Same shape for every --flag VALUE option in run-build-kotlin-docs-with-act.sh:115-124.
[ $# -ge 2 ] || { log "error: $1 needs a value"; usage; exit 1; }before each $2 read covers it.
There was a problem hiding this comment.
Fixed in both scripts. A need_value helper checks [ $# -ge 2 ] before each $2 read — the seven flags in run-build-kotlin-docs-with-act.sh and the two in build-stdlib-json-docs.sh:
$ ./run-build-kotlin-docs-with-act.sh --db-path
error: --db-path needs a value
$ build-stdlib-json-docs.sh kotlin-repo --kotlin-libs-version
error: --kotlin-libs-version needs a value
Usage: ... [--kotlin-libs-version V] [--kotlin-libs-repo URL] <path-to-kotlin-repo-root> [output-dir]
test_a_flag_without_a_value_is_reported runs both through bash and asserts exit 1, the message, and no unbound variable.
F25 (critical, act input inversion) did not reproduce - two act runs show
the negation gates evaluating correctly in both directions, and the
suggested `inputs.x != 'true'` would invert on real GitHub instead. The
other ten are fixed:
- F26 an animated GIF is no longer predicted as .webp (it cannot be
re-encoded), and duplicate basenames keep-first-skip-rest rather
than de-conflicting onto a name nothing writes
- F27 optimize_directory returns OptimizeResult(renamed, written); the
insert loop follows `written` instead of rglob-ing the work dir
- F28 drop the unreachable `b.type == "image"` branch from page.peb, and
record in both places that IMAGE_REF_RE's anchoring depends on it
- F29 re-convert the pages that linked to a failed page before its stem
was dropped, so broken-link styling covers the whole corpus, not
just what converted after the failure
- F30 list_stored_media asks is_continuation_path, so an image genuinely
named "<other>-1" is listed rather than swallowed
- F31 a GIF's loop count is passed through only when the source had one;
info.get("loop", 0) turned "plays once" into "loops forever"
- F32 renumber_misnumbered_fragments scans before backing up, and
vacuums only when it moved rows
- F33 sys.path.append, not insert(0), so the stdlib still wins
- F34 rewrite_pages returns the references it saw; delete_unreferenced_
media reuses them instead of decompressing every page a second time
- F35 arity checks before each `$2` read in both shell scripts
Also from the lower-severity list: render_node's "byte-identical to
nav.peb" claim is corrected (whitespace differs; a Pebble 3.2.2 render
confirms nothing else does) and its unused `indent` parameter dropped;
find_missing_assets uses md_to_json.fenced_spans instead of a second,
backtick-only fence pattern.
20 new tests. 174 + 33 + 189 pass; the 5 failures and 1 collection error
in scripts/ are pre-existing on the base commit. Local workflow green
end-to-end under act with identical output to the previous round.
Co-Authored-By: Claude Opus 5 <[email protected]>
Third review round: 10 of 11 fixed, 1 disprovedPushed as a single commit, F25 (critical) did not reproduceYou flagged this as the one to verify first, so I did. Two act runs through
The input file's md5 changed,
The other ten
Also from the older lower-severity list: Two corrections to my own earlier claims"Byte-identical to F34's cost. I measured instead of repeating the estimate: the corpus has 268 One thing worth flagging backF29 and F31 both say "no reply explains why". They were answered — the 2026-09-01 summary comment has a "Not fixed, deliberately, so they don't look handled" section listing Verification20 new tests, in
174 + 33 + 189 pass. The 5 failures and 1 collection error under Local workflow green end to end under act, with output identical to the previous round: 266/266 pages converted, 298 images inserted, 224 renamed, 47 page/nav rows rewritten, 98 unreferenced deleted, 160 Workflow parity re-checked and unchanged: the only differences between |
Self-review of the previous commit, which introduced two of these. - The duplicate-basename skip added for F26 keyed on src.name.lower(), but populate_db indexes image basenames exactly - so "a/Logo.png" and "b/logo.png" are two addressable images there, and dropping one left the page referencing it resolving to a row nothing inserts. They are a collision, which `claimed` already de-conflicts, not a duplicate. - The F35 test shelled out to run-build-kotlin-docs-with-act.sh, whose `command -v act` check ran before argument parsing - so it failed on any machine without act, including this PR's own python-tests.yaml. The check now sits with the other validation, just before the first thing that needs act; a mistyped flag is reported as a mistyped flag. - F26's animated exemption stopped at .gif, but optimize_raster copies *any* animated raster through unchanged, so an APNG or multi-frame TIFF under --webp still claimed a .webp nothing writes. Replaced with ANIMATABLE_EXTENSIONS; .webp is excluded (same name either way) and JPEG/BMP cannot be animated, so nothing pays for a probe it can't use. - possible_output_names now takes the answer rather than the path, so it does no I/O and the probe happens once per source instead of once per de-confliction attempt. - optimize_directory and rewrite_pages kept their old -> dict / -> int annotations after returning NamedTuples. - fenced_spans runs an unterminated fence to EOF, so one stray ``` line silently stopped find_missing_assets checking the rest of the file - a false negative in the report whose job is catching what's missing. It now says so, and only for a genuinely unclosed fence. - Dropped dead `if False` scaffolding and a vacuous assert from the F28 test. - repair() answered a half-supplied scan with a fresh one, silently discarding the caller's snapshot; it refuses now. 14 new tests; every one that can fail against the previous commit does. On the real 299-image corpus the optimize pass is byte-for-byte unchanged - same 299 written, same 219 renames, same output bytes, and the added probe costs nothing measurable (38.6s vs 39.0s). Running insert_optimized_media against the real database gives the same 160 .webp rows as the last act run. 188 + 33 + 189 pass; scripts/ has the same 5 pre-existing failures as the base commit. Co-Authored-By: Claude Opus 5 <[email protected]>
Two of these are misses in the previous commit's own fixes.
- The act check moved above the argument *validation* rather than below
it, so five semantic errors (both --skip flags, --db-path missing or
nonexistent, --images-zip-path missing or nonexistent) were still
answered with "act is required" on a machine without act. It now sits
immediately before the act invocation; all five report themselves, and
act is still required once the arguments are good.
- possible_output_names' undetermined case claimed both names, which was
widened from GIFs to every PNG and TIFF by the last commit. It now
claims nothing: optimize_raster repeats the same Image.open and
is_animated access, so whatever makes the probe fail makes process_file
fail too, and the source writes no file at all. Holding two names for
it de-conflicted a real image against an output that never appears.
And the deeper one behind both of the last two rounds' F26-shaped bugs:
optimize_directory now checks that the name process_file actually wrote
was one the planning pass claimed for that source, and fails loudly
otherwise. The prediction lives in a second place from the code it
predicts and has drifted twice, silently both times; this is the missing
connection between the halves.
Also:
- _is_animated_raster reports a probe failure through the logger instead
of swallowing it - it runs for every PNG now, and a systematic failure
would otherwise shift the whole de-confliction pass unexplained.
- outside_fences' `source` is required, not defaulted: the warning is the
entire point of that change, and an optional argument is how a later
call site quietly gets the old silence back.
- The unterminated-fence check runs once on spans[-1]. Only the last span
can be unterminated (fenced_spans appends the run-to-EOF one after its
loop), so checking every span re-split every code block for nothing.
- `animated: Optional[bool]`, and a non-bool argument is refused - the
parameter took a Path one revision ago, and a Path is truthy, so a
stale call reported "animated" for every file with no error.
- The case-handling test asserts the property rather than the
"{stem}-{ext}" naming literal.
6 new tests, and every changed behaviour fails against the previous
commit. 192 + 33 + 189 pass; scripts/ unchanged from base. The real
299-image corpus is byte-for-byte identical again, and the local
workflow under act produces the same database md5 as the last two
rounds - with the new written-vs-claimed check silent across every path
the corpus exercises, including SVG rasterization.
Co-Authored-By: Claude Opus 5 <[email protected]>
The drift check added in the previous commit crashed on ordinary input. possible_output_names' SVG branch returned the lowercase SVG_EXTENSION literal instead of echoing the source's own suffix - every other branch already echoes it - so "Diagram.SVG" was claimed as "Diagram.svg" while optimize_svg writes `dst`, which keeps the source's spelling. Harmless while it only fed the casefolded `claimed` map; once the check compared what was written against what was claimed it aborted the entire run over one ordinary file, after all 299 images had been optimized. The corpus has no uppercase extensions, so neither the full-corpus run nor act could have caught it. Three things had to change for that to be safe, not just quiet: - The check asks `claimed`, casefolded like every other lookup in the pass, instead of a second exact-case dict. De-confliction guarantees no two sources hold the same lowercased name, so "did this source claim this name" is exactly `claimed[name] is src` - and `claimed_for` is gone rather than kept in step by hand. - A write from a source that claimed nothing (its animation could not be determined, so the planner expected optimize_raster to fail too) is one file's bad luck, not drift. It is now reported and the file kept - this module's rule is that a single file's problem does not end the run, and a dangling symlink doing exactly that was fixed as a bug in this same PR. Kept rather than dropped, deviating from the review's suggestion: discarding a successfully optimized image is the worse outcome, and insert_optimized_media's seen_names guard still covers the only residual risk. - Genuine drift raises ValueError, not RuntimeError, so insert_optimized_media's `except ValueError` turns it into a clean "error: ..." line instead of letting a traceback escape main(). Also `logger: Optional[Logger]`, the annotation this commit's parent fixed one function above and repeated here. 9 new tests, including the uppercase case across .SVG/.Svg/.PNG/.JPG/.TXT with and without --webp; the six raster/passthrough parametrizations pass against the parent commit too, which is what pins the crash to the SVG branch. 204 + 33 + 189 pass; scripts/ unchanged from base. The real 299-image corpus is byte-for-byte identical, and act produces the same database md5 as the last three rounds. Co-Authored-By: Claude Opus 5 <[email protected]>
The drift check's exemption keyed off the wrong thing, and let real drift through. `if src in unpredicted` read as "the planner claimed nothing for this source", but it recorded "the probe failed" - and those come apart under --webp false, where possible_output_names returns the source's own name before it ever consults `animated`. So for any file whose probe failed, a writer that produced a name nobody claimed got a warning and an exit 0 instead of the error the check exists to raise. The warning even said "the de-confliction pass held no name for it" while `claimed` held one. Rather than correct the condition, remove the case: optimize_raster now takes the planner's `animated` answer instead of re-deriving it with its own `getattr(img, "is_animated", False)` a few lines later. Two answers to one question was the whole reason a source could be unpredictable here and processable there. A source whose animation is undetermined is now refused in optimize_raster, counted by process_file as one file's error like any other, and never reaches the check - so the planner's "claims nothing" is always accurate and the exemption has nothing left to describe. Both answers already came from the same access on the same file, so this only changes behaviour on a transient. The per-source plan is one record (Plan: dst_rel, names, animated) instead of a dst dict beside a parallel set - every parallel structure this pass has grown ended up disagreeing with the one next to it, which is what both of the last two rounds' bugs were. Also: - The drift message reports `plan.names`, the claim as the planner wrote it, not `claimed`'s casefolded keys - reporting "diagram.svg" for a source that claimed "Diagram.SVG" points the reader at a case mismatch that isn't the problem. - The check compares with == rather than `is`: Path defines __eq__, and identity held only because two loops happened to share list objects. - The uppercase test asserts the written name per case, not just that one file came out. Dropped test_drift_is_refused_even_when_the_probe_failed as written: it stubbed the probe to False - a probe that succeeded - so it demonstrated nothing about the exemption and passed against the unfixed tree. The regression for that hole is the --webp False half of test_an_undetermined_probe_skips_one_file_and_no_more, which fails against it. 206 + 33 + 189 pass; scripts/ unchanged from base. Real 299-image corpus byte-for-byte identical, and act gives the same database md5 for the fourth round running. Co-Authored-By: Claude Opus 5 <[email protected]>
The previous commit flattened every animated WEBP to a single frame. optimize_raster stopped deriving is_animated itself and took the planner's answer instead - but the planner probed only ANIMATABLE_EXTENSIONS, and .webp was deliberately excluded from that set on the grounds that "an animated WEBP is copied through as a .webp, which is what the conversion branch predicts anyway, so there is nothing to resolve and no reason to pay for opening the file". True for predicting a *name*; wrong once the same value decided whether to re-encode. A 4-frame webp came out with 1 frame, exit 0, no warning - and the written-vs-claimed guard could not see it, because the name was exactly what was predicted. Only the frames were gone. The set is gone with it. One question - "is this file animated" - now gets one answer, taken from the file, for every raster; and the naming rule follows from it uniformly: under --webp a raster keeps its own extension exactly when it is animated, because that is when optimize_raster copies it through instead of re-encoding. No curated membership to get wrong, which it had been twice (first .gif alone, then a four-entry set missing .webp and .jpg). That also restores, and this time documents, the handling of an MPO stored as .jpg - a phone stereo or burst capture, which Pillow reports as animated. It is copied through rather than flattened to its primary frame; the previous commit changed that silently as a side effect. Also: - optimize_raster's copy-through comment named webp as its example while that branch was unreachable for webp. It says what the branch really covers now, MPO included. - process_file's docstring said `animated` was "irrelevant for SVG and passthrough files", implying it carried a real answer for every raster; it was a hard-coded False for four of the eight raster extensions. - test_an_undetermined_probe_skips_one_file_and_no_more used other.jpg as its control, which survived only because .jpg was unprobed. Now a .txt passthrough, which no widening of the probe can touch. New: test_every_animated_raster_keeps_its_frames_under_webp over animated WEBP, APNG and MPO-as-.jpg, plus a static counterpart so the exemption stays animation and not extension. The webp and MPO cases fail against the previous commit; the APNG case passes, which is the control showing what the old set did and did not cover. 214 + 33 + 189 pass; scripts/ unchanged from base. Real 299-image corpus byte-for-byte identical, 39.5s -> 40.0s for probing every raster rather than four extensions. Co-Authored-By: Claude Opus 5 <[email protected]>
None of these is data loss; two are worth the change. - The comment replacing ANIMATABLE_EXTENSIONS' rationale was appended rather than substituted, so the sentence it replaced survived directly above it: "opening every JPEG to learn that would cost an extra pass over the whole corpus", immediately followed by "Probed for every raster". The stale half is the exact argument that narrowed the probe set and flattened every animated WEBP, sitting there ready to persuade the next reader. The block is now one accurate paragraph, including what the widening really costs (39.5s -> 40.0s, which the previous comment called "measurable at neither end") and that .bmp is probed despite not being animatable - the price of having no list to get wrong. - An animated raster is copied byte-for-byte: no resize, no re-encode. It was logged as "Optimized src -> dst" and counted in stats["raster"], so nothing anywhere said that the file ships at its original dimensions, past --max-width, into a database bundled into an offline app. optimize_raster now returns (path, copied_through) - the shape optimize_svg already uses for was_rasterized - and process_file counts it in stats["copied"] and logs "Copied unresized (animated)". - possible_output_names checked `animated is None` only after the non-webp early return, so an unprobeable raster claimed a name there while optimize_raster refuses it and writes nothing. That held the name against other sources and broke the "claims nothing exactly when it writes nothing" invariant the written-vs-claimed check reads as a guarantee. The check is now above the --webp branch. - test_every_animated_raster_keeps_its_frames now runs both --webp settings; every animation test ran only the webp path, which is not the one where the None case was mishandled. New: the two reporting tests. The copy-through one fails against the previous commit, printing the "Optimized" line it should not have. 219 + 33 + 189 pass; scripts/ unchanged from base. Real 299-image corpus byte-for-byte identical, and the raster/copied tallies are unchanged at 223/1 - it holds no animated non-GIF, which is why none of this was visible there. Co-Authored-By: Claude Opus 5 <[email protected]>
Six self-review rounds since the last update — 44 findings, and a caveat about them
What follows is what my own The two that matteredAn animated WEBP was flattened to a single frame. Fixing your F26 (animated GIFs mispredicted as The set is gone. One question — "is this file animated" — one answer, taken from the file, for every raster; the naming rule follows from it ( An SVG with an uppercase extension aborted the whole run. The rest, by round
Full detail is in each commit message. What I'd take from thisThree of these were introduced by guards or refactors I added while fixing your findings, and one of those guards — the written-vs-claimed check — has now been the subject of four consecutive rounds. It is worth its keep (it is what would catch the next planner/writer divergence, and it is silent across the whole real corpus), but the pattern is clear enough to name: each round's fix added a parallel structure beside an existing one, and the two then disagreed. The other thing worth naming is that the corpus is a weak oracle for this code. webHelpImages.zip has no animated non-GIF, no uppercase extensions, no duplicate basenames, and 299 exactly-distinct filenames. Every bug above passed a byte-identical corpus run and a green State
The local workflow is green end to end under act, with the same counts as every prior round: 266/266 pages, 298 images inserted, 224 renames, 47 page/nav rows rewritten, 98 unreferenced deleted, 160 Two notes on that run. The built database's md5 changed for the first time in five rounds — that is upstream drift, not this branch: exactly two of 30,620 rows differ ( I've stopped self-reviewing here. Six rounds in, the yield has gone from silent data loss to comment accuracy, and the remaining risk is better addressed by your eyes than by another pass of mine. |
Summary
An end-to-end pipeline for loading Kotlin website content and
kotlin-stdlib/-reflect/-test JSON docs into
documentation.db, plus aGitHub Action (
Build Kotlin Docs) that runs it against a Drive-hosted copyof the database.
Scope
Split out of #21
into two ticket-scoped PRs:
md_to_json.py).This PR depends on ADFA-5039 merging first.
populate_db.py,build_nav.py, andfind_missing_assets.pyallimportmd_to_json.pydirectly, and that file isn't included here. Please review this PR's
content, but hold off merging until #23 lands -
README.mdin particularwill very likely conflict with #23's own new
README.mdat the same path(both add a new file there) and need a quick manual merge to combine the two
once both are in.
Changes
ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/:build_nav.py(sidebar nav from
kr.tree),find_missing_assets.py(source QA),populate_db.py(inserts pages/nav/media intodocumentation.db,supports pruning via
--blacklisted-element-titles),optimize_media.py/
insert_optimized_media.py(media optimization + DB update),templates/,assets/.scripts/sync_kotlin_stdlib_docs/sync_kdoc_json_to_db.py- syncsDokka-generated kotlin-stdlib/-reflect/-test JSON into the database.
ProcessDocs/ProcessKotlinDocs/run_e2e_pipeline_test.sh- local e2e testof the whole pipeline against a scratch copy of the database.
.github/workflows/build-kotlin-docs.yaml- CI counterpart: pullsdocumentation.db(and Writerside'swebHelpImages.zip) from GoogleDrive, runs the same pipeline, uploads the result back.
Dokka-plugin-kdoc2json/scripts/kotlin/build-stdlib-json-docs.sh+build.gradle.ktstweak - builds the kdoc-to-json plugin and generatesthe stdlib JSON docs the sync script consumes.
CLAUDE.md- repo-level orientation doc (schema drift between this repo'stooling and the live production database, repo tour, decisions log).
Test plan
run_e2e_pipeline_test.shlocally against a scratch database.Build Kotlin Docswithdry_run: trueand confirm thesummary/blacklist-verification steps pass.
made during the split, aside from
README.mdbeing trimmed to drop themd_to_json.py-only section now covered by ADFA-5039).