Skip to content

Fix/adfa 4514 [KDoc-to-JSON plugin] - #18

Open
alexmmiller wants to merge 18 commits into
mainfrom
fix/ADFA-4514
Open

Fix/adfa 4514 [KDoc-to-JSON plugin]#18
alexmmiller wants to merge 18 commits into
mainfrom
fix/ADFA-4514

Conversation

@alexmmiller

@alexmmiller alexmmiller commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Plugin for Dokka that intercepts the HTML rendering process to output raw JSON data in its place.

Example output with sourceSet whitelisting: "kotlin-jvm-common-json.zip" attachment at https://appdevforall.atlassian.net/browse/ADFA-4737

alexmmiller and others added 8 commits July 1, 2026 15:42
configJson was a dedicated Json instance used only for the manual
config-decode fallback path. Inlining it removes the standalone
property while keeping the same ignoreUnknownKeys tolerance for
extra keys in user-authored plugin config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- scripts/verify_package_index.py: confirms every object listed in a
  package's index.json actually has documented content on the page its
  url points to (matching by dri), not just that the file exists.
- scripts/kotlin/test_kotlin_stdlib.sh: pure-bash check that the
  default HTML build and the kdoc-to-json JSON build have a one-to-one
  set of pages (extension-swap aware, in both directions).

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

Copy link
Copy Markdown
Collaborator

From Claude AI:

Issues found

  1. omitNulls config option almost certainly doesn't work as documented.
    In JsonPluginConfig.kt:

kotlin
@SerialName("omit-nulls")
val omitNulls: Boolean = false,

Every other field in this data class relies on its default (property-name-matching) serial name — this is the only one with a custom @SerialName, and it maps to kebab-case "omit-nulls" instead of "omitNulls". But the README's own config example (section 3) and its config table both show:

json
{ ..., "omitNulls": true }

Since parsing goes through kotlinx.serialization either via Dokka's native config decoder or the manual Json { ignoreUnknownKeys = true } fallback in JsonRenderer.kt, a user who copies the README's example verbatim will have "omitNulls" silently treated as an unknown key, and omitNulls will stay at its default false — with no error, since ignoreUnknownKeys swallows it. This seems like a stale/accidental annotation (every other field name should probably work here) rather than an intentional key rename. Worth fixing the annotation (or the docs, if kebab-case really is intended) and probably adding a quick round-trip test on JsonPluginConfig to catch this class of bug in future.

  1. README section 7 ("Resolving Cross-Module Links") describes behavior the code doesn't actually implement.
    The README says an unresolved DRI "means the LinkPostProcessor failed to find that DRI" and implies you'd inspect the output for unresolved:... strings to debug it. But in LinkPostProcessor.kt:

kotlin
val replaced = unresolvedRegex.replace(text) { matchResult ->
val dri = matchResult.groupValues[1]
val resolved = driIndex[dri]
if (resolved != null) { ...; "$rootPrefix$resolved" } else { "#" }
}

Every unresolved: gets rewritten in this same pass — resolved ones become a relative path, unresolved ones become a bare "#". So the final on-disk JSON never actually contains an unresolved: string to grep for; the documented troubleshooting technique (search rendered JSON for unresolved:) won't find anything even when links failed to resolve. If diagnosing failed resolutions is meant to be possible, the plugin needs to log which DRIs fell through to "#" (it currently doesn't — only a final aggregate "Successfully resolved $replacedCount..." count is logged, with no count/list of failures), or the docs should stop implying you can find them by inspecting output.

  1. Minor: inconsistent error handling between LinkPostProcessor's two passes.
    Pass 1 (building the DRI index) wraps each file in try/catch and logs+continues on failure. Pass 2 (the actual rewrite) has no such guard — a single unreadable/unwritable file (permissions, disk full, concurrent modification) throws and aborts the whole post-process step, leaving every other file's links unpatched even though the index was already built successfully. Worth matching pass 1's resilience here, especially since this runs as the last step of a potentially long Dokka build.

  2. Minor: driIndex[dri] = fullUrl in extractDris silently last-writer-wins if the same DRI resolves to a URL in more than one file (plausible with expect/actual declarations across source sets). Not necessarily wrong, but undocumented — a one-line comment on why last-wins is fine (or a warning when it happens) would help a future reader trust this is intentional rather than an oversight.

