Skip to content

ADFA-4739: Kotlin docs DB pipeline + Build Kotlin Docs GitHub Action - #24

Open
alexmmiller wants to merge 38 commits into
mainfrom
fix/ADFA-4739
Open

ADFA-4739: Kotlin docs DB pipeline + Build Kotlin Docs GitHub Action#24
alexmmiller wants to merge 38 commits into
mainfrom
fix/ADFA-4739

Conversation

@alexmmiller

@alexmmiller alexmmiller commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

An end-to-end pipeline for loading Kotlin website content and
kotlin-stdlib/-reflect/-test JSON docs into documentation.db, plus a
GitHub Action (Build Kotlin Docs) that runs it against a Drive-hosted copy
of the database.

Scope

Split out of #21
into two ticket-scoped PRs:

  • ADFA-5039 - producing the raw JSON data for the Kotlin website (md_to_json.py).
  • ADFA-4739 (this PR) - the database-manipulation side and the GitHub Action.

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
directly, and that file isn't included here. Please review this PR's
content, but hold off merging until #23 lands - README.md in particular
will very likely conflict with #23's own new README.md at 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 into documentation.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 - syncs
    Dokka-generated kotlin-stdlib/-reflect/-test JSON into the database.
  • ProcessDocs/ProcessKotlinDocs/run_e2e_pipeline_test.sh - local e2e test
    of the whole pipeline against a scratch copy of the database.
  • .github/workflows/build-kotlin-docs.yaml - CI counterpart: pulls
    documentation.db (and Writerside's webHelpImages.zip) from Google
    Drive, runs the same pipeline, uploads the result back.
  • Dokka-plugin-kdoc2json/scripts/kotlin/build-stdlib-json-docs.sh +
    build.gradle.kts tweak - builds the kdoc-to-json plugin and generates
    the stdlib JSON docs the sync script consumes.
  • CLAUDE.md - repo-level orientation doc (schema drift between this repo's
    tooling and the live production database, repo tour, decisions log).

Test plan

  • Once ADFA-5039 is merged and this branch is rebased, re-run
    run_e2e_pipeline_test.sh locally against a scratch database.
  • Trigger Build Kotlin Docs with dry_run: true and confirm the
    summary/blacklist-verification steps pass.
  • Reviewer confirms the scripts match their originals in ADFA-4739: Pipeline for producing template-based Kotlin documentation #21 (no edits
    made during the split, aside from README.md being trimmed to drop the
    md_to_json.py-only section now covered by ADFA-5039).

alexmmiller and others added 10 commits July 22, 2026 15:30
…, 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>
Converts kotlin-web-site/docs (Writerside-flavored Markdown) into the JSON
block schema this project's templating engine renders - one JSON file per
topic, plus theme.json and a copy of images/. Split out of the larger
Kotlin-docs pipeline PR so this ticket's scope (producing the JSON) can be
reviewed independently of the database-insertion side (ADFA-4739).

Includes review_build_json.sh, a throwaway helper that clones
kotlin-web-site and runs the converter against it, for reviewers to see
real output without any other setup - not part of the actual pipeline.

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 hal-eisen-adfa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

  1. CLAUDE.md makes a factual claim about templateId that the same commit falsifies — and reasons from it to a scope conclusion.
  2. nav.peb emits a nav-hidden class 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.

Comment thread CLAUDE.md Outdated
Comment on lines +86 to +88
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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 %}">

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread CLAUDE.md Outdated
Comment on lines +150 to +155
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +25 to +26
- `pip install markdown-it-py Pillow scour brotli`
- `cairosvg` (only needed if an optimized SVG exceeds `--svg-rasterize-threshold`): `pip install cairosvg`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 runtime RuntimeError telling the user to pip install cairosvg)
  • run_e2e_pipeline_test.sh lines 100, 105, 125, 141, 145, 175 — six bare python3 invocations

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +97 to +98
except Exception as exc: # noqa: BLE001 - surface which file broke, keep auditing the rest
print(f"error scanning {md_path}: {exc}", file=sys.stderr)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fixes 12 correctness bugs found by review (verified against a live
kotlin-web-site corpus, not just read-the-code):

- TAG_RE matched "tab" as a prefix of "table", silently eating every raw
  HTML table in the corpus (38 occurrences / 18 files).
- merge_attr_lines deleted any {...}-shaped paragraph even when it parsed
  to zero attrs, dropping real content.
- <tabs> blocks discarded any non-<tab> sibling (intro/trailing prose).
- A mismatched closing tag_marker was silently ignored, leaving the wrong
  frame open and relocating later content into it.
