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 35 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
|
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
… brotli Both from hal-eisen-adfa's follow-up review of 838ac44. insert_optimized_media.delete_content still built the LIKE pattern the migration script had stopped using: `path = ? OR path LIKE '<path>-%'`, where `_` is a single-character wildcard and the suffix is not constrained to digits. Rows matched that way are never re-inserted, so the loss is permanent. It now deletes the base row by exact path and each continuation by the exact paths populate_db.fragment_chain returns, which does the over-matching query once and re-checks every candidate's parsed suffix. The claim "no write path constructs a LIKE pattern any more" is now true of the whole tree, not just one file. The `brotli` CLI became a required external binary in three independent paths (populate_db's DictionaryCompressor, sync_kdoc_json_to_db, docdb_studio) and nothing declared it. The Python `brotli` package the README asks for is a different artifact and exposes no custom-dictionary parameter, which is exactly why the CLI is unavoidable -- and what makes `pip install brotli` read as though it covers this. Declared in the four places that would tell someone: * build-kotlin-docs.yaml's apt-get line -- it runs populate_db, insert_optimized_media and sync_kdoc_json_to_db. * docdb-regression-test.yaml's apt-get line -- it runs docdb-studio against the downloaded production database, which is now a dictionary database, so its reads need the binary too. (CI previously depended on whatever the runner image happened to ship.) * ProcessKotlinWebsiteJSON/README.md, beside the existing `pngquant on PATH` bullet, spelling out that this is the CLI and not the Python package. * docdb-studio/README.md, noting `uv sync` cannot install it and that a database with no CompressionDictionary needs nothing extra. publish-doc-db.yaml is deliberately untouched: it runs the scripts/ingest.py pipeline, which does not reach for the CLI. Tests unchanged and passing: 25 in ProcessKotlinWebsiteJSON, 173 in docdb-studio. Both workflow files still parse as YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…base remint_dictionary.py trains a new shared dictionary for an already-migrated database and recompresses every 'brotli' row against it in one transaction, replacing the CompressionDictionary row. verify_remint_dictionary.py is the read-only gate: it decodes every row out of both databases and requires the plaintexts to match, exiting non-zero otherwise. These deliberately do what load_or_create_dictionary refuses to do, and the refusal is right for the pipeline: replacing a stored dictionary without recompressing the content orphans every row, since the dictionary decode fails and the plain fallback fails too. The only safe way to change a dictionary is to change the content with it, atomically, which is what this pair is for. Either every row converts and the dictionary is replaced, or nothing is written. Why it is worth having: the dictionary a database is first minted with is permanent for its content, so a poorly-sampled one stays expensive forever. Re-minting the 21-Aug database with the stratified, byte-budgeted sampler took its brotli content from 83.4 MiB to 65.6 MiB and the vacuumed file from 268 MB to 249 MB -- 18 MB -- with all 29,677 items verified byte-identical, and the result confirmed on device: pages served at their original byte counts through brotli4j, whose attachDictionary had never seen this dictionary before. The verifier is not ceremony. A row recompressed against a mismatched dictionary decodes with no error into *different* bytes 38% of the time (50% raises, 12% is identical because the perturbed region was never referenced), so nothing at runtime detects it and the check has to happen against the original before the file is put in place. collect_training_samples now takes an optional decoder, defaulting to plain Brotli. A re-mint's rows are dictionary-compressed, so it passes one that reads against the outgoing dictionary and falls back to plain -- the fallback is required, not defensive, because a dictionary database always holds some plain rows. read_item, write_item and load_base_rows are reused from the migration script rather than copied, which is what keeps the in-place write (never DELETE+INSERT on a base row, because of the '%.pdf' triggers) in one place. Four tests. Two of them exist because writing them corrected me: re-minting with the same seed and corpus reproduces the stored dictionary byte for byte, so a test asserting the dictionary changed has to vary the seed -- and an earlier assertion that the outgoing dictionary can no longer decode a re-minted row was asserting a coin flip, the same mistake as asserting that a wrong-dictionary decode raises. The remaining two cover the abort path leaving the database untouched, and the verifier actually objecting to a corrupted re-mint rather than passing vacuously. 29 pipeline tests (from 25) and 173 docdb-studio tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docdb-studio's README named `apt` and `brew` and left Windows users with nothing, on the one dependency `uv sync` cannot install for them. It now has a section of its own, following the per-OS shape the uv instructions already use: winget, scoop and choco, each preceded by the matching `search` command so a renamed package cannot strand the reader, plus MSYS2 for anyone who already has Git for Windows. Then the two things that actually go wrong on Windows: a changed PATH is only visible in newly-opened terminals, and a package manager can install the binary somewhere that is not on PATH at all -- so `where.exe brotli`, the usual shim directories, and where to edit PATH. It also states plainly that the `brotli` in `uv sync` is a different artifact from the `brotli` program, since `pip install brotli` succeeding is exactly what makes this confusing, and doubly so on Windows where there is no `brotli.exe` afterwards. Writing that section exposed a real defect in the BrotliCliMissing handling from 838ac44. Subclassing brotli.error kept a missing binary from escaping as an unhandled RuntimeError, which is what the review asked for -- but the two call sites catch brotli.error and return []/None, so the failure became a blank preview with nothing said anywhere. A corrupt row and a missing binary are not the same event: one is a single bad row, the other means nothing in this database will ever decode and is fixable in one command. The call sites now catch BrotliCliMissing separately and print which path failed and why, and the exception's message points at the README rather than listing two Unix package managers. The README says what actually happens -- blank preview plus an explanatory error in the launching terminal -- rather than claiming the UI reports it. 174 docdb-studio tests pass (from 173); the new one asserts both call sites log rather than swallow, and that the message names the path and points at the README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tionary ADFA-5153: Compress Content table with a trained Brotli dictionary
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
|
| 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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.
| 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.
| @@ -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.
| 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.
| 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.
| 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.
| # 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.
| # 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.
| 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.
| # 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.
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.
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).