diff --git a/AGENTS.md b/AGENTS.md index 8db586c013..bf219229e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,8 +72,8 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE - `pnpm test` - unit tests - `pnpm dev` - dev server from `examples/` - `pnpm check:types` - raw TS compile across all referenced projects (`tsc -b tsconfig.references.json`). Does NOT run the public-interface chain. Legacy alias: `pnpm run type-check`. -- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + public-method fixture coverage + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`. -- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps ten stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `public-method-coverage`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`. +- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + ts-jsdoc hygiene + public-method fixture coverage + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`. +- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps twelve stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts-test`, `jsdoc-hygiene-ts`, `public-method-coverage`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`. - `pnpm check:public:docapi` - Document API public surface only. Wraps four stages: `contract-parity`, `contract-outputs`, `examples`, `overview-alignment`. Clean-checkout safe: gitignored generated artifacts are built in memory; tracked outputs (reference docs, overview block) are compared byte-for-byte. No mutation. Legacy alias: `pnpm run docapi:check`. - `pnpm generate:docapi` - regenerate Document API outputs after editing the contract (alias of `docapi:sync`). Writes gitignored Document API generated artifacts. Run only when you need the artifacts materialized locally (SDK builds, publishing); `check:public:docapi` does not require it. - `pnpm generate:all` - regenerate schemas, SDK clients, tool catalogs, reference docs. diff --git a/packages/superdoc/scripts/README.md b/packages/superdoc/scripts/README.md index 8ca6c952a2..77026c52e6 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -104,6 +104,7 @@ it stopped running. | `verify-public-facade-emit.cjs` | postbuild | Per-facade expected symbol set + ESM/CJS parity + legacy command-signature compat. Derives the expected name set directly from the facade source file under `packages/superdoc/src/public/**`; rejects `export *` / `export * as X` in facade sources so the contract stays explicit. | Symbol set drift ships silently; CJS shims diverge from ESM; a wildcard re-export silently widens the public surface. | | `report-declaration-reachability.cjs` | postbuild | Instrumentation (not a gate): per-bucket reachability ratio of emitted declarations. | Loses visibility into unreachable emit (the SD-2952 trim target). | | `check-jsdoc.cjs` | wrapper stage 3 (`jsdoc-ratchet`) | Two gates: (a) per-file checkJs on the hand-curated `CHECKED_FILES` (currently 6 files; each must carry `// @ts-check` and stay clean against tsc); (b) ratchet over the public-reachable .js JSDoc surface — every file must be in `CHECKED_FILES`, carry `// @ts-check`, be on `jsdoc-allowlist.cjs` with a reason, or be in `jsdoc-debt-snapshot.json` as known pre-existing debt. New public JSDoc files that aren't accounted for fail with a clear "add @ts-check or allowlist" message. Stale snapshot entries (file gone, gained @ts-check, moved out of public surface) also fail. The allowlist contract is enforced too: every entry must carry a non-empty reason, point at an existing file, and still resolve to a public-reachable JSDoc file. Refresh the snapshot with `pnpm --filter superdoc run check:jsdoc -- --write`. Runs as stage 3 of `check:public:superdoc`. | New public-reachable JSDoc files could land without type coverage; existing ones could lose their `// @ts-check` directive without surfacing as a regression; the allowlist could grow silent / typo-shaped exemptions. | +| `check-jsdoc-hygiene-ts.cjs` | wrapper stage 5 (`jsdoc-hygiene-ts`) | Companion to `check-jsdoc.cjs` for the `.ts` side. Walks every `.ts` file under `packages/superdoc/src/` and `packages/super-editor/src/` (excluding `*.d.ts`, `*.test.ts`, `*.spec.ts`, `dev/`, `__mocks__/`, `__fixtures__/`) and flags type-bearing JSDoc tags: `@type`, `@typedef`, `@callback`, `@template`, `@implements`, `@extends`, `@augments`, `@enum` always; `@param`, `@returns`, `@return`, `@this` only when `tag.typeExpression` is set (prose-only forms pass). AST-based via `ts.getJSDocTags`; not regex. Baseline at `jsdoc-hygiene-ts-baseline.json` uses the stable key `file::enclosingSymbol::tagName::class::occurrenceIndex` so line shifts don't churn entries. Self-tested via `check-jsdoc-hygiene-ts-tests.cjs` (13 in-memory fixtures; name avoids the `*.test.*` glob so vitest doesn't pick it up as a unit-test suite). The self-test suite runs as wrapper stage 4 (`jsdoc-hygiene-ts-test`) immediately before this gate. Policy at `type-hygiene.md`. Refresh with `node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs --write`. | Type-bearing JSDoc in `.ts` files is documentation-only (TS ignores it), so duplicate type information drifts silently — `@param {Element}` while signature said `HTMLElement` is the canonical example. Without this gate, that class of drift ships. | The repo also has a top-level public-contract tier gate. One script, `scripts/report-public-contract.mjs`, with two modes: @@ -146,12 +147,13 @@ what an actual consumer would see — not the workspace source. | `check-root-classification-closure.mjs` | Asserts no `supported-root` or `legacy-root` export references an `internal-candidate` symbol in its public declared type. | Closure rule from SD-3212. | | `check-public-method-coverage.mjs` | Obligation-based ratchet over public `SuperDoc` methods + getters. For each member the AST computes which obligations are meaningful (`parameters`, `returns`, or `call`); the gate fails when any required obligation is unsatisfied by a fixture under `src/` AND not on the debt snapshot. Catches the `search(text: string)` regression class — call sites do NOT satisfy `parameters`/`returns` on their own. | Snapshot at `public-method-coverage-debt-snapshot.json`; allowlist at `public-method-coverage-allowlist.cjs` (each entry validated: key must match a real member, value must be a non-empty reason). Refresh with `--write`. | -Of these, six run as wrapper stages of `check:public:superdoc` -after the cheap policy gates (`contract-tiers-test`, -`contract-tiers`, `jsdoc-ratchet`, `public-method-coverage`) and -`build`: `consumer-typecheck-matrix`, -`deep-type-audit-supported-root`, `package-shape`, -`export-snapshots`, `root-classification-closure`. +Six of these run as wrapper stages of `check:public:superdoc`. +`public-method-coverage` runs alongside the cheap policy gates +(`contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, +`jsdoc-hygiene-ts-test`, `jsdoc-hygiene-ts`) before `build`. The other +five run after `build`: +`consumer-typecheck-matrix`, `deep-type-audit-supported-root`, +`package-shape`, `export-snapshots`, `root-classification-closure`. `consumer-typecheck-matrix` packs `superdoc.tgz` and installs it into the consumer fixture. The rest reuse what matrix produced: `deep-type-audit-supported-root`, `export-snapshots`, and diff --git a/packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs new file mode 100644 index 0000000000..70d2bbcb84 --- /dev/null +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * Tests for `check-jsdoc-hygiene-ts.cjs`. + * + * Protects the scanner against two failure modes: + * + * 1. Silent false-positives — flagging legitimate documentation + * tags (@deprecated, @example, @typeParam, etc.). + * 2. Silent false-negatives — the "scanner returns nothing for + * everything" failure mode, where AST field-name drift or a + * logic bug makes the gate look like it's working when it + * isn't. + * + * Each fixture is an in-memory TypeScript source snippet plus the + * expected list of (tag, class) pairs the scanner should emit. + * + * Wired into check:public:superdoc as the `jsdoc-hygiene-ts-test` + * stage, which runs immediately before `jsdoc-hygiene-ts` so AST + * drift surfaces here, not as a silent zero-result downstream. + * + * File is intentionally named `*-tests.cjs` (not `*.test.cjs`) so + * vitest doesn't pick it up via the `*.test.*` glob — this is a + * standalone Node runner, not a vitest suite. + * + * Run manually with: + * node packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs + */ + +const { findViolations } = require('./check-jsdoc-hygiene-ts.cjs'); + +const FIXTURES = [ + // ─── Negative control ──────────────────────────────────────────── + // Clean public method with TS-only types and prose-only JSDoc. + // Asserts the scanner returns ZERO hits — catches the "silent + // false-negative" failure mode where the scanner produces nothing + // and looks like it's working. + { + name: 'negative-control: prose-only JSDoc + TS types', + src: ` +/** + * Does the thing. + * @param name The thing to do. + * @returns The result of doing it. + */ +export function doThing(name: string): boolean { + return true; +} +`, + expected: [], + }, + + // ─── Mixed-tag block ───────────────────────────────────────────── + // A single JSDoc comment that mixes legitimate documentation tags + // (@deprecated) with one type-bearing tag (@param {string}). Must + // report exactly one violation, not three, not zero. Catches the + // "detector iterates all tags and mis-keys" bug. + { + name: 'mixed-tag block: only the typed @param is flagged', + src: ` +/** + * Mounts the thing. + * @deprecated Use newThing instead. + * @param {string} foo The input. + * @returns The result. + */ +export function mount(foo: string): string { + return foo; +} +`, + expected: [{ tag: 'param', class: 'declaration-doc-type' }], + }, + + // ─── @param with type braces ───────────────────────────────────── + { + name: 'typed @param flagged', + src: ` +/** @param {Element} el */ +export function render(el: HTMLElement): void {} +`, + expected: [{ tag: 'param', class: 'declaration-doc-type' }], + }, + + // ─── @returns with type braces ─────────────────────────────────── + { + name: 'typed @returns flagged', + src: ` +/** @returns {boolean} */ +export function ready(): boolean { return true; } +`, + expected: [{ tag: 'returns', class: 'declaration-doc-type' }], + }, + + // ─── @param prose-only ─────────────────────────────────────────── + { + name: 'prose-only @param not flagged', + src: ` +/** @param el The element. */ +export function render(el: HTMLElement): void {} +`, + expected: [], + }, + + // ─── @type inline ──────────────────────────────────────────────── + { + name: 'inline @type cast flagged', + src: ` +export function pick(): unknown { + const v = {} as unknown; + /** @type {Element} */ + const e = document.body; + return e; +} +`, + expected: [{ tag: 'type', class: 'inline-fake-cast' }], + }, + + // ─── @typedef ──────────────────────────────────────────────────── + { + name: '@typedef always flagged', + src: ` +/** + * @typedef {Object} Options + * @property {string} name + */ +export const x = 1; +`, + // Only @typedef fires; nested @property tags inside the typedef + // are not surfaced as top-level tags by ts.getJSDocTags. That's + // intentional for the detector — the parent @typedef IS the + // violation, and the cleanup fix (convert to native interface) + // takes the @property lines with it. Field-level reporting is + // reviewer ergonomics, not detector correctness. + expected: [{ tag: 'typedef', class: 'typedef-style' }], + }, + + // ─── @callback ─────────────────────────────────────────────────── + { + name: '@callback always flagged', + src: ` +/** + * @callback Listener + * @param {string} event + * @returns {void} + */ +export const x = 1; +`, + expected: [{ tag: 'callback', class: 'typedef-style' }], + }, + + // ─── @template ─────────────────────────────────────────────────── + { + name: '@template always flagged in .ts', + src: ` +/** + * @template T + */ +export function identity(value: T): T { return value; } +`, + expected: [{ tag: 'template', class: 'declaration-doc-type' }], + }, + + // ─── @typeParam not flagged ────────────────────────────────────── + // TSDoc-canonical alternative to @template; pure prose form. + { + name: '@typeParam not flagged (TSDoc-canonical)', + src: ` +/** + * @typeParam T - The type of the value being returned. + */ +export function identity(value: T): T { return value; } +`, + expected: [], + }, + + // ─── @deprecated alone ─────────────────────────────────────────── + { + name: '@deprecated not flagged', + src: ` +/** @deprecated Use newThing instead. */ +export function oldThing(): void {} +`, + expected: [], + }, + + // ─── @see / @example / @throws not flagged ────────────────────── + { + name: 'doc-only tags not flagged', + src: ` +/** + * Does the thing. + * + * @see {@link OtherThing} + * @example + * doThing(); + * @throws when X is invalid. + */ +export function doThing(): void {} +`, + expected: [], + }, + + // ─── @this typed ──────────────────────────────────────────────── + // `@this` canonically takes a type expression; the TS JSDoc parser + // treats `@this ` as a typed form, so practical usage is + // always flagged. In `.ts`, the right pattern is the parameter + // `this: Foo` in the function signature. + { + name: 'typed @this flagged', + src: ` +/** @this {Foo} */ +export function a(): void {} +`, + expected: [{ tag: 'this', class: 'declaration-doc-type' }], + }, +]; + +function run() { + let passed = 0; + let failed = 0; + for (const fx of FIXTURES) { + const got = findViolations('fixture.ts', fx.src).map((v) => ({ tag: v.tag, class: v.class })); + const gotKey = JSON.stringify(got); + const expKey = JSON.stringify(fx.expected); + if (gotKey === expKey) { + passed++; + console.log(` PASS ${fx.name}`); + } else { + failed++; + console.log(` FAIL ${fx.name}`); + console.log(` expected: ${expKey}`); + console.log(` got: ${gotKey}`); + } + } + console.log(''); + console.log(`${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +run(); diff --git a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs new file mode 100644 index 0000000000..f634754ca3 --- /dev/null +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs @@ -0,0 +1,379 @@ +#!/usr/bin/env node +/** + * SD-673 / type-hygiene-ts: gate for type-bearing JSDoc in `.ts` + * source under `packages/superdoc/src/` and `packages/super-editor/src/`. + * + * Policy: TypeScript syntax is the only source of truth for shape on + * the public contract surface. Type-bearing JSDoc in `.ts` files is + * documentation-only — TS ignores it — so duplicated type information + * drifts silently. See `type-hygiene.md` for the full rule. + * + * Detection model: + * + * - Positive detection on tag name. Two sets: + * + * ALWAYS_FLAG_TAGS — tag is a violation purely by being present + * in a `.ts` file (the tag is a JSDoc-type-system construct that + * has a native TS equivalent): + * @type, @typedef, @callback, @template, @implements, + * @extends, @augments, @enum + * + * FLAG_WHEN_TYPED_TAGS — tag is a violation only when it carries + * a type expression (the `{Type}` brace). Prose-only forms are + * fine: + * @param, @returns, @return, @this + * + * - Every other JSDoc tag is ignored (allows `@deprecated`, + * `@example`, `@throws`, `@see`, `@typeParam`, etc.). + * + * Snapshot/ratchet pattern mirrors `check-jsdoc.cjs`: + * + * - Existing violations are recorded in + * `jsdoc-hygiene-ts-baseline.json`. New violations on top of the + * baseline fail. + * - Stale baseline entries (file removed, JSDoc cleaned up) also + * fail; rerun with --write to refresh. + * + * Refreshing the baseline: + * + * node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs --write + * + * Scope: see `type-hygiene.md` § Scope. Excludes test files and + * declaration files. + */ + +const fs = require('fs'); +const path = require('path'); +const ts = require('typescript'); + +const scriptDir = __dirname; +const repoRoot = path.resolve(scriptDir, '..', '..', '..'); + +const BASELINE_PATH = path.join(scriptDir, 'jsdoc-hygiene-ts-baseline.json'); +const POLICY_RELATIVE = 'packages/superdoc/scripts/type-hygiene.md'; + +const SCAN_ROOTS = ['packages/superdoc/src', 'packages/super-editor/src']; + +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', '__mocks__', '__fixtures__', 'dev']); +const EXCLUDED_FILE_SUFFIXES = ['.d.ts', '.test.ts', '.spec.ts', '.test.tsx', '.spec.tsx']; + +const ALWAYS_FLAG_TAGS = new Set([ + 'type', + 'typedef', + 'callback', + 'template', + 'implements', + 'extends', + 'augments', + 'enum', +]); + +const FLAG_WHEN_TYPED_TAGS = new Set(['param', 'returns', 'return', 'this']); + +const CLASS_BY_TAG = { + type: 'inline-fake-cast', + typedef: 'typedef-style', + callback: 'typedef-style', + enum: 'typedef-style', + template: 'declaration-doc-type', + implements: 'declaration-doc-type', + extends: 'declaration-doc-type', + augments: 'declaration-doc-type', + param: 'declaration-doc-type', + returns: 'declaration-doc-type', + return: 'declaration-doc-type', + this: 'declaration-doc-type', +}; + +// ─── File discovery ─────────────────────────────────────────────────── + +function listTsFiles(rootRel) { + const out = []; + const absRoot = path.join(repoRoot, rootRel); + if (!fs.existsSync(absRoot)) return out; + + const stack = [absRoot]; + while (stack.length) { + const dir = stack.pop(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (EXCLUDED_DIRS.has(entry.name)) continue; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(abs); + } else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.tsx')) { + if (EXCLUDED_FILE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))) continue; + out.push(path.relative(repoRoot, abs).replace(/\\/g, '/')); + } else if (entry.isFile() && entry.name.endsWith('.tsx')) { + if (EXCLUDED_FILE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))) continue; + out.push(path.relative(repoRoot, abs).replace(/\\/g, '/')); + } + } + } + return out; +} + +// ─── AST walk ───────────────────────────────────────────────────────── + +/** + * Render a member name from the AST node that carries it. Handles + * `Identifier`, `PrivateIdentifier` (returns `#name` so private + * methods don't collapse onto their enclosing class's key), and + * string/numeric literal property names. + * + * Returns null when the name shape is not one we render — caller + * falls through to the next ancestor. + */ +function renderName(nameNode) { + if (!nameNode) return null; + if (ts.isIdentifier(nameNode)) return String(nameNode.escapedText); + if (ts.isPrivateIdentifier(nameNode)) { + // `nameNode.text` is the original source ('#name'). escapedText + // may also include the leading '#' depending on TS version; using + // .text avoids depending on that detail. + return nameNode.text; + } + if (ts.isStringLiteral(nameNode) || ts.isNumericLiteral(nameNode)) return String(nameNode.text); + return null; +} + +/** + * Walk up from a node to find the nearest named declaration ancestor. + * Used to anchor each violation to a stable enclosing-symbol name so + * the baseline key survives line shifts caused by edits elsewhere in + * the file. + * + * Returns '' when no named ancestor is found (e.g. inline + * `/** @type *​/` cast inside an anonymous expression at module scope). + */ +function enclosingSymbolName(node) { + let n = node; + while (n) { + // Direct .name on the node (FunctionDeclaration, MethodDeclaration, + // ClassDeclaration, InterfaceDeclaration, TypeAliasDeclaration, + // PropertyDeclaration / PropertySignature, GetAccessorDeclaration, + // EnumDeclaration, etc.). Includes PrivateIdentifier so `#method` + // doesn't collapse onto its enclosing class's key. + if (n.name) { + const rendered = renderName(n.name); + if (rendered) return rendered; + } + // VariableStatement -> VariableDeclarationList -> VariableDeclaration[]. + // Use the first declared name as the anchor. + if (ts.isVariableStatement(n) && n.declarationList && n.declarationList.declarations.length > 0) { + const d = n.declarationList.declarations[0]; + const rendered = renderName(d.name); + if (rendered) return rendered; + } + if (ts.isVariableDeclaration(n)) { + const rendered = renderName(n.name); + if (rendered) return rendered; + } + n = n.parent; + } + return ''; +} + +/** + * Walk a source file's AST and emit one violation per type-bearing + * JSDoc tag. + * + * Each violation's identity is the stable tuple + * `{file, symbol, tag, occurrenceIndex}`. `line` is captured for + * human-readable display only; it is NOT part of the baseline key + * because line numbers shift under unrelated edits and would produce + * spurious stale-plus-new pairs on the ratchet. + */ +function findViolations(filePath, sourceText) { + const sf = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, /* setParentNodes */ true); + const findings = []; + const seenPositions = new Set(); + const occurrenceCounter = new Map(); + + function visit(node) { + const tags = ts.getJSDocTags(node); + if (tags && tags.length > 0) { + const symbol = enclosingSymbolName(node); + for (const tag of tags) { + const name = tag.tagName && tag.tagName.escapedText ? String(tag.tagName.escapedText) : ''; + const violation = classifyTag(name, tag); + if (!violation) continue; + // Position de-dupe so the same tag surfaced via multiple parent + // walks counts once. + const positionKey = `${tag.pos}:${tag.end}:${name}`; + if (seenPositions.has(positionKey)) continue; + seenPositions.add(positionKey); + // Occurrence index within (symbol, tag) so multiple `@param`s + // on the same method are distinguished without depending on + // line numbers. + const counterKey = `${symbol}::${name}`; + const occurrenceIndex = occurrenceCounter.get(counterKey) || 0; + occurrenceCounter.set(counterKey, occurrenceIndex + 1); + const { line } = sf.getLineAndCharacterOfPosition(tag.pos); + findings.push({ + file: filePath, + line: line + 1, + tag: name, + class: violation, + symbol, + occurrenceIndex, + }); + } + } + ts.forEachChild(node, visit); + } + visit(sf); + + findings.sort( + (a, b) => + a.symbol.localeCompare(b.symbol) || + a.tag.localeCompare(b.tag) || + a.occurrenceIndex - b.occurrenceIndex, + ); + return findings; +} + +function classifyTag(name, tag) { + if (ALWAYS_FLAG_TAGS.has(name)) { + return CLASS_BY_TAG[name]; + } + if (FLAG_WHEN_TYPED_TAGS.has(name)) { + // tag.typeExpression is set when the tag carries a `{Type}` brace. + if (tag.typeExpression) return CLASS_BY_TAG[name]; + } + return null; +} + +// ─── Baseline serialization ─────────────────────────────────────────── + +function loadBaseline() { + if (!fs.existsSync(BASELINE_PATH)) { + return { $comment: '', knownViolations: [] }; + } + const raw = fs.readFileSync(BASELINE_PATH, 'utf8'); + try { + return JSON.parse(raw); + } catch (e) { + throw new Error(`Failed to parse baseline at ${BASELINE_PATH}: ${e.message}`); + } +} + +function violationKey(v) { + // Stable across line shifts and re-orderings of unrelated code. + // `line` is intentionally excluded: editing above a JSDoc block + // would otherwise produce one stale + one new entry for an + // unchanged violation, and the ratchet would be noisy. + return `${v.file}::${v.symbol}::${v.tag}::${v.class}::${v.occurrenceIndex}`; +} + +function writeBaseline(violations) { + const data = { + $comment: + 'Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. ' + + 'Each entry is "file::enclosingSymbol::tagName::class::occurrenceIndex"; ' + + 'line numbers are NOT part of the key, so the baseline survives unrelated ' + + 'edits that shift line numbers. The gate grandfathers these and fails on ' + + 'net-new violations. Refresh after intentional cleanup with --write. ' + + 'Goal is to drain to zero, then flip to "zero allowed" in a separate PR. ' + + 'See packages/superdoc/scripts/type-hygiene.md.', + knownViolations: violations.map(violationKey).sort(), + }; + fs.writeFileSync(BASELINE_PATH, JSON.stringify(data, null, 2) + '\n'); +} + +// ─── Main ───────────────────────────────────────────────────────────── + +function main() { + const writeMode = process.argv.includes('--write'); + + const files = []; + for (const root of SCAN_ROOTS) { + files.push(...listTsFiles(root)); + } + files.sort(); + + const allViolations = []; + for (const rel of files) { + const abs = path.join(repoRoot, rel); + const text = fs.readFileSync(abs, 'utf8'); + const violations = findViolations(rel, text); + allViolations.push(...violations); + } + + const currentKeys = new Set(allViolations.map(violationKey)); + const baseline = loadBaseline(); + const baselineKeys = new Set(baseline.knownViolations); + + const newViolations = allViolations.filter((v) => !baselineKeys.has(violationKey(v))); + const staleEntries = [...baselineKeys].filter((k) => !currentKeys.has(k)).sort(); + + if (writeMode) { + writeBaseline(allViolations); + console.log(`[jsdoc-hygiene-ts] wrote ${BASELINE_PATH} (${allViolations.length} entries).`); + return; + } + + // Report + const total = allViolations.length; + const grandfathered = total - newViolations.length; + console.log(`[jsdoc-hygiene-ts] type-bearing JSDoc scanner`); + console.log('========================================================================'); + console.log(`Scope: ${SCAN_ROOTS.join(', ')} (excludes test files / .d.ts)`); + console.log(`Files scanned: ${files.length}`); + console.log(`Total violations: ${total}`); + console.log(`Grandfathered: ${grandfathered}`); + console.log(`Baseline at: ${path.relative(repoRoot, BASELINE_PATH)}`); + + if (newViolations.length > 0 || staleEntries.length > 0) { + console.log(''); + if (newViolations.length > 0) { + console.log(`FAIL ${newViolations.length} new type-bearing JSDoc tag(s) in .ts source:`); + for (const v of newViolations.slice(0, 30)) { + console.log(` - ${v.file}:${v.line} @${v.tag} on \`${v.symbol}\` [${v.class}]`); + } + if (newViolations.length > 30) { + console.log(` ... and ${newViolations.length - 30} more.`); + } + console.log(''); + console.log( + 'Type-bearing JSDoc is not allowed in .ts source under packages/superdoc/src\n' + + 'or packages/super-editor/src. Use TypeScript for shape (signatures, interfaces,\n' + + '`as Type` casts) and prose-only JSDoc for documentation. See ' + + POLICY_RELATIVE + + ' for the rule and fix patterns.\n', + ); + } + if (staleEntries.length > 0) { + console.log(`FAIL ${staleEntries.length} stale baseline entry/entries (no longer violating):`); + for (const k of staleEntries.slice(0, 30)) { + console.log(` - ${k}`); + } + if (staleEntries.length > 30) { + console.log(` ... and ${staleEntries.length - 30} more.`); + } + console.log(''); + console.log( + 'These entries were cleaned up but the baseline still records them.\n' + + 'Run with --write to refresh the snapshot and lock in the win.\n', + ); + } + process.exit(1); + } + + console.log(''); + console.log(`OK ${total} violation(s) tracked as baseline; no net-new entries.`); +} + +// Export the AST + classification helpers so the test runner can +// exercise them on in-memory fixtures without touching the file +// system. Only invoke main() when executed directly as a CLI. +module.exports = { + findViolations, + classifyTag, + ALWAYS_FLAG_TAGS, + FLAG_WHEN_TYPED_TAGS, + CLASS_BY_TAG, +}; + +if (require.main === module) { + main(); +} diff --git a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json new file mode 100644 index 0000000000..fadeef1522 --- /dev/null +++ b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json @@ -0,0 +1,90 @@ +{ + "$comment": "Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. Each entry is \"file::enclosingSymbol::tagName::class::occurrenceIndex\"; line numbers are NOT part of the key, so the baseline survives unrelated edits that shift line numbers. The gate grandfathers these and fails on net-new violations. Refresh after intentional cleanup with --write. Goal is to drain to zero, then flip to \"zero allowed\" in a separate PR. See packages/superdoc/scripts/type-hygiene.md.", + "knownViolations": [ + "packages/super-editor/src/editors/v1/core/Editor.ts::#withState::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts::EventEmitter::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts::emit::returns::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts::off::returns::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts::on::returns::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts::once::returns::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Extension.ts::Extension::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Extension.ts::Extension::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/Mark.ts::Mark::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Mark.ts::Mark::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/Mark.ts::Mark::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/Mark.ts::MarkConfig::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Mark.ts::MarkConfig::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/Mark.ts::MarkConfig::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/Node.ts::Node::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Node.ts::Node::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/Node.ts::Node::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/Node.ts::NodeConfig::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/Node.ts::NodeConfig::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/Node.ts::NodeConfig::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNode::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNode::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNode::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNodeConfig::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNodeConfig::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts::OxmlNodeConfig::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/defineMark.ts::::typedef::typedef-style::0", + "packages/super-editor/src/editors/v1/core/defineMark.ts::defineMark::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/defineMark.ts::defineMark::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/defineMark.ts::defineMark::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/defineNode.ts::::typedef::typedef-style::0", + "packages/super-editor/src/editors/v1/core/defineNode.ts::defineNode::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/defineNode.ts::defineNode::template::declaration-doc-type::1", + "packages/super-editor/src/editors/v1/core/defineNode.ts::defineNode::template::declaration-doc-type::2", + "packages/super-editor/src/editors/v1/core/helpers/getExtensionConfigField.ts::getExtensionConfigField::template::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts::#focusEditorAfterImageSelection::returns::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts::#updateSelection::returns::declaration-doc-type::0", + "packages/superdoc/src/core/EventEmitter.ts::EventEmitter::template::declaration-doc-type::0", + "packages/superdoc/src/core/EventEmitter.ts::emit::returns::declaration-doc-type::0", + "packages/superdoc/src/core/EventEmitter.ts::off::returns::declaration-doc-type::0", + "packages/superdoc/src/core/EventEmitter.ts::on::returns::declaration-doc-type::0", + "packages/superdoc/src/core/EventEmitter.ts::once::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#abortUpgrade::type::inline-fake-cast::0", + "packages/superdoc/src/core/SuperDoc.ts::#applyDocumentMode::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#applyDocumentMode::param::declaration-doc-type::1", + "packages/superdoc/src/core/SuperDoc.ts::#initCollaboration::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#log::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#patchNaiveUIStyles::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#requireSuperdocStore::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#resolveSourceEditor::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#triggerCollaborationSaves::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::#validateUpgradePrerequisites::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::extends::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::implements::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::addCommentsList::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::addSharedUser::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::broadcastEditorBeforeCreate::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::broadcastEditorCreate::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::event::type::inline-fake-cast::0", + "packages/superdoc/src/core/SuperDoc.ts::export::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::exportEditorsToDOCX::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::getHTML::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::getZoom::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::goToSearchResult::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::goToSearchResult::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::lockSuperdoc::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::lockSuperdoc::param::declaration-doc-type::1", + "packages/superdoc/src/core/SuperDoc.ts::navigateTo::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::openSurface::template::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::pendingCollaborationSaves::type::inline-fake-cast::0", + "packages/superdoc/src/core/SuperDoc.ts::readyEditors::type::inline-fake-cast::0", + "packages/superdoc/src/core/SuperDoc.ts::removeSharedUser::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::requiredNumberOfEditors::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::scrollToComment::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::scrollToComment::param::declaration-doc-type::1", + "packages/superdoc/src/core/SuperDoc.ts::scrollToComment::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::scrollToElement::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::scrollToElement::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::search::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::search::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::setActiveEditor::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::setTrackedChangesPreferences::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::setZoom::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::upgradeToCollaboration::returns::declaration-doc-type::0", + "packages/superdoc/src/core/types/index.ts::InternalConfig::type::inline-fake-cast::0" + ] +} diff --git a/packages/superdoc/scripts/type-hygiene.md b/packages/superdoc/scripts/type-hygiene.md new file mode 100644 index 0000000000..30e4dca7ac --- /dev/null +++ b/packages/superdoc/scripts/type-hygiene.md @@ -0,0 +1,181 @@ +# TypeScript-side JSDoc hygiene + +## Rule + +In `.ts` source under `packages/superdoc/src/` and +`packages/super-editor/src/`, do not use type-bearing JSDoc tags. +TypeScript syntax is the only source of truth for shape on the public +contract surface; JSDoc is reserved for prose documentation. + +## What this rule covers + +The scanner (`check-jsdoc-hygiene-ts.cjs`) flags two patterns in `.ts` +files: + +### 1. Type-bearing tags that always violate + +These tags exist only to declare types in JSDoc form. In `.ts` files +there is always a native TypeScript construct that is more accurate +and self-checking: + +| Tag | Use in TS instead | +|--------------|----------------------------------------------------| +| `@type` | `as Type`, or just let inference work | +| `@typedef` | `type X = ...` or `interface X { ... }` | +| `@callback` | `type X = (...) => ...` | +| `@template` | Native generic syntax: `function foo(...)`. For documentation, use `@typeParam T - description` | +| `@implements`| `class Foo implements Bar` | +| `@extends` | `class Foo extends Bar` / `interface Foo extends Bar` | +| `@augments` | Same as `@extends` | +| `@enum` | `enum X { ... }` | + +### 2. Tags that violate only when carrying a `{Type}` brace + +These tags are legitimate as prose documentation. They are flagged +only when they duplicate a TypeScript-expressible type: + +| Tag | OK | Flagged | +|--------------|-------------------------------|----------------------------------------| +| `@param` | `@param name description` | `@param {string} name description` | +| `@returns` | `@returns description` | `@returns {string} description` | +| `@return` | `@return description` | `@return {string} description` | +| `@this` | `@this description` | `@this {Foo} description` | + +## Why + +Type-bearing JSDoc in `.ts` files is documentation-only — the TS +compiler ignores it. That means: + +- The annotation can drift from the actual TS signature without any + build error. The recent `addCommentsList` fix is one example: + `@param {Element}` while the signature was `HTMLElement`. +- Inline `/** @type {Foo} */ value` looks like a cast but is a no-op. + If a real cast was intended, use `value as Foo`. If the JSDoc is + redundant commentary, delete it. +- Two sources of truth for the same shape diverge over time, and the + one TypeScript doesn't enforce becomes wrong silently. + +JSDoc is still useful in `.ts` for prose documentation: function +descriptions, `@deprecated`, `@example`, `@throws`, `@see`, +`@typeParam` (the TSDoc-canonical alternative to `@template`), etc. +The scanner does not flag these. + +## What still belongs in JSDoc + +- Prose explaining what the function does, behavioral contracts, + side-effect notes, and `@deprecated` / `@example` / `@throws` / + `@see` tags. +- Parameter and return descriptions, as long as they do not carry + `{Type}` braces: `@param name description` is fine. +- `@typeParam T - description` for documenting generic parameters + (TSDoc-canonical, prose-only). + +## How to fix violations + +### `@param {Type} name description` → `@param name description` + +```ts +// Before +/** + * @param {HTMLElement} element The DOM element to mount into. + */ +addCommentsList(element: HTMLElement) { ... } + +// After +/** + * @param element The DOM element to mount into. + */ +addCommentsList(element: HTMLElement) { ... } +``` + +The signature carries the type; the prose stays useful. + +### `@returns {Type} description` → `@returns description` + +Same pattern. Drop the `{Type}` brace, keep the prose. + +### Inline `/** @type {Foo} */ value` + +Triage each occurrence: + +- If a cast was genuinely intended (e.g. the TS inference is wider + than the runtime guarantee), replace with `value as Foo`. +- If the annotation was redundant commentary, delete it. + +In `.ts` files the inline `@type` cast is a no-op — TS ignores it. So +either fix the type system properly with `as`, or remove the misleading +comment. + +### `@typedef`, `@callback`, `@enum` + +Convert to a native TypeScript declaration: + +```ts +// Before +/** + * @typedef {Object} Options + * @property {string} name + * @property {boolean} [verbose] + */ + +// After +interface Options { + name: string; + verbose?: boolean; +} +``` + +Export the type if it is part of the public surface. + +### `@template T` + +In `.ts` files, generics belong in the signature: + +```ts +// Before +/** + * @template T + * @param {T} value + */ +function identity(value) { return value; } + +// After +/** + * @typeParam T - The type of the value being returned. + */ +function identity(value: T): T { return value; } +``` + +If you want to document the type parameter, use TSDoc's `@typeParam`, +not `@template`. + +## Scope + +The scanner runs on `.ts` files under `packages/superdoc/src/` and +`packages/super-editor/src/`. Excludes: + +- `*.d.ts` (declaration files, generated) +- `*.test.ts` / `*.spec.ts` (test files; type-bearing JSDoc is fine + for test fixtures) +- `dev/`, `__mocks__/`, `__fixtures__/` directories + +The rule **does not** apply to `.js` files. Those use JSDoc as their +type system via `// @ts-check`; that is enforced separately by +`check-jsdoc.cjs`. Both gates can coexist: TS files use TS syntax for +types, JS files use JSDoc for types, neither uses both. + +## Refreshing the baseline + +The scanner snapshot lives at +`packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json`. It records +the existing violations the gate grandfathers. New violations on top +of the baseline fail CI. + +To refresh after intentional cleanup: + +```sh +node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs --write +``` + +The goal is to drain the baseline to zero, then flip the gate to +"zero allowed" (separate PR). diff --git a/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs index 5737e226ed..b7943ac07b 100755 --- a/scripts/check-public-contract.mjs +++ b/scripts/check-public-contract.mjs @@ -29,7 +29,25 @@ * land without `// @ts-check` or * when the allowlist carries * empty/stale entries. - * 4. public-method-coverage - obligation-based ratchet over + * 4. jsdoc-hygiene-ts-test - self-test suite for the + * jsdoc-hygiene-ts scanner; 13 + * in-memory fixtures verifying + * detector correctness. Runs + * immediately before the scanner + * stage so AST-shape drift surfaces + * here, not as a silent zero-result + * downstream. + * 5. jsdoc-hygiene-ts - type-bearing JSDoc gate for .ts + * source under packages/superdoc/src + * and packages/super-editor/src. + * Companion to jsdoc-ratchet on the + * .ts side: enforces TS syntax as + * the single source of truth for + * shape. Grandfathers an existing + * baseline; fails on net-new + * type-bearing JSDoc. See + * packages/superdoc/scripts/type-hygiene.md. + * 6. public-method-coverage - obligation-based ratchet over * public SuperDoc methods + * getters. For each member the * AST computes which obligations @@ -42,7 +60,7 @@ * on their own — that's why * `search(text: string)` shipped * under v1 of this gate. - * 5. build - vite build + the postbuild + * 7. build - vite build + the postbuild * validator chain * (check-tsconfig-type-surface, * ensure-types, audit-bundle, @@ -53,29 +71,29 @@ * Skipped when `--skip-build` is * passed (CI calls `pnpm run build` * separately in its own step). - * 6. consumer-typecheck-matrix - packs superdoc + installs the + * 8. consumer-typecheck-matrix - packs superdoc + installs the * tarball into * tests/consumer-typecheck/ * node_modules/, then runs every * consumer scenario. - * 7. deep-type-audit-supported-root - strict gate on the supported- + * 9. deep-type-audit-supported-root - strict gate on the supported- * root public surface; fails on any * `any` leak. Reuses the install - * from stage 6. - * 8. package-shape - publint + attw against the packed + * from stage 8. + * 10. package-shape - publint + attw against the packed * manifest. Reuses the tarball - * from stage 6. - * 9. export-snapshots - super-editor / legacy / root + * from stage 8. + * 11. export-snapshots - super-editor / legacy / root * no-growth export snapshots. * Reuses the install. - * 10. root-classification-closure - no supported-root or legacy-root + * 12. root-classification-closure - no supported-root or legacy-root * export references an internal- * candidate type in its public * declared shape (SD-3212 A1b). * - * Why stage 6 runs before 7-10: stage 6 packs `superdoc.tgz` and - * installs the tarball into the consumer fixture once. Stages 7, 9, - * and 10 reuse the installed fixture; stage 8 reuses the packed tarball + * Why stage 8 runs before 9-12: stage 8 packs `superdoc.tgz` and + * installs the tarball into the consumer fixture once. Stages 9, 11, + * and 12 reuse the installed fixture; stage 10 reuses the packed tarball * directly. Without this ordering each downstream stage would `--pack` * separately and multiply the work. * @@ -135,6 +153,32 @@ const stages = [ 'fails when new public-reachable JSDoc files land without // @ts-check. ' + 'Cheap; runs before the slow build so JSDoc drift fails fast.', }, + { + name: 'jsdoc-hygiene-ts-test', + cwd: REPO_ROOT, + cmd: 'node', + args: ['packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs'], + blurb: + 'Self-test suite for the jsdoc-hygiene-ts scanner. 13 in-memory ' + + 'fixtures verifying detector correctness across negative control, ' + + 'mixed-tag blocks, prose-vs-typed forms, and each tag class. ' + + 'Fast-fails before the scanner runs so AST-shape drift or detector ' + + 'logic bugs surface here rather than as silent zero-result ' + + 'false-passes downstream.', + }, + { + name: 'jsdoc-hygiene-ts', + cwd: REPO_ROOT, + cmd: 'node', + args: ['packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs'], + blurb: + 'Type-bearing JSDoc gate for .ts source under packages/superdoc/src and ' + + 'packages/super-editor/src. Grandfathers an existing baseline of violations ' + + 'and fails on net-new type-bearing JSDoc tags (@param {T}, @returns {T}, ' + + '@type, @typedef, @template, etc.). See packages/superdoc/scripts/' + + 'type-hygiene.md for the rule. Cheap; complements jsdoc-ratchet (which ' + + 'covers .js files) by enforcing TS-as-single-source on the .ts side.', + }, { name: 'public-method-coverage', cwd: REPO_ROOT,