- ATTR_PAIR_RE didn't allow whitespace around "=", and parse_attrs' whole-
  string quoted-vs-bare check blanked bare values whenever any sibling in
  the same group was quoted.
- fold_image_attrs only handled exactly one trailing {...} group and
  required the whole text token to be nothing else, so a second attribute
  group or trailing prose leaked as literal visible text.
- broken-ext-link-color was interpolated into a style="..." attribute
  unvalidated (markup-injection hole) and unconditionally appended even
  when the <a> already had a style=, producing a silently-ignored
  duplicate attribute.
- main() swallowed per-file conversion failures and still exited 0.
- The image-src regex rewrote src= on any element, not just <img>,
  producing false "image not found" warnings for <script>/<iframe>.
- page_id/sourceFile used str(Path(...)) instead of .as_posix(), which
  would disagree with build_topic_index's forward-slashed ids on Windows.
- build_topic_index resolved duplicate topic stems first-wins with no
  warning, unlike the equivalent image-filename collision handling.
- Heading lines with a trailing Writerside attribute suffix (most commonly
  {id="..."}, also seen as {completion-point=...}) rendered that suffix as
  literal visible garbage text, with no attribute handling and no
  anchor-override support - found via corpus verification, not flagged by
  either reviewer, but the same bug family and comparably common (~39
  affected pages).

Also: de-dupes heading ids that collide on identical text (anchor to the
second heading no longer lands on the first); skips re-copying unchanged
images and prunes stale topic JSON left over from a previous run; switches
review_build_json.sh from raw pip/python3 to `uv run` (PEP 668
externally-managed-environment installs were crashing it outright on
Homebrew/Debian/Ubuntu Python).

