Context
The v13 JSDoc cleanup (#12899) made the docs build fail loudly on unparseable type expressions — buildScripts/docs/jsdocx.mjs now exits non-zero when any jsdoc-x batch throws. #12899 explicitly deferred the prevention arm as a post-v13 follow-up, out of scope: "a lint/CI gate running the parser in check-mode." That follow-up was never filed, so the failure mode recurred: new TS-like JSDoc type expressions have landed in src/dashboard/ and again break npm run generate-docs-json (the last step of build all) and the docs-app content. This ticket is that deferred #12899 follow-up plus the cleanup of the current breakers. Operator-flagged as the top-priority next item.
Release classification: release-blocker candidate — breaks the build all docs step + docs-app content; board on the active release project if release-gating.
The Problem
jsdoc-x parses type expressions with catharsis (the Closure/JSDoc type grammar — not TypeScript). A bare union inside a record-type value breaks it, and — empirically (V-B-A below) — the precise trigger is a bare union with no space after the colon:
| type expression |
catharsis.parse(expr, {jsdoc:true}) |
{layout:Object|null, errors:String[]} (no space) |
FAIL — Expected … but "|" found |
{layout: Object|null, errors: String[]} (space) |
OK |
{layout:(Object|null), errors:String[]} (parenthesized) |
OK |
The build aborts on the first failing batch (runner.mjs Promise.all rejects → jsdocx.mjs .catch sets exitCode=1) without enumerating the rest — which is why the raw log reads like "50+ errors" (one batch's errors, doubled across the error + stack re-print). The true unparseable set across the full build scope (src, ai, docs/app, apps) is 5 record-unions, all in the dashboard docking subsystem.
The Architectural Reality
- Parser chain:
buildScripts/docs/jsdocx.mjs → buildScripts/docs/jsdoc-x/{index,runner}.mjs → jsdoc-api → catharsis (dictionaries: ['jsdoc','closure']). Scope (jsdocx.mjs:18): src/**, ai/**, docs/app/**, configured apps/**; excludes underscore-prefixed paths + the machine-local config overlays.
- The fail-loud-but-abort-on-first-batch behavior is by design (#12899) — correct for the build, but it surfaces late (last
build all step) and never enumerates. A fast pre-merge lint that runs the same parser and enumerates all offenders is the missing prevention layer.
- The 5 current breakers (all
@returns record-unions, no space after colon):
src/dashboard/DockZoneModel.mjs:655 {{layout:Object|null, errors:String[]}}
src/dashboard/DockZoneModel.mjs:734 {{document:Object|null, errors:String[]}}
src/dashboard/DockZoneModel.mjs:891 {{collection:Object|null, errors:String[]}}
src/dashboard/DockZoneModel.mjs:1056 {{document:Object|null, errors:String[]}}
src/dashboard/DockSplitter.mjs:215 {{document:Object|null, errors:String[]}}
- The rest of the codebase (e.g.
src/ai/WriteGuard.mjs:64, src/ai/admitWrite.mjs:31, much of ai/**) already uses the spaced or parenthesized form → catharsis-valid → must NOT be touched.
The Fix
- Clean the 5 breakers — parenthesize the union:
Object|null → (Object|null). Matches the codebase's established in-record-union pattern (admitWrite.mjs, DockZoneModel.mjs:384 slot:(Number|String)), is robust regardless of spacing, preserves author intent, minimal diff.
- Add
buildScripts/util/check-jsdoc-types.mjs — a catharsis-scan lint (sibling of check-shorthand.mjs / check-aiconfig-test-mutation.mjs): extract type expressions from /**-block type tags (@param/@returns/@type/@property/@typedef/@yields/@throws/…) and run catharsis.parse(expr, {jsdoc:true}), failing on parse errors. Runs the same parser the build uses, catching exactly what breaks build all — including future TS-isms a regex couldn't anticipate. CLI: positional file-args (lint-staged) OR no-args full-build-scope scan (CI). Exported core for a unit spec. /**-only (jsdoc ignores /* and //).
- Wire it: lint-staged
*.mjs entry (fast local pre-commit) + .github/workflows/jsdoc-type-lint.yml (the firm gate; npm ci --ignore-scripts for catharsis, skipping the heavy postinstall).
- Unit spec locking the behavior + the spacing quirk.
Contract Ledger
Not applicable — no consumed API / MCP tool / error-code / config-key surface changes. The lint is a new build/CI gate that enforces the docs-build's already-existing parse requirement earlier; it introduces no new contract, only surfaces an existing one pre-merge. (Ledger gate run per discipline → negative result.)
Decision Record impact
none — build tooling + JSDoc-comment hygiene; no ADR governs the docs-build type grammar.
Acceptance Criteria
Out of Scope
src/sitemap/Component.mjs:30 @member {String[} (malformed-bracket typo) — it lives in a /* single-star comment jsdoc ignores, so it does NOT break the build; the /**-only lint correctly won't flag it. (Separate latent issue: those sitemap member docs use /* and aren't parsed at all.)
- Re-styling the ~30 valid spaced/parenthesized record-unions — they parse fine; pure churn.
- Forking/patching catharsis's no-space quirk — we conform to the parser, not patch it.
Avoided Traps
- Regex linter — the trigger is a catharsis tokenizer quirk (spacing-sensitive); a regex would false-positive on valid spaced unions or miss other TS-isms. Running the real parser is the only correct design (the spacing table above is the falsifying evidence).
- Heavy CI install — the other lint workflows are dependency-free; mine needs catharsis, so
npm ci --ignore-scripts keeps it lean.
Related
- #12899 (origin — this is its deferred post-v13 lint follow-up; the fail-loud arm shipped there)
- Dashboard docking subsystem: #13158 (epic), #13247 — the breakers landed here.
Handoff Retrieval Hints
- Semantic: "catharsis no-space union record JSDoc type lint docs build"
- Anchors:
buildScripts/docs/jsdocx.mjs:18, buildScripts/docs/jsdoc-x/runner.mjs:104-116, src/dashboard/DockZoneModel.mjs:{655,734,891,1056}, src/dashboard/DockSplitter.mjs:215.
Authored by Claude Opus 4.8 (Claude Code), @neo-opus-vega (Vega).
Context
The v13 JSDoc cleanup (#12899) made the docs build fail loudly on unparseable type expressions —
buildScripts/docs/jsdocx.mjsnow exits non-zero when anyjsdoc-xbatch throws. #12899 explicitly deferred the prevention arm as a post-v13 follow-up, out of scope: "a lint/CI gate running the parser in check-mode." That follow-up was never filed, so the failure mode recurred: new TS-like JSDoc type expressions have landed insrc/dashboard/and again breaknpm run generate-docs-json(the last step ofbuild all) and the docs-app content. This ticket is that deferred #12899 follow-up plus the cleanup of the current breakers. Operator-flagged as the top-priority next item.Release classification: release-blocker candidate — breaks the
build alldocs step + docs-app content; board on the active release project if release-gating.The Problem
jsdoc-xparses type expressions with catharsis (the Closure/JSDoc type grammar — not TypeScript). A bare union inside a record-type value breaks it, and — empirically (V-B-A below) — the precise trigger is a bare union with no space after the colon:catharsis.parse(expr, {jsdoc:true}){layout:Object|null, errors:String[]}(no space)Expected … but "|" found{layout: Object|null, errors: String[]}(space){layout:(Object|null), errors:String[]}(parenthesized)The build aborts on the first failing batch (
runner.mjsPromise.allrejects →jsdocx.mjs.catchsetsexitCode=1) without enumerating the rest — which is why the raw log reads like "50+ errors" (one batch's errors, doubled across the error + stack re-print). The true unparseable set across the full build scope (src,ai,docs/app,apps) is 5 record-unions, all in the dashboard docking subsystem.The Architectural Reality
buildScripts/docs/jsdocx.mjs→buildScripts/docs/jsdoc-x/{index,runner}.mjs→jsdoc-api→catharsis(dictionaries: ['jsdoc','closure']). Scope (jsdocx.mjs:18):src/**,ai/**,docs/app/**, configuredapps/**; excludes underscore-prefixed paths + the machine-local config overlays.build allstep) and never enumerates. A fast pre-merge lint that runs the same parser and enumerates all offenders is the missing prevention layer.@returnsrecord-unions, no space after colon):src/dashboard/DockZoneModel.mjs:655{{layout:Object|null, errors:String[]}}src/dashboard/DockZoneModel.mjs:734{{document:Object|null, errors:String[]}}src/dashboard/DockZoneModel.mjs:891{{collection:Object|null, errors:String[]}}src/dashboard/DockZoneModel.mjs:1056{{document:Object|null, errors:String[]}}src/dashboard/DockSplitter.mjs:215{{document:Object|null, errors:String[]}}src/ai/WriteGuard.mjs:64,src/ai/admitWrite.mjs:31, much ofai/**) already uses the spaced or parenthesized form → catharsis-valid → must NOT be touched.The Fix
Object|null→(Object|null). Matches the codebase's established in-record-union pattern (admitWrite.mjs,DockZoneModel.mjs:384slot:(Number|String)), is robust regardless of spacing, preserves author intent, minimal diff.buildScripts/util/check-jsdoc-types.mjs— a catharsis-scan lint (sibling ofcheck-shorthand.mjs/check-aiconfig-test-mutation.mjs): extract type expressions from/**-block type tags (@param/@returns/@type/@property/@typedef/@yields/@throws/…) and runcatharsis.parse(expr, {jsdoc:true}), failing on parse errors. Runs the same parser the build uses, catching exactly what breaksbuild all— including future TS-isms a regex couldn't anticipate. CLI: positional file-args (lint-staged) OR no-args full-build-scope scan (CI). Exported core for a unit spec./**-only (jsdoc ignores/*and//).*.mjsentry (fast local pre-commit) +.github/workflows/jsdoc-type-lint.yml(the firm gate;npm ci --ignore-scriptsfor catharsis, skipping the heavy postinstall).Contract Ledger
Not applicable — no consumed API / MCP tool / error-code / config-key surface changes. The lint is a new build/CI gate that enforces the docs-build's already-existing parse requirement earlier; it introduces no new contract, only surfaces an existing one pre-merge. (Ledger gate run per discipline → negative result.)
Decision Record impact
none— build tooling + JSDoc-comment hygiene; no ADR governs the docs-build type grammar.Acceptance Criteria
dashboard/record-unions are parenthesized → catharsis-parseable.npm run generate-docs-jsonpasses after the fix (ground-truth build gate).buildScripts/util/check-jsdoc-types.mjs: scans/**-block type tags over the build scope via catharsis; exit 1 + lists offenders on failure, exit 0 on clean; supports file-args (lint-staged) + no-args (CI full scan) modes.*.mjs+.github/workflows/jsdoc-type-lint.yml(workflow green on the branch)./*-block ignored, malformed-bracket FAILs — proving catch-correctness with no false-positives/negatives.Out of Scope
src/sitemap/Component.mjs:30@member {String[}(malformed-bracket typo) — it lives in a/*single-star comment jsdoc ignores, so it does NOT break the build; the/**-only lint correctly won't flag it. (Separate latent issue: those sitemap member docs use/*and aren't parsed at all.)Avoided Traps
npm ci --ignore-scriptskeeps it lean.Related
Handoff Retrieval Hints
buildScripts/docs/jsdocx.mjs:18,buildScripts/docs/jsdoc-x/runner.mjs:104-116,src/dashboard/DockZoneModel.mjs:{655,734,891,1056},src/dashboard/DockSplitter.mjs:215.Authored by Claude Opus 4.8 (Claude Code), @neo-opus-vega (Vega).