On the "sourceSet whitelisting" commits

The last two commits (sourceSet whitelisting, Whitelist sourceSets per ADFA-4737) look purposeful and match a real ticket, and the whitelist logic itself (in JsonRenderer/ModelMapper) looks correct from what I read. I'd suggest double-checking verify_sourceset_whitelist.py gets run against a real kotlin-stdlib build with a whitelist configured, as part of this PR's test plan — I didn't see that confirmation anywhere in the PR description (there's no "Test plan" checklist here at all, unlike #21).

Suggestion: given the size (10 commits, an entire new Gradle module) and no test plan in the description, it'd help reviewers if the PR description at least linked a sample run's output (e.g. from scripts/build-example.sh or test_kotlin_stdlib.sh) so they don't have to build the whole thing locally to sanity-check items 1–2 above.

alexmmiller and others added 3 commits July 27, 2026 17:00
- Remove stray @SerialName("omit-nulls") on JsonPluginConfig.omitNulls
  so the README's documented "omitNulls" key actually deserializes
  instead of silently being ignored.
- Log DRIs that LinkPostProcessor fails to resolve (patched to "#"),
  and wrap pass 2's file rewrite in try/catch so one bad file no
  longer aborts link resolution for the rest of the build.
- Document the last-writer-wins behavior when a DRI resolves in more
  than one file.
- Update README section 7 to match actual behavior: the final JSON
  never contains "unresolved:" strings, so failures should be found
  via the build log instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fix examples/example-data-processor first: Nesting.kt referenced Meta,
Provider, and DataMap, none of which were defined anywhere, so the
example library didn't even compile. Add Types.kt with those
definitions, which also happens to exercise the annotation/interface/
typeAlias "kind" values.

Parameterize the example project's plugin config (build.gradle.kts) to
read from a KDOC2JSON_TEST_CONFIG env var when set, so tests can drive
the plugin through arbitrary configs without editing the build script.

Add tests/, one bash script per config option (logLevel, logFile,
replaceHtmlExtension, omitFields, omitNulls, classDiscriminator,
prettyPrint, sourceSetWhitelist) plus a shared lib.sh harness and
run_all.sh runner. Each test builds the example project against a real
config and asserts on the resulting JSON output.

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

Copy link
Copy Markdown
Collaborator Author

Addressed David's comment, added tests for config options, added a test plan I'm going to start working through

alexmmiller and others added 4 commits July 27, 2026 18:21
Fills the fixture gaps listed in TEST_PLAN.md §1 so later steps (§2-§9)
have real constructs to assert against: a bounded generic
(BoundedContainer<T : Comparable<T>>), use-site variance
(copyItems(List<out Number>, MutableList<in Number>)), a nullable type
and Java interop (formatOrDefault, currentJavaDate), an extension
function/property, a suspend function, an infix function, an operator
function, a function with a default parameter, a repeatable
multi-parameter annotation applied twice plus one applied to a
parameter, a data class, a sealed class hierarchy with a real
companion object, equals/hashCode/toString overrides, a Throwable
subclass, and a KDoc comment exercising @param/@return/@throws/@see,
two @sample tags, nested bold/italic/list markup, and raw <, >, & in
prose.

Verified each construct against the actual generated JSON (not just
that it compiles) -- e.g. confirmed the "redundant projection" compiler
warning on copyItems doesn't stop Dokka's model from still emitting
distinct Covariance/Contravariance projections.