Adds tests/test_md_to_json.py (30 cases, one per fix above, several reusing
reviewers' own repro snippets) and tests/conftest.py so pytest can find the
module under test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Alex Miller and others added 2 commits August 10, 2026 13:00
group_containers crashed (KeyError) on a top-level unmatched closing tag
since the len(stack) > 1 guard was dropped during the round-1 fix. The
topics-dir pruning rmtree could delete the source docs when output_dir
resolves to the same tree as docs_root. The duplicate-style merge could
produce invalid (dropped) CSS when the existing style had no trailing
";". fold_image_attrs re-stripped the remainder after an image, eating
the leading space before trailing prose. load_config raised a bare
TypeError instead of its own clean error on a non-string color value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
@alexmmiller

Copy link
Copy Markdown
Collaborator Author

Pushed 66da59d addressing your review. Replied inline to each of the 5 line comments individually; summary here.

Blocking (2)

  1. CLAUDE.md's templateId "out of scope" claim was false as of this same PR (line 88) - dropped templateId from the "zero matches outside WebServer.kt" parenthetical, keeping only Templates/Bookshelf/BookCategories/PUCC (the names that actually are absent), and added a note that populate_db.py/insert_optimized_media.py do read/write templateId directly.
  2. nav-hidden was inert (templates/nav.peb:38) - added .nav-hidden { display: none; } to assets/docs.css, so Writerside-hidden nav entries (the 29 tour-step cases you found in the committed nav.html) are actually hidden.

Non-blocking (3)

  1. Stale Dokka-plugin-kdoc2json branch state (CLAUDE.md:150-156, decisions log line 183) - rewrote the bullet to describe the plugin as merged (fix/ADFA-4514, 4c6b8aef) with the actual files present on main, and removed the resolved rebase item from the decisions log.
  2. pip/bare python3 vs. the repo's uv convention - converted every usage example in README.md, 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. Also added scour/cairosvg to requirements.txt, since they'd only ever existed in prose.
  3. find_missing_assets.py swallowed per-file scan failures and exited 0 regardless (line 98) - added a failure counter, a --allow-failures opt-out flag, sys.exit(1) on any scan failure, and a "N file(s) failed to scan" line in the report itself (with an explicit "this report is incomplete" marker when non-zero) - mirroring the pattern md_to_json.py uses on ADFA-5039: Convert kotlin-web-site docs to JSON #23.

Tests

Added tests/test_find_missing_assets.py covering item 5's exit-code/report behavior. Since md_to_json.py isn't on this branch yet (lands with #23), the tests stub that import via PYTHONPATH and run the script as a subprocess rather than importing it directly. 3/3 pass.

Verified the uv run --with-requirements invocations actually work (installed uv locally and ran optimize_media.py --help plus the heredoc pattern used in the e2e script through it) and that run_e2e_pipeline_test.sh is still syntactically valid (bash -n). Full end-to-end execution of find_missing_assets.py/populate_db.py still isn't possible here until #23 merges, per the PR description.

@alexmmiller
alexmmiller dismissed hal-eisen-adfa’s stale review August 10, 2026 18:57

Sent prematurely, apologies

Alex Miller and others added 12 commits August 12, 2026 16:07
Self-closing container tags (<tab .../>) had their trailing "/" eaten
by TAG_RE's greedy attrs group, so they were recorded as openers that
never close - silently nesting the rest of the page inside them. TAG_RE
now captures the self-close marker in its own group, and the html_block
dispatch emits an immediate open/close pair for one.

Indented (4-space) code blocks produced markdown-it's "code_block"
token, which convert_node had no case for - they fell through to the
generic fallback and rendered as unescaped raw HTML instead of a typed
code block. Added a code_block case alongside the existing fence one.

The documented "image" block type was never actually emitted - images
are inline-only in markdown-it, always folded into their containing
block's own html. Fixed the docstring/README to match reality instead
of documenting a block shape that can't occur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# 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>
davidschachterADFA and others added 9 commits August 17, 2026 21:08
…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
HIGH: slugify() deleted punctuation instead of hyphenating it, diverging
from Writerside's own anchor algorithm on any heading with "." "/" ":"
etc. - 209 dead #anchor links measured on the live kotlin-web-site
corpus, down to 15 with this fix.

MEDIUM: fold_image_attrs set folded_any on a regex match rather than a
successful parse, so a non-attribute "{...}" group (a CSS class
shorthand, or prose) was swallowed instead of left as visible text -
same guard merge_attr_lines/extract_trailing_attrs already apply.
Also extended fold_image_attrs to handle a trailing "{...}" group
after a *link*, not just an image/heading (real hit in
js/js-ir-compiler.md). group_containers now warns when a container is
still open at EOF, symmetric with the existing unmatched-closer warning.

LOW: MD_LINK_RE and resolve_image_src now resolve a ".md"/image
reference that carries a path prefix by bare filename (Writerside's own
convention) instead of silently failing to match/resolve with no
warning; also drops resolve_image_src's dead startswith("/") check.
Blank lines inside a raw HTML run (e.g. <pre>, or between <table> rows)
are preserved instead of dropped. main()'s output-aliases-source guard
now runs before theme.json/images are written, not just before the
topics rmtree. images/ now prunes stale files the same way topics/
JSON already did, via a new _prune_stale_files. Documented that "html"
blocks are passthrough fragments that may be structurally unbalanced
across block boundaries. markdown-it-py pinned with a >=2.0 floor
(Token.attrs was a list before that version).

13 new regression tests; 55/55 pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@alexmmiller

Copy link
Copy Markdown
Collaborator Author

Review: database-insertion path

Focused pass over the files that write to documentation.db. Four things on this branch do:

File What it writes
ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/populate_db.py wipes and rebuilds k/html/* + assets/*
ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/insert_optimized_media.py replaces k/html/images/*, rewrites pages, deletes unreferenced media
scripts/sync_kotlin_stdlib_docs/sync_kdoc_json_to_db.py overwrites/deletes k/kotlin-{stdlib,reflect,test}/* + orphaned tooltips
.github/workflows/build-kotlin-docs.yaml:244 one inline INSERT OR IGNORE INTO ContentTypes

optimize_media.py, build_nav.py, and find_missing_assets.py never open a connection.

Two things that are right, up front

Content types are handled correctly throughout. Every type is resolved by value through get_content_type/get_id, which raise if the row is missing; extension mapping is an explicit allowlist (EXTENSION_TO_CONTENT_TYPE) that skips-and-warns on an unknown extension rather than falling back to text/plain; compression is read from ContentTypes.compression rather than assumed. There is not one hardcoded contentTypeID in the PR. This matters because ~34% of the .html rows in the current production DB are typed text/plain, caused by exactly the two anti-patterns this PR avoids (a text/plain fallback for unrecognized extensions, and hardcoded positional type IDs). The text/html-for-JSON-storage choice at populate_db.py:149 is deliberate and correct — templateId points the server at page.peb, so the served type is HTML.

The big DELETE is correctly scoped. populate_db.py:530's DELETE FROM Content WHERE path LIKE 'k/html/%' OR path LIKE 'assets/%' hits 489 rows against the current production DB (267 pages + nav, 222 images) plus the 3 assets — exactly what the script reinserts. The 3,264 k/ rows outside k/html/ (stdlib, disambiguation) are untouched.

Findings

1. HIGH — a conversion failure silently drops a live page and leaves a normal-looking nav link to it. populate_db.py:483

convert_file failures are caught, logged to stderr, and skipped, so the page never reaches pages and no Content row is inserted — but line 530 already deleted the old, working row in the same transaction, which then commits. Worse, topic_index_db still contains the stem (only blacklisted stems get popped at line 460), so build_node resolves it, sets no_link = False, and emits a normally-styled <a href="/k/html/<stem>.html"> with a humanize()-derived title. Net effect of one bad .md: a page vanishes from production and the sidebar links to a 404 in the same styling as every working page.

This is a regression against the standalone path — build_nav.py's load_page_index builds stem_to_id from generated JSON, so a failed page drops out of nav automatically. The in-process variant loses that property.

Minimum fix: topic_index_db.pop(md_path.stem, None) in the except, matching the blacklist path so links render styled-broken. Better: exit non-zero on any conversion failure, since CI uploads this DB to Drive and nothing currently checks that Converted {len(pages)}/{len(md_files)} is a clean ratio.

2. MEDIUM-HIGH — duplicate .md stems abort the whole run. populate_db.py:475-479

md_files iterates every *.md and derives db_id = f"k/html/{md_path.stem}". Content.path is UNIQUE (UNIQUE('path') in the live schema does enforce — SQLite resolves the quoted string as an identifier; verified), so two same-stem files in different topics/ subdirectories mean two inserts at one path → IntegrityError → rollback → raw traceback, after the full conversion pass has already run. md_to_json.build_topic_index handles exactly this case with keep-first-plus-warning, and topic_index_db inherits that dedupe — but the conversion loop doesn't. It fails safe (nothing uploaded), but it should dedupe by stem the same way rather than crash on an upstream rename.

3. MEDIUM — delete_unreferenced_media is an unguarded mass delete with no dry-run. insert_optimized_media.py:290

It deletes every k/html/images/* row not referenced by a k/html/%.html page with templateId != 0. In CI that's safe — steps 2 and 3 share if: !inputs.skip_website_docs, so pages always exist. Run standalone against a DB without those page rows and collect_referenced_media returns an empty set, so it deletes every image, including the ones inserted seconds earlier in the same transaction. There's no floor check (if not referenced: abort) and, unlike sync_kdoc_json_to_db.py, this script has no --dry-run — the most destructive of the three is the one without a preview mode.

Related blind spot: only page/nav rows are scanned, so an image referenced from assets/docs.css or a .peb template would be collected as garbage. Latent today (no url() in docs.css, no image refs in either template — checked), but it's a silent-data-loss trap for whoever adds a CSS background later.

4. MEDIUM — sync_kdoc_json_to_db.py ignores the chunking contract the rest of the PR treats as mandatory. sync_kdoc_json_to_db.py:164

It does UPDATE Content SET content = ? with the entire new blob and never chunks. Meanwhile any existing <path>-N fragment matches the prefix LIKE, fails relative_target_path's file lookup, and gets deleted. So a stdlib page crossing 1 MiB produces one oversized row that no longer matches WebServer.kt's reassembly protocol — the same protocol populate_db.py:145 argues has to be byte-exact on both sides. Latent right now (largest stdlib blob is 172 KB, and none of the 44 chunked rows in the DB are under these prefixes), but the two scripts disagree on an invariant the PR itself documents as non-negotiable.

5. MEDIUM — unknown contentTypeID silently writes uncompressed bytes and commits. sync_kdoc_json_to_db.py:154-157

compression_by_type.get(...) missing → compression = "none" → raw bytes stored in a row whose declared type says brotli. The warning goes to stderr, the transaction commits, and the server gets undecompressable content. Given that a mistyped content column already shipped to production undetected for months, this should be fatal, not a warning.

6. LOW-MEDIUM — delete-after-insert in the rename loop can remove a just-inserted row. insert_optimized_media.py:415

Inserts run first, then for old_name in rename_map: delete_content(...). If one rename's new name equals another's old name (a.png → b.webp alongside b.webp → c.webp), the delete pass removes the row the insert pass just wrote from a.png. This is precisely the chain-rename hazard rewrite_pages' docstring reasons about carefully for text substitution, and the same reasoning wasn't applied here. Unlikely with Writerside sources; the fix is just moving the delete loop above the insert loop.

7. LOW — unescaped LIKE wildcards. insert_optimized_media.py:103

delete_content does path LIKE ? with f"{path}-%" built from a filename. _ and % are wildcards. No current image name contains either, but NAV_CONTENT_PATH is k/html/_nav.html, so the pattern is already k/html/_nav.html-% with a live wildcard in it. Add ESCAPE '\' and escape the parameter. Every other LIKE in the PR uses literal prefixes and is fine.

8. LOW — image_index_db assumes a flat zip and would break silently otherwise. populate_db.py:467

{name: name for name in image_names} keys on the full zip entry, but Converter.resolve_image_src looks up src.rsplit("/", 1)[-1], and md_to_json.build_image_index maps bare-name → rel-path. A nested zip entry gives a key nothing can match, so images resolve as missing while the row lands at k/html/images/sub/foo.png. {Path(name).name: name} makes it match the documented contract at no cost.

9. LOW — the three scripts back up two different ways. populate_db.py:212 and insert_optimized_media.py use VACUUM INTO (documented as WAL-safe); sync_kdoc_json_to_db.py:39 uses shutil.copy2. The live DB is journal_mode=delete, so this isn't currently biting, but the PR's own docstring makes the argument for VACUUM INTO and then one script doesn't follow it. Also worth noting all three run in CI against the same ~300 MB file, so a run keeps three full backup copies on the runner.

Nits

  • build-kotlin-docs.yaml:151 says scour/cairosvg are "not in the root requirements.txt" — this same PR adds them to requirements.txt, so they're installed twice and the comment is stale.
  • .gitignore's *.db doesn't match documentation.db.backup-<ts> or documentation.db.bak.<ts>, so every local run leaves untracked noise in git status.

Merge order

The PR body's caveat holds — populate_db.py, build_nav.py, and find_missing_assets.py all import md_to_json, which isn't in this diff, so nothing here is runnable until #23 lands. This review was done against #23's copy of md_to_json.py.

Alex Miller and others added 4 commits August 24, 2026 15:10
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>
All six are latent: the live kotlin-web-site corpus triggers none of them, and
converting all 303 pages before and after produces byte-identical JSON with an
identical warning set. Each is a case the corpus happens not to contain today.

 - An explicit {id="..."} only beat a *later* auto-slug. In the other order
   (`## Custom anchor` then `## Something {id="custom-anchor"}`) the auto-slug
   claimed the id first and both headings shipped id="custom-anchor", so any
   link to the explicit one landed on the auto one. reserve_explicit_heading_ids
   now registers every explicit id up front, before any heading converts, so
   explicit wins in both directions. Scoped to inlines directly inside a
   heading - a paragraph merely ending in "{id=...}" is not an anchor. Two
   genuinely identical explicit ids are a source bug this can't fix, so both
   are kept as authored and it warns.
 - A heading whose text is all punctuation ("## ...") slugified to "", i.e.
   id="" - invalid HTML and unlinkable. Falls back to "section", numbered by
   the existing de-dup loop.
 - extract_title matched a `[//]: # (title: ...)` comment inside a fenced code
   block, which both set a bogus title and deleted that line out of the code
   sample being displayed. It now skips matches inside ``` / ~~~ fences,
   handling tilde fences, longer fences nesting shorter ones, and an
   unterminated fence running to EOF.
 - A raw html_block run of nothing but blank lines emitted
   {"type": "html", "html": ""} - a truthy list of empty strings. Blank lines
   inside a run that has content are still preserved, which is what keeps
   <pre>/<script> bodies intact.
 - COLOR_RE's flat {3,8} accepted #12345 and #1234567, which no browser does.
   Narrowed to the lengths CSS defines. This value is interpolated into a
   style="" attribute, so the validator should mean what it says.
 - build_tree popped an already-empty stack on a stray closing token, turning
   a malformed stream into "IndexError: pop from empty list" with nothing
   naming the cause. Defensive - markdown-it always balances - but it now
   ignores the stray closer and says so.

Docstring: the "Block shapes" list omitted note/tip/warning, which
group_containers genuinely emits (README already documented them). Also records
that a <tab> written outside any <tabs> comes through as a bare "tab" block a
template won't render - verified, and it does not warn.

Tests: 55 -> 91, coverage 66% -> 81% (main() is subprocess-tested, so
under-reported either way). Beyond one regression test per fix, this closes the
coverage gaps the review named: %variables% substitution and load_variables had
no test at all; block-level <note> was covered by nothing, since the corpus only
uses the single-line form; and both <tabs> fallback paths - synthesizing tabs
from adjacent code fences, and dropping a wrapper with nothing tab-like - were
untested despite each having fixed a content-dropping bug. Adds direct tests for
the blockquote/list/table/hr converters and a guard that "_raw" never reaches
the JSON.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants