From bc7592304eca06e56e4e87f8fe13fe4ea73affc7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 13:26:16 -0300 Subject: [PATCH 1/5] feat(scripts): add type-bearing JSDoc hygiene gate for .ts source (SD-673) Adds a new check:public:superdoc stage (jsdoc-hygiene-ts) that enforces TypeScript-as-single-source on the .ts public contract surface. Companion to the existing jsdoc-ratchet stage, which covers .js files; together they enforce that each language's source files use the right type system (TS syntax in .ts, JSDoc + @ts-check in .js) without one polluting the other. Scanner (check-jsdoc-hygiene-ts.cjs): - Walks .ts source under packages/superdoc/src and packages/super-editor/src (excludes test/.d.ts files). - Positive detection on tag name. ALWAYS_FLAG_TAGS: @type, @typedef, @callback, @template, @implements, @extends, @augments, @enum. FLAG_WHEN_TYPED_TAGS: @param, @returns, @return, @this (only flagged when tag.typeExpression is set, so prose-only @param name description passes). Every other tag is ignored, including @deprecated, @example, @throws, @see, @typeParam. - AST-based via ts.getJSDocTags + tag.typeExpression presence check; no regex on raw comment text. De-dupes per (file, pos, end, tag) so tags surfaced through multiple parent nodes count once. - Classifies each violation as declaration-doc-type, inline-fake-cast, or typedef-style so the future cleanup PR can group fixes by type. Baseline (jsdoc-hygiene-ts-baseline.json) records the 85 existing violations grandfathered at PR time. The ratchet fails on net-new entries or stale baseline entries (file cleaned up, snapshot still records it). Refresh with --write after intentional cleanup. Goal is to drain to zero, then flip to 'zero allowed' in a separate PR. Scanner tests (check-jsdoc-hygiene-ts.test.cjs) protect against two failure modes: silent false-positives (flagging legitimate doc tags) and silent false-negatives (scanner produces nothing and looks like it's working). 13 in-memory fixtures including a negative control (prose-only JSDoc + TS types -> zero hits) and a mixed-tag block (@deprecated + typed @param + prose @returns -> exactly one hit). Policy doc at packages/superdoc/scripts/type-hygiene.md explains the rule, the fix patterns for each violation class, and the scope. The gate's failure message links to this doc so contributors hit a clear path forward rather than a cryptic CI error. Verified: scanner tests -> 13 passed, 0 failed; baseline scan -> 85 grandfathered, no net-new; canary fixture (added typed @param) -> correctly fails with the new violation surfaced; pnpm check:public:superdoc --skip-build -> PASS (10 ran, 1 skipped, 139.4s). Sibling pnpm check:jsdoc continues to enforce the .js side unchanged. --- .../scripts/check-jsdoc-hygiene-ts.cjs | 293 ++++++++++++++++++ .../scripts/check-jsdoc-hygiene-ts.test.cjs | 225 ++++++++++++++ .../scripts/jsdoc-hygiene-ts-baseline.json | 90 ++++++ packages/superdoc/scripts/type-hygiene.md | 181 +++++++++++ scripts/check-public-contract.mjs | 13 + 5 files changed, 802 insertions(+) create mode 100644 packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs create mode 100644 packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs create mode 100644 packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json create mode 100644 packages/superdoc/scripts/type-hygiene.md 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..6a4876a85d --- /dev/null +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs @@ -0,0 +1,293 @@ +#!/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 ───────────────────────────────────────────────────────── + +/** + * Walk a source file's AST and emit one violation per type-bearing + * JSDoc tag. De-duped at the end by {file, pos, end}. + */ +function findViolations(filePath, sourceText) { + const sf = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, /* setParentNodes */ true); + const findings = []; + const seen = new Set(); + + function visit(node) { + // ts.getJSDocTags returns the JSDoc tags attached to this node. + // We don't need to traverse the JSDoc comment itself — getting the + // tags from each declaration is enough. + const tags = ts.getJSDocTags(node); + if (tags && tags.length > 0) { + for (const tag of tags) { + const name = tag.tagName && tag.tagName.escapedText ? String(tag.tagName.escapedText) : ''; + const violation = classifyTag(name, tag); + if (!violation) continue; + const key = `${tag.pos}:${tag.end}:${name}`; + if (seen.has(key)) continue; + seen.add(key); + const { line } = sf.getLineAndCharacterOfPosition(tag.pos); + findings.push({ + file: filePath, + line: line + 1, + tag: name, + class: violation, + }); + } + } + ts.forEachChild(node, visit); + } + visit(sf); + + findings.sort((a, b) => (a.line - b.line) || a.tag.localeCompare(b.tag)); + 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) { + return `${v.file}:${v.line}:${v.tag}`; +} + +function writeBaseline(violations) { + const data = { + $comment: + 'Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. ' + + 'Each entry is "file:line:tag"; 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} [${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 on the public contract surface.\n' + + 'Use TypeScript for shape (signatures, interfaces, `as Type` casts) and prose-only\n' + + '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/check-jsdoc-hygiene-ts.test.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs new file mode 100644 index 0000000000..7855617fb2 --- /dev/null +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs @@ -0,0 +1,225 @@ +#!/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. + * + * Run with: node packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.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; +`, + // Each tag is reported separately. @typedef + @property both fire. + 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/jsdoc-hygiene-ts-baseline.json b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json new file mode 100644 index 0000000000..0d2cfdffa3 --- /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:line:tag\"; 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:699:template", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts:26:template", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts:35:returns", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts:47:returns", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts:81:returns", + "packages/super-editor/src/editors/v1/core/EventEmitter.ts:97:returns", + "packages/super-editor/src/editors/v1/core/Extension.ts:27:template", + "packages/super-editor/src/editors/v1/core/Extension.ts:28:template", + "packages/super-editor/src/editors/v1/core/Mark.ts:10:template", + "packages/super-editor/src/editors/v1/core/Mark.ts:11:template", + "packages/super-editor/src/editors/v1/core/Mark.ts:42:template", + "packages/super-editor/src/editors/v1/core/Mark.ts:43:template", + "packages/super-editor/src/editors/v1/core/Mark.ts:44:template", + "packages/super-editor/src/editors/v1/core/Mark.ts:9:template", + "packages/super-editor/src/editors/v1/core/Node.ts:192:template", + "packages/super-editor/src/editors/v1/core/Node.ts:193:template", + "packages/super-editor/src/editors/v1/core/Node.ts:194:template", + "packages/super-editor/src/editors/v1/core/Node.ts:72:template", + "packages/super-editor/src/editors/v1/core/Node.ts:73:template", + "packages/super-editor/src/editors/v1/core/Node.ts:74:template", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts:24:template", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts:25:template", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts:26:template", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts:6:template", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts:7:template", + "packages/super-editor/src/editors/v1/core/OxmlNode.ts:8:template", + "packages/super-editor/src/editors/v1/core/defineMark.ts:36:typedef", + "packages/super-editor/src/editors/v1/core/defineMark.ts:53:template", + "packages/super-editor/src/editors/v1/core/defineMark.ts:54:template", + "packages/super-editor/src/editors/v1/core/defineMark.ts:55:template", + "packages/super-editor/src/editors/v1/core/defineNode.ts:35:typedef", + "packages/super-editor/src/editors/v1/core/defineNode.ts:52:template", + "packages/super-editor/src/editors/v1/core/defineNode.ts:53:template", + "packages/super-editor/src/editors/v1/core/defineNode.ts:54:template", + "packages/super-editor/src/editors/v1/core/helpers/getExtensionConfigField.ts:34:template", + "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts:4940:returns", + "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts:6918:returns", + "packages/superdoc/src/core/EventEmitter.ts:25:template", + "packages/superdoc/src/core/EventEmitter.ts:34:returns", + "packages/superdoc/src/core/EventEmitter.ts:49:returns", + "packages/superdoc/src/core/EventEmitter.ts:64:returns", + "packages/superdoc/src/core/EventEmitter.ts:80:returns", + "packages/superdoc/src/core/SuperDoc.ts:1033:returns", + "packages/superdoc/src/core/SuperDoc.ts:1114:param", + "packages/superdoc/src/core/SuperDoc.ts:1330:param", + "packages/superdoc/src/core/SuperDoc.ts:1362:returns", + "packages/superdoc/src/core/SuperDoc.ts:1386:param", + "packages/superdoc/src/core/SuperDoc.ts:1398:param", + "packages/superdoc/src/core/SuperDoc.ts:1458:param", + "packages/superdoc/src/core/SuperDoc.ts:1466:param", + "packages/superdoc/src/core/SuperDoc.ts:1488:param", + "packages/superdoc/src/core/SuperDoc.ts:1495:param", + "packages/superdoc/src/core/SuperDoc.ts:1626:param", + "packages/superdoc/src/core/SuperDoc.ts:1649:param", + "packages/superdoc/src/core/SuperDoc.ts:1650:param", + "packages/superdoc/src/core/SuperDoc.ts:1651:returns", + "packages/superdoc/src/core/SuperDoc.ts:1678:returns", + "packages/superdoc/src/core/SuperDoc.ts:1695:param", + "packages/superdoc/src/core/SuperDoc.ts:1696:returns", + "packages/superdoc/src/core/SuperDoc.ts:175:extends", + "packages/superdoc/src/core/SuperDoc.ts:176:implements", + "packages/superdoc/src/core/SuperDoc.ts:1808:param", + "packages/superdoc/src/core/SuperDoc.ts:1809:param", + "packages/superdoc/src/core/SuperDoc.ts:1827:param", + "packages/superdoc/src/core/SuperDoc.ts:185:type", + "packages/superdoc/src/core/SuperDoc.ts:1947:param", + "packages/superdoc/src/core/SuperDoc.ts:1948:returns", + "packages/superdoc/src/core/SuperDoc.ts:1961:param", + "packages/superdoc/src/core/SuperDoc.ts:1962:returns", + "packages/superdoc/src/core/SuperDoc.ts:1970:returns", + "packages/superdoc/src/core/SuperDoc.ts:1982:param", + "packages/superdoc/src/core/SuperDoc.ts:2022:returns", + "packages/superdoc/src/core/SuperDoc.ts:2039:param", + "packages/superdoc/src/core/SuperDoc.ts:2040:param", + "packages/superdoc/src/core/SuperDoc.ts:2052:param", + "packages/superdoc/src/core/SuperDoc.ts:2102:param", + "packages/superdoc/src/core/SuperDoc.ts:2192:returns", + "packages/superdoc/src/core/SuperDoc.ts:2205:type", + "packages/superdoc/src/core/SuperDoc.ts:2276:template", + "packages/superdoc/src/core/SuperDoc.ts:320:type", + "packages/superdoc/src/core/SuperDoc.ts:323:type", + "packages/superdoc/src/core/SuperDoc.ts:633:returns", + "packages/superdoc/src/core/SuperDoc.ts:700:param", + "packages/superdoc/src/core/SuperDoc.ts:860:returns", + "packages/superdoc/src/core/types/index.ts:1763:type" + ] +} 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..90da6ee1e6 100755 --- a/scripts/check-public-contract.mjs +++ b/scripts/check-public-contract.mjs @@ -135,6 +135,19 @@ 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', + 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, From 1ed4e1db8452c26e250611238999abe0324489b1 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 13:53:15 -0300 Subject: [PATCH 2/5] fix(scripts): make jsdoc-hygiene-ts baseline key line-independent + update wrapper docs Three follow-ups to the initial scanner commit, all addressing review feedback: Baseline key was file:line:tag, which churned under unrelated edits. Inserting 3 blank lines at the top of SuperDoc.ts (verified empirically) would have produced one stale + one new baseline entry per grandfathered violation in that file - ~40 false-positive ratchet failures from a no-op edit. New key shape: file::enclosingSymbol::tagName::class::occurrenceIndex Line stays in the violation record for human-readable display in failure messages, but is NOT part of the identity tuple. Verified that inserting 3 blank lines at the top of SuperDoc.ts now produces zero stale/new churn (85 still grandfathered). enclosingSymbol is the nearest named-declaration ancestor (function, method, class, interface, type alias, variable). occurrenceIndex disambiguates multiple same-name tags on the same symbol (e.g. two @param tags on the same method). Wrapper docs updated to reflect the 11-stage check:public:superdoc: - AGENTS.md (CLAUDE.md symlinks to it): stage list + count. - scripts/check-public-contract.mjs header: inserted stage 4 (jsdoc-hygiene-ts), renumbered 5-11. @typedef test fixture comment corrected: only @typedef fires (ts.getJSDocTags does not surface @property as a top-level tag); the parent @typedef is the violation and the cleanup fix takes its @property lines with it. Verified: 13/13 scanner tests pass; check:public:superdoc --skip-build PASS (10 ran with --skip-build, 1 skipped, 126.6s); line-shift canary on SuperDoc.ts produces no baseline churn. --- AGENTS.md | 2 +- .../scripts/check-jsdoc-hygiene-ts.cjs | 88 +++++++-- .../scripts/check-jsdoc-hygiene-ts.test.cjs | 7 +- .../scripts/jsdoc-hygiene-ts-baseline.json | 172 +++++++++--------- scripts/check-public-contract.mjs | 34 ++-- 5 files changed, 189 insertions(+), 114 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8db586c013..50ead1ba8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE - `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:superdoc` - SuperDoc public package surface only. Wraps eleven stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `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/check-jsdoc-hygiene-ts.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs index 6a4876a85d..4d478daf8c 100644 --- a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs @@ -114,34 +114,82 @@ function listTsFiles(rootRel) { // ─── AST walk ───────────────────────────────────────────────────────── +/** + * 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.). + if (n.name && ts.isIdentifier(n.name)) { + return String(n.name.escapedText); + } + // 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]; + if (d.name && ts.isIdentifier(d.name)) return String(d.name.escapedText); + } + if (ts.isVariableDeclaration(n) && n.name && ts.isIdentifier(n.name)) { + return String(n.name.escapedText); + } + n = n.parent; + } + return ''; +} + /** * Walk a source file's AST and emit one violation per type-bearing - * JSDoc tag. De-duped at the end by {file, pos, end}. + * 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 seen = new Set(); + const seenPositions = new Set(); + const occurrenceCounter = new Map(); function visit(node) { - // ts.getJSDocTags returns the JSDoc tags attached to this node. - // We don't need to traverse the JSDoc comment itself — getting the - // tags from each declaration is enough. 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; - const key = `${tag.pos}:${tag.end}:${name}`; - if (seen.has(key)) continue; - seen.add(key); + // 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, }); } } @@ -149,7 +197,12 @@ function findViolations(filePath, sourceText) { } visit(sf); - findings.sort((a, b) => (a.line - b.line) || a.tag.localeCompare(b.tag)); + findings.sort( + (a, b) => + a.symbol.localeCompare(b.symbol) || + a.tag.localeCompare(b.tag) || + a.occurrenceIndex - b.occurrenceIndex, + ); return findings; } @@ -179,16 +232,23 @@ function loadBaseline() { } function violationKey(v) { - return `${v.file}:${v.line}:${v.tag}`; + // 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:line:tag"; 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.', + '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'); @@ -242,7 +302,7 @@ function main() { 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} [${v.class}]`); + console.log(` - ${v.file}:${v.line} @${v.tag} on \`${v.symbol}\` [${v.class}]`); } if (newViolations.length > 30) { console.log(` ... and ${newViolations.length - 30} more.`); diff --git a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs index 7855617fb2..d99118fdaa 100644 --- a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs @@ -115,7 +115,12 @@ export function pick(): unknown { */ export const x = 1; `, - // Each tag is reported separately. @typedef + @property both fire. + // 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' }], }, diff --git a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json index 0d2cfdffa3..c95c481356 100644 --- a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json +++ b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json @@ -1,90 +1,90 @@ { - "$comment": "Auto-managed by packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs. Each entry is \"file:line:tag\"; 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.", + "$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:699:template", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts:26:template", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts:35:returns", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts:47:returns", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts:81:returns", - "packages/super-editor/src/editors/v1/core/EventEmitter.ts:97:returns", - "packages/super-editor/src/editors/v1/core/Extension.ts:27:template", - "packages/super-editor/src/editors/v1/core/Extension.ts:28:template", - "packages/super-editor/src/editors/v1/core/Mark.ts:10:template", - "packages/super-editor/src/editors/v1/core/Mark.ts:11:template", - "packages/super-editor/src/editors/v1/core/Mark.ts:42:template", - "packages/super-editor/src/editors/v1/core/Mark.ts:43:template", - "packages/super-editor/src/editors/v1/core/Mark.ts:44:template", - "packages/super-editor/src/editors/v1/core/Mark.ts:9:template", - "packages/super-editor/src/editors/v1/core/Node.ts:192:template", - "packages/super-editor/src/editors/v1/core/Node.ts:193:template", - "packages/super-editor/src/editors/v1/core/Node.ts:194:template", - "packages/super-editor/src/editors/v1/core/Node.ts:72:template", - "packages/super-editor/src/editors/v1/core/Node.ts:73:template", - "packages/super-editor/src/editors/v1/core/Node.ts:74:template", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts:24:template", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts:25:template", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts:26:template", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts:6:template", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts:7:template", - "packages/super-editor/src/editors/v1/core/OxmlNode.ts:8:template", - "packages/super-editor/src/editors/v1/core/defineMark.ts:36:typedef", - "packages/super-editor/src/editors/v1/core/defineMark.ts:53:template", - "packages/super-editor/src/editors/v1/core/defineMark.ts:54:template", - "packages/super-editor/src/editors/v1/core/defineMark.ts:55:template", - "packages/super-editor/src/editors/v1/core/defineNode.ts:35:typedef", - "packages/super-editor/src/editors/v1/core/defineNode.ts:52:template", - "packages/super-editor/src/editors/v1/core/defineNode.ts:53:template", - "packages/super-editor/src/editors/v1/core/defineNode.ts:54:template", - "packages/super-editor/src/editors/v1/core/helpers/getExtensionConfigField.ts:34:template", - "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts:4940:returns", - "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts:6918:returns", - "packages/superdoc/src/core/EventEmitter.ts:25:template", - "packages/superdoc/src/core/EventEmitter.ts:34:returns", - "packages/superdoc/src/core/EventEmitter.ts:49:returns", - "packages/superdoc/src/core/EventEmitter.ts:64:returns", - "packages/superdoc/src/core/EventEmitter.ts:80:returns", - "packages/superdoc/src/core/SuperDoc.ts:1033:returns", - "packages/superdoc/src/core/SuperDoc.ts:1114:param", - "packages/superdoc/src/core/SuperDoc.ts:1330:param", - "packages/superdoc/src/core/SuperDoc.ts:1362:returns", - "packages/superdoc/src/core/SuperDoc.ts:1386:param", - "packages/superdoc/src/core/SuperDoc.ts:1398:param", - "packages/superdoc/src/core/SuperDoc.ts:1458:param", - "packages/superdoc/src/core/SuperDoc.ts:1466:param", - "packages/superdoc/src/core/SuperDoc.ts:1488:param", - "packages/superdoc/src/core/SuperDoc.ts:1495:param", - "packages/superdoc/src/core/SuperDoc.ts:1626:param", - "packages/superdoc/src/core/SuperDoc.ts:1649:param", - "packages/superdoc/src/core/SuperDoc.ts:1650:param", - "packages/superdoc/src/core/SuperDoc.ts:1651:returns", - "packages/superdoc/src/core/SuperDoc.ts:1678:returns", - "packages/superdoc/src/core/SuperDoc.ts:1695:param", - "packages/superdoc/src/core/SuperDoc.ts:1696:returns", - "packages/superdoc/src/core/SuperDoc.ts:175:extends", - "packages/superdoc/src/core/SuperDoc.ts:176:implements", - "packages/superdoc/src/core/SuperDoc.ts:1808:param", - "packages/superdoc/src/core/SuperDoc.ts:1809:param", - "packages/superdoc/src/core/SuperDoc.ts:1827:param", - "packages/superdoc/src/core/SuperDoc.ts:185:type", - "packages/superdoc/src/core/SuperDoc.ts:1947:param", - "packages/superdoc/src/core/SuperDoc.ts:1948:returns", - "packages/superdoc/src/core/SuperDoc.ts:1961:param", - "packages/superdoc/src/core/SuperDoc.ts:1962:returns", - "packages/superdoc/src/core/SuperDoc.ts:1970:returns", - "packages/superdoc/src/core/SuperDoc.ts:1982:param", - "packages/superdoc/src/core/SuperDoc.ts:2022:returns", - "packages/superdoc/src/core/SuperDoc.ts:2039:param", - "packages/superdoc/src/core/SuperDoc.ts:2040:param", - "packages/superdoc/src/core/SuperDoc.ts:2052:param", - "packages/superdoc/src/core/SuperDoc.ts:2102:param", - "packages/superdoc/src/core/SuperDoc.ts:2192:returns", - "packages/superdoc/src/core/SuperDoc.ts:2205:type", - "packages/superdoc/src/core/SuperDoc.ts:2276:template", - "packages/superdoc/src/core/SuperDoc.ts:320:type", - "packages/superdoc/src/core/SuperDoc.ts:323:type", - "packages/superdoc/src/core/SuperDoc.ts:633:returns", - "packages/superdoc/src/core/SuperDoc.ts:700:param", - "packages/superdoc/src/core/SuperDoc.ts:860:returns", - "packages/superdoc/src/core/types/index.ts:1763:type" + "packages/super-editor/src/editors/v1/core/Editor.ts::Editor::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::PresentationEditor::returns::declaration-doc-type::0", + "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts::PresentationEditor::returns::declaration-doc-type::1", + "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::SuperDoc::extends::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::implements::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::1", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::2", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::3", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::4", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::5", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::returns::declaration-doc-type::0", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::returns::declaration-doc-type::1", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::returns::declaration-doc-type::2", + "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::type::inline-fake-cast::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/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs index 90da6ee1e6..c5c4492ed9 100755 --- a/scripts/check-public-contract.mjs +++ b/scripts/check-public-contract.mjs @@ -29,7 +29,17 @@ * land without `// @ts-check` or * when the allowlist carries * empty/stale entries. - * 4. public-method-coverage - obligation-based ratchet over + * 4. 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. + * 5. public-method-coverage - obligation-based ratchet over * public SuperDoc methods + * getters. For each member the * AST computes which obligations @@ -42,7 +52,7 @@ * on their own — that's why * `search(text: string)` shipped * under v1 of this gate. - * 5. build - vite build + the postbuild + * 6. build - vite build + the postbuild * validator chain * (check-tsconfig-type-surface, * ensure-types, audit-bundle, @@ -53,29 +63,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 + * 7. 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- + * 8. 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 7. + * 9. package-shape - publint + attw against the packed * manifest. Reuses the tarball - * from stage 6. - * 9. export-snapshots - super-editor / legacy / root + * from stage 7. + * 10. export-snapshots - super-editor / legacy / root * no-growth export snapshots. * Reuses the install. - * 10. root-classification-closure - no supported-root or legacy-root + * 11. 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 7 runs before 8-11: stage 7 packs `superdoc.tgz` and + * installs the tarball into the consumer fixture once. Stages 8, 10, + * and 11 reuse the installed fixture; stage 9 reuses the packed tarball * directly. Without this ordering each downstream stage would `--pack` * separately and multiply the work. * From 5bc5a80b0c4a3bce217abcfb035ebca7360da502 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 14:01:51 -0300 Subject: [PATCH 3/5] fix(scripts): jsdoc-hygiene-ts handles private-identifier symbols + README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups addressing review feedback on the scanner PR: enclosingSymbolName() only recognized Identifier names, so private methods like `#privateMethod` collapsed onto their enclosing class's key. Verified empirically: the previous baseline had 12 SuperDoc:: entries (6 distinct `SuperDoc::param` with occurrenceIndex 0-5), but the SuperDoc class itself only carries @extends and @implements JSDoc. The other 10 were private methods (#applyDocumentMode, #abortUpgrade, etc.) all keyed as SuperDoc, which would have churned unrelated indexes when any one of them was edited. Fix: - renderName() helper recognizes ts.isPrivateIdentifier (returns #name via nameNode.text so the leading # is preserved without depending on escapedText escape rules across TS versions) and string/numeric literal property names alongside identifiers. - enclosingSymbolName() uses renderName() for direct .name, variable declarations, and variable statements. Regenerated baseline. SuperDoc::SuperDoc:: entries dropped from 12 to 2 (the legit class-level @extends and @implements); 13 private-method entries now anchor to their actual #name. Total count unchanged at 85 — same violations, more honest keys. packages/superdoc/scripts/README.md updated with a row for check-jsdoc-hygiene-ts.cjs alongside check-jsdoc.cjs, and the wrapper-stages paragraph now lists jsdoc-hygiene-ts alongside the other policy gates. Scanner failure-message scope wording aligned with reality: was "public contract surface," now "packages/superdoc/src or packages/super-editor/src," matching the actual scan scope. Verified: 13/13 scanner tests pass; check:public:superdoc --skip-build PASS (10 ran, 1 skipped, 138.5s); single-# rendering on private identifiers confirmed in baseline output. --- packages/superdoc/scripts/README.md | 5 ++- .../scripts/check-jsdoc-hygiene-ts.cjs | 44 +++++++++++++++---- .../scripts/jsdoc-hygiene-ts-baseline.json | 26 +++++------ 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/packages/superdoc/scripts/README.md b/packages/superdoc/scripts/README.md index 8ca6c952a2..dc81bd79fe 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 4 (`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.test.cjs` (13 in-memory fixtures). 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: @@ -148,8 +149,8 @@ what an actual consumer would see — not the workspace source. 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`, +`contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts`, +`public-method-coverage`) and `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 diff --git a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs index 4d478daf8c..f634754ca3 100644 --- a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs @@ -114,6 +114,28 @@ function listTsFiles(rootRel) { // ─── 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 @@ -129,18 +151,22 @@ function enclosingSymbolName(node) { // Direct .name on the node (FunctionDeclaration, MethodDeclaration, // ClassDeclaration, InterfaceDeclaration, TypeAliasDeclaration, // PropertyDeclaration / PropertySignature, GetAccessorDeclaration, - // EnumDeclaration, etc.). - if (n.name && ts.isIdentifier(n.name)) { - return String(n.name.escapedText); + // 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]; - if (d.name && ts.isIdentifier(d.name)) return String(d.name.escapedText); + const rendered = renderName(d.name); + if (rendered) return rendered; } - if (ts.isVariableDeclaration(n) && n.name && ts.isIdentifier(n.name)) { - return String(n.name.escapedText); + if (ts.isVariableDeclaration(n)) { + const rendered = renderName(n.name); + if (rendered) return rendered; } n = n.parent; } @@ -309,9 +335,9 @@ function main() { } console.log(''); console.log( - 'Type-bearing JSDoc is not allowed in .ts source on the public contract surface.\n' + - 'Use TypeScript for shape (signatures, interfaces, `as Type` casts) and prose-only\n' + - 'JSDoc for documentation. See ' + + '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', ); diff --git a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json index c95c481356..fadeef1522 100644 --- a/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json +++ b/packages/superdoc/scripts/jsdoc-hygiene-ts-baseline.json @@ -1,7 +1,7 @@ { "$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::Editor::template::declaration-doc-type::0", + "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", @@ -36,25 +36,25 @@ "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::PresentationEditor::returns::declaration-doc-type::0", - "packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts::PresentationEditor::returns::declaration-doc-type::1", + "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::SuperDoc::param::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::1", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::2", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::3", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::4", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::param::declaration-doc-type::5", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::returns::declaration-doc-type::0", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::returns::declaration-doc-type::1", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::returns::declaration-doc-type::2", - "packages/superdoc/src/core/SuperDoc.ts::SuperDoc::type::inline-fake-cast::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", From 89e75fbde564712fd31690d750f37a2d897c58fc Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 14:20:07 -0300 Subject: [PATCH 4/5] docs(scripts): clarify wrapper-stages prose + add ts-jsdoc to check:public summary Two doc tweaks following review: README.md wrapper-stages paragraph was correct in its count but the prose ('Of these, six run as wrapper stages ... after [parenthetical including public-method-coverage] and build: [list of five post-build stages]') could be parsed as a mismatch. Rewrote to break public- method-coverage out as 'runs alongside the cheap policy gates' so the 'five post-build' list reads cleanly as a subset rather than as the total. AGENTS.md check:public summary bullet listed every other gate but omitted the new ts-jsdoc hygiene one; added it alongside 'jsdoc ratchet'. The detailed check:public:superdoc bullet was already correct. Verified: file diffs only, no script changes; baseline + scanner tests unchanged. --- AGENTS.md | 2 +- packages/superdoc/scripts/README.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 50ead1ba8c..ddc0eb8c10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ 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` - **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 eleven stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `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. diff --git a/packages/superdoc/scripts/README.md b/packages/superdoc/scripts/README.md index dc81bd79fe..c3783b1d0f 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -147,12 +147,12 @@ 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`, `jsdoc-hygiene-ts`, -`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`) 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 From 254da78b52470b980ad8da2a45e4cd3cc833ebd0 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 15:43:15 -0300 Subject: [PATCH 5/5] fix(scripts): rename jsdoc-hygiene-ts self-tests + wire into CI (fixes vitest discovery on #3511) CI failure on this PR: the unit-tests (other-packages) job runs vitest across all non-superdoc packages, and the @superdoc package's vitest config picked up packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs via the *.test.* glob. The file is a standalone Node runner (not a vitest suite), so vitest fails with: Error: No test suite found in file packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs Test Files 1 failed | 284 passed (285) Local check:public:superdoc didn't catch this because it doesn't run the broader vitest sweep. Two-part fix: - Rename check-jsdoc-hygiene-ts.test.cjs -> check-jsdoc-hygiene-ts-tests.cjs. The new name doesn't match vitest's *.test.* glob. - Wire the self-tests in as wrapper stage 4 (jsdoc-hygiene-ts-test), running immediately before jsdoc-hygiene-ts. Self-tests had been manual-only; AST drift would have surfaced as a silent zero-result downstream. The stage runs the standalone Node script and asserts 13 in-memory fixtures pass. File header updated to reflect the rename and the stage wiring. AGENTS.md / CLAUDE.md (symlinked) and scripts/check-public-contract.mjs header now show 12 stages (was 11) with stage 4 = jsdoc-hygiene-ts-test. packages/superdoc/scripts/README.md row updated for the rename plus the wrapper-stages paragraph now includes the new stage. Verified: - pnpm check:types -> PASS - node check-jsdoc-hygiene-ts-tests.cjs -> 13/13 pass - pnpm check:public:superdoc --skip-build -> PASS (11 ran, 1 skipped, 131.1s; was 10 before this commit added the test stage) - pnpm --filter superdoc test --run -> PASS (1054/1054, 77 files; was 1 file failing before the rename) --- AGENTS.md | 2 +- packages/superdoc/scripts/README.md | 5 +- ...t.cjs => check-jsdoc-hygiene-ts-tests.cjs} | 11 ++++- scripts/check-public-contract.mjs | 47 ++++++++++++++----- 4 files changed, 48 insertions(+), 17 deletions(-) rename packages/superdoc/scripts/{check-jsdoc-hygiene-ts.test.cjs => check-jsdoc-hygiene-ts-tests.cjs} (94%) diff --git a/AGENTS.md b/AGENTS.md index ddc0eb8c10..bf219229e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE - `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 + 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 eleven stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `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: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 c3783b1d0f..77026c52e6 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -104,7 +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 4 (`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.test.cjs` (13 in-memory fixtures). 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. | +| `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: @@ -150,7 +150,8 @@ what an actual consumer would see — not the workspace source. 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`) before `build`. The other five run after `build`: +`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 diff --git a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs b/packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs similarity index 94% rename from packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs rename to packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs index d99118fdaa..70d2bbcb84 100644 --- a/packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs +++ b/packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs @@ -14,7 +14,16 @@ * Each fixture is an in-memory TypeScript source snippet plus the * expected list of (tag, class) pairs the scanner should emit. * - * Run with: node packages/superdoc/scripts/check-jsdoc-hygiene-ts.test.cjs + * 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'); diff --git a/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs index c5c4492ed9..b7943ac07b 100755 --- a/scripts/check-public-contract.mjs +++ b/scripts/check-public-contract.mjs @@ -29,7 +29,15 @@ * land without `// @ts-check` or * when the allowlist carries * empty/stale entries. - * 4. jsdoc-hygiene-ts - type-bearing JSDoc gate for .ts + * 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 @@ -39,7 +47,7 @@ * baseline; fails on net-new * type-bearing JSDoc. See * packages/superdoc/scripts/type-hygiene.md. - * 5. public-method-coverage - obligation-based ratchet over + * 6. public-method-coverage - obligation-based ratchet over * public SuperDoc methods + * getters. For each member the * AST computes which obligations @@ -52,7 +60,7 @@ * on their own — that's why * `search(text: string)` shipped * under v1 of this gate. - * 6. build - vite build + the postbuild + * 7. build - vite build + the postbuild * validator chain * (check-tsconfig-type-surface, * ensure-types, audit-bundle, @@ -63,29 +71,29 @@ * Skipped when `--skip-build` is * passed (CI calls `pnpm run build` * separately in its own step). - * 7. 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. - * 8. 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 7. - * 9. package-shape - publint + attw against the packed + * from stage 8. + * 10. package-shape - publint + attw against the packed * manifest. Reuses the tarball - * from stage 7. - * 10. export-snapshots - super-editor / legacy / root + * from stage 8. + * 11. export-snapshots - super-editor / legacy / root * no-growth export snapshots. * Reuses the install. - * 11. 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 7 runs before 8-11: stage 7 packs `superdoc.tgz` and - * installs the tarball into the consumer fixture once. Stages 8, 10, - * and 11 reuse the installed fixture; stage 9 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. * @@ -145,6 +153,19 @@ 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,