Not included: the expect/actual fixture (TEST_PLAN §1 last row). That
needs converting this example project to Kotlin Multiplatform with a
second source set, which would invalidate the "main"-source-set
assumption the existing tests/test_*.sh scripts rely on -- deferred to
its own change rather than bundled in here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds tests/test_dto_mapping.sh, test_type_mapping.sh, and test_doc_tags.sh,
covering TEST_PLAN.md §2 (ModelMapper.mapToDto correctness), §3
(type/bound/projection mapping), and §4 (doc-tag & text extraction).
Structural assertions live in tests/helpers/check_*.py (with a small
shared checklib.py) since they need real JSON parsing rather than grep,
following the precedent already set by
scripts/verify_sourceset_whitelist.py.

Two more small fixture prerequisites, alongside step 1's Advanced.kt:
- Counter (a var property) and applyCallbacks (plain/extension/suspend
  lambda parameters) in Advanced.kt, needed for the property
  getter/setter and functional-type rows in §2/§3.
- A fenced code block and a blockquote added to safeDivide's KDoc, needed
  for §4's block-level-tag row.
- dokkaSourceSets.configureEach { samples.from(...) } in build.gradle.kts:
  without it Dokka never resolves @sample tags at all, regardless of the
  plugin's own fallback logic.

Also fixes a real bug this surfaced in ModelMapper.kt: the @sample
fallback looked for a ContentCodeBlock whose *style* contained
"RunnableSample", but Dokka's DefaultSamplesTransformer actually tags
these blocks via ContentKind.Sample on `dci.kind`, not via style. The
old check silently matched nothing, so every @sample tag came through
with empty text. Verified via javap on dokka-base or the sample text is
now correctly extracted and multiple @sample tags on one doc each pull
their own distinct source.

Verified test efficacy by mutating a real mapping (Covariance ->
Contravariance) and confirming test_type_mapping.sh caught it, then
reverted.

Known gap, not fixed here (needs a schema change, not a test): `@see`
tags never get a resolved url. Dokka's `See` DocTag carries its
resolved link on a dedicated `address: DRI?` field, but TagWrapperDto
has no `url` property for mapDocNodes to populate. `@throws`/inline
[Foo]-links elsewhere still resolve correctly since those go through
the tag's `root` content instead.

Not covered: §4's per-source-set documentation row, which needs an
expect/actual pair across two source sets -- deferred along with the
rest of the multiplatform work noted in the Advanced.kt commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds tests/test_renderer.sh (§6: package-list, all-types.json, breadcrumbs
at root/max-depth, and file-path parity vs Dokka's own HTML layout) and
tests/test_link_postprocessor.sh (§7: no lingering "unresolved:" strings,
and a genuinely-unresolvable DRI correctly patched to "#" with a matching
build-log warning). Structural checks live in tests/helpers/check_*.py,
same pattern as step 2.

New fixture for the file-path-parity check: examples/html-baseline, a
stock-Dokka-HTML sibling of example-data-processor that reuses the exact
same sources via kotlin.srcDir(...) but has no kdoc-to-json plugin
dependency, so it renders with Dokka's default HTML renderer. Confirmed
by diffing the two builds' page sets that our JSON pages line up with
Dokka's own HTML pages one-for-one, aside from each renderer's own
top-level artifacts (our all-types.json aggregate; Dokka's
navigation.html and static template assets) and a module-name-prefix
folder convention Dokka's default renderer adds that ours doesn't.

lib.sh: run_dokka now records the Gradle log path in LAST_GRADLE_LOG so
tests can assert on build-log content, not just written JSON.

Verified test efficacy by mutating a real behavior (package-list sort
order) and confirming test_renderer.sh caught it, then reverted.

Deferred (per discussion before starting this step): three TEST_PLAN
rows structurally need a real multi-module or multiplatform Dokka
build, which this single-module fixture can't provide -- confirmed
empirically ("Successfully resolved 0 cross-module links!" in the
build log, i.e. every "unresolved:" marker here is either resolved
directly by locationProvider or is permanently unresolvable):
- §6 multimodule index.json
- §7 relative-path-depth for a genuinely cross-module-resolved DRI
- §7 last-writer-wins for expect/actual
All three are left to TEST_PLAN.md §8's kotlin-stdlib stress test,
which already covers real multiplatform/multimodule behavior at scale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds tests/test_robustness.sh covering: an unknown extra config key
(tolerated), no plugin config registered at all (falls back to
defaults), an empty/near-empty module (a package with zero public
declarations doesn't crash the build), and a classDiscriminator
collision (fails with a clear serialization error).

New harness support in lib.sh: run_dokka_expect_failure (for assertions
where the build SHOULD fail, without aborting the script) and
run_dokka_no_plugin_config (drives the new KDOC2JSON_NO_PLUGIN_CONFIG
env var in build.gradle.kts, which skips registering any
pluginsConfiguration entry at all).

New fixture: examples/example-data-processor/.../emptypkg/InternalOnly.kt,
an internal-only declaration in its own package so that package has zero
documented (public) members.

Corrects a real inaccuracy this surfaced in TEST_PLAN.md's own "Malformed
plugin config" row: the original wording assumed any bad config falls
through to JsonRenderer's own try/catch and its lenient manual parse.
Verified that's only true for valid-JSON-with-an-unknown-key. Genuinely
invalid JSON syntax and valid JSON with a wrong-typed known field both
crash the whole Gradle build one layer up -- Dokka's own Gradle plugin
deserializes jsonEncode()'s output against JsonPluginConfig client-side
(via Jackson) before our worker/renderer code ever runs, so those two
cases never reach our try/catch at all. Nothing to fix in this plugin's
code for that; it's upstream Dokka Gradle Plugin behavior. Updated the
TEST_PLAN.md row to describe the actual, verified boundary instead.

Verified test efficacy by mutating a real default (replaceHtmlExtension
false -> true) and confirming test_robustness.sh caught it, then
reverted.

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

Copy link
Copy Markdown
Collaborator Author

Implemented all automation specified in TEST_PLAN.md. Actual testing against Dokka base kotiln-stdlib can be done manually.

@luisguzman-adfa luisguzman-adfa left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hello @alexmmiller please find here the review I can provide;

PR #18 — review comments

Ran build-example.sh + run_all.sh on ARM64/Ubuntu 24.04: build OK, 14/14 test scripts pass.

The following comes from this review, each is a Suggestion (concrete fix) or a Decision point (design call to make). Most useful first:

1. Accessor/param links resolve to dead # (design call).
The example logged Failed to resolve 28 DRIs (patched to "#") — getters/setters, ctor params. resolveUrl (ModelMapper 24-40) emits unresolved: for synthetic accessors, then LinkPostProcessor (45-51) turns anything not in the index into "#".

Decision point: either resolve those DRIs to their owner's page (strip the getter/setter/PointingToCallableParameters suffix, look up the parent DRI) so they become real links, or document them as intentional non-links. In a 10-type fixture this already produced 28 dead links, so at real-library scale it could leave many references non-navigable.

2. href is not escaped — injection risk. extractText (ModelMapper 178-183) builds <a href="$href"> from KDoc; a " in a link breaks/injects into the HTML that ships inside the JSON.

Suggestion: Escape the attribute value ("&quot;).

3. Hard cast can crash mapping. mapProjection(...) as VarianceDto (ModelMapper 224) throws ClassCastException if Dokka returns Star/a bare bound.

Suggestion: as? VarianceDto ?: InvarianceDto(...).

4. Render loop isn't resilient like LinkPostProcessor now is. Page writes (JsonRenderer 178-207) have no try/catch, so one unwritable file aborts the whole build's output.

Suggestion: Wrap per-file write in try/catch + warn, matching the hardening already done in LinkPostProcessor.

5. mapExtras reflects on Dokka internals by class/method name (DefaultValue, AdditionalModifiers, ObviousMember…), swallowing failures into debug. This silently degrades when someone bumps Dokka (which the README invites).

Suggestion: Mark it version-coupled with a comment + add one assertion against the pinned Dokka so a break is loud.

6. omitNulls also strips empty strings/arrays/objects, not just nulls (JsonRenderer 247-252) — so functions: [] disappears.

Decision point: Note this in the README (consumers must treat missing keys as empty), or split it as omitEmpty.

7. README §7 is stale. It says grep the JSON for unresolved:, but nothing on disk contains it anymore — the info is in the Failed to resolve N DRIs log.

Suggestion: Point the doc at the log warning.

8. Preview-API warning at JsonOutputPlugin.kt:18

Suggestion: add @OptIn(DokkaPluginApiPreview::class).

9. Gradle drift (infra). Plugin uses wrapper 9.1.0, example uses 9.5.1 (two downloads); both warn they break on Gradle 10 (Kotlin 1.9.x plugin).

Suggestion: Align the wrappers; open a follow-up to bump the Kotlin plugin before Gradle 10.

PR description: add the test-plan and confirmation that verify_sourceset_whitelist.py was run against a real kotlin-stdlib build are not yet there — worth adding, along with the sample run (14/14)

1. Dead accessor/param links (ModelMapper.resolveUrl): getters/setters,
   constructors, and callable parameters never get their own PageNode,
   so they always fell through to "unresolved:" and got patched to a
   dead "#" by LinkPostProcessor -- 28 of them in this fixture alone.
   Now falls back to the owning declaration's own DRI (same
   package/class, no callable, PointingToDeclaration) so these resolve
   to a real, navigable page instead. Cuts this fixture's unresolved
   count from 28 to 1 (a genuinely pageless compiler-internal
   annotation with nothing to fall back to). Updated
   test_link_postprocessor.sh's "genuinely unresolvable DRI" case to
   that one remaining example.

2. href injection (ModelMapper.extractText): <a href="$href"> was built
   from raw KDoc/resolved-link content with no escaping. Added
   escapeHtmlAttribute (& before ", to avoid double-escaping a literal
   &quot; already in the source) and applied it to both the raw-HTML-<a>
   and resolved-DocumentationLink cases.

3. Hard cast could crash mapping (ModelMapper): `mapProjection(...) as
   VarianceDto` for a type parameter's own variance would throw
   ClassCastException if Dokka ever handed back a Star/bare-Bound
   there. Added mapVariance(), which degrades to InvarianceDto around
   whatever bound can be salvaged instead of crashing.

4. Render loop wasn't resilient (JsonRenderer): wrapped the per-page
   write in try/catch + warn, matching the resilience
   LinkPostProcessor's own passes already have, so one unwritable/
   unserializable page doesn't abort every other page's output.

5. mapExtras reflects on Dokka internals by class/method name and is
   silently fragile across Dokka version bumps. Marked it
   VERSION-COUPLED with a comment, and added DokkaVersionCheck, a
   one-time-per-process check that the exact FQCNs this reflection
   depends on are still loadable, logging a loud warning (not silently
   swallowed to debug) if a future Dokka bump renames/moves/removes one.

6. omitNulls also strips empty strings/arrays/objects, not just nulls.
   Documented this explicitly in the README (a missing key must be
   treated as its empty value) rather than splitting it into a new
   omitEmpty option, to avoid a config-surface change in this PR.

8. Added @OptIn(DokkaPluginApiPreview::class) on
   pluginApiPreviewAcknowledgement to silence the preview-API build
   warning.

9. Aligned kdoc-to-json's Gradle wrapper (was 9.1.0) with the example
   projects' (9.5.1) so a contributor building both doesn't trigger two
   separate wrapper downloads.

Not done here (needs the user's own follow-up): opening a tracking
issue for bumping the Kotlin Gradle plugin off 1.9.x before Gradle 10,
and updating the PR description.

Verified end to end: full local suite (tests/run_all.sh) still 14/14
after every change; a fresh dokkaGenerate log now shows only 1
"Failed to resolve" DRI instead of 28, and no preview-API warning.

Co-Authored-By: Claude Sonnet 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