Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE
- `pnpm test` - unit tests
- `pnpm dev` - dev server from `examples/`
- `pnpm check:types` - raw TS compile across all referenced projects (`tsc -b tsconfig.references.json`). Does NOT run the public-interface chain. Legacy alias: `pnpm run type-check`.
- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + public-method fixture coverage + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`.
- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps ten stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `public-method-coverage`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`.
- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + ts-jsdoc hygiene + public-method fixture coverage + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`.
- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps twelve stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts-test`, `jsdoc-hygiene-ts`, `public-method-coverage`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`.
- `pnpm check:public:docapi` - Document API public surface only. Wraps four stages: `contract-parity`, `contract-outputs`, `examples`, `overview-alignment`. Clean-checkout safe: gitignored generated artifacts are built in memory; tracked outputs (reference docs, overview block) are compared byte-for-byte. No mutation. Legacy alias: `pnpm run docapi:check`.
- `pnpm generate:docapi` - regenerate Document API outputs after editing the contract (alias of `docapi:sync`). Writes gitignored Document API generated artifacts. Run only when you need the artifacts materialized locally (SDK builds, publishing); `check:public:docapi` does not require it.
- `pnpm generate:all` - regenerate schemas, SDK clients, tool catalogs, reference docs.
Expand Down
14 changes: 8 additions & 6 deletions packages/superdoc/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ it stopped running.
| `verify-public-facade-emit.cjs` | postbuild | Per-facade expected symbol set + ESM/CJS parity + legacy command-signature compat. Derives the expected name set directly from the facade source file under `packages/superdoc/src/public/**`; rejects `export *` / `export * as X` in facade sources so the contract stays explicit. | Symbol set drift ships silently; CJS shims diverge from ESM; a wildcard re-export silently widens the public surface. |
| `report-declaration-reachability.cjs` | postbuild | Instrumentation (not a gate): per-bucket reachability ratio of emitted declarations. | Loses visibility into unreachable emit (the SD-2952 trim target). |
| `check-jsdoc.cjs` | wrapper stage 3 (`jsdoc-ratchet`) | Two gates: (a) per-file checkJs on the hand-curated `CHECKED_FILES` (currently 6 files; each must carry `// @ts-check` and stay clean against tsc); (b) ratchet over the public-reachable .js JSDoc surface — every file must be in `CHECKED_FILES`, carry `// @ts-check`, be on `jsdoc-allowlist.cjs` with a reason, or be in `jsdoc-debt-snapshot.json` as known pre-existing debt. New public JSDoc files that aren't accounted for fail with a clear "add @ts-check or allowlist" message. Stale snapshot entries (file gone, gained @ts-check, moved out of public surface) also fail. The allowlist contract is enforced too: every entry must carry a non-empty reason, point at an existing file, and still resolve to a public-reachable JSDoc file. Refresh the snapshot with `pnpm --filter superdoc run check:jsdoc -- --write`. Runs as stage 3 of `check:public:superdoc`. | New public-reachable JSDoc files could land without type coverage; existing ones could lose their `// @ts-check` directive without surfacing as a regression; the allowlist could grow silent / typo-shaped exemptions. |
| `check-jsdoc-hygiene-ts.cjs` | wrapper stage 5 (`jsdoc-hygiene-ts`) | Companion to `check-jsdoc.cjs` for the `.ts` side. Walks every `.ts` file under `packages/superdoc/src/` and `packages/super-editor/src/` (excluding `*.d.ts`, `*.test.ts`, `*.spec.ts`, `dev/`, `__mocks__/`, `__fixtures__/`) and flags type-bearing JSDoc tags: `@type`, `@typedef`, `@callback`, `@template`, `@implements`, `@extends`, `@augments`, `@enum` always; `@param`, `@returns`, `@return`, `@this` only when `tag.typeExpression` is set (prose-only forms pass). AST-based via `ts.getJSDocTags`; not regex. Baseline at `jsdoc-hygiene-ts-baseline.json` uses the stable key `file::enclosingSymbol::tagName::class::occurrenceIndex` so line shifts don't churn entries. Self-tested via `check-jsdoc-hygiene-ts-tests.cjs` (13 in-memory fixtures; name avoids the `*.test.*` glob so vitest doesn't pick it up as a unit-test suite). The self-test suite runs as wrapper stage 4 (`jsdoc-hygiene-ts-test`) immediately before this gate. Policy at `type-hygiene.md`. Refresh with `node packages/superdoc/scripts/check-jsdoc-hygiene-ts.cjs --write`. | Type-bearing JSDoc in `.ts` files is documentation-only (TS ignores it), so duplicate type information drifts silently — `@param {Element}` while signature said `HTMLElement` is the canonical example. Without this gate, that class of drift ships. |

The repo also has a top-level public-contract tier gate. One script,
`scripts/report-public-contract.mjs`, with two modes:
Expand Down Expand Up @@ -146,12 +147,13 @@ what an actual consumer would see — not the workspace source.
| `check-root-classification-closure.mjs` | Asserts no `supported-root` or `legacy-root` export references an `internal-candidate` symbol in its public declared type. | Closure rule from SD-3212. |
| `check-public-method-coverage.mjs` | Obligation-based ratchet over public `SuperDoc` methods + getters. For each member the AST computes which obligations are meaningful (`parameters`, `returns`, or `call`); the gate fails when any required obligation is unsatisfied by a fixture under `src/` AND not on the debt snapshot. Catches the `search(text: string)` regression class — call sites do NOT satisfy `parameters`/`returns` on their own. | Snapshot at `public-method-coverage-debt-snapshot.json`; allowlist at `public-method-coverage-allowlist.cjs` (each entry validated: key must match a real member, value must be a non-empty reason). Refresh with `--write`. |

Of these, six run as wrapper stages of `check:public:superdoc`
after the cheap policy gates (`contract-tiers-test`,
`contract-tiers`, `jsdoc-ratchet`, `public-method-coverage`) and
`build`: `consumer-typecheck-matrix`,
`deep-type-audit-supported-root`, `package-shape`,
`export-snapshots`, `root-classification-closure`.
Six of these run as wrapper stages of `check:public:superdoc`.
`public-method-coverage` runs alongside the cheap policy gates
(`contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`,
`jsdoc-hygiene-ts-test`, `jsdoc-hygiene-ts`) before `build`. The other
five run after `build`:
`consumer-typecheck-matrix`, `deep-type-audit-supported-root`,
`package-shape`, `export-snapshots`, `root-classification-closure`.
`consumer-typecheck-matrix` packs `superdoc.tgz` and installs it into
the consumer fixture. The rest reuse what matrix produced:
`deep-type-audit-supported-root`, `export-snapshots`, and
Expand Down
239 changes: 239 additions & 0 deletions packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
#!/usr/bin/env node
/**
* Tests for `check-jsdoc-hygiene-ts.cjs`.
*
* Protects the scanner against two failure modes:
*
* 1. Silent false-positives — flagging legitimate documentation
* tags (@deprecated, @example, @typeParam, etc.).
* 2. Silent false-negatives — the "scanner returns nothing for
* everything" failure mode, where AST field-name drift or a
* logic bug makes the gate look like it's working when it
* isn't.
*
* Each fixture is an in-memory TypeScript source snippet plus the
* expected list of (tag, class) pairs the scanner should emit.
*
* Wired into check:public:superdoc as the `jsdoc-hygiene-ts-test`
* stage, which runs immediately before `jsdoc-hygiene-ts` so AST
* drift surfaces here, not as a silent zero-result downstream.
*
* File is intentionally named `*-tests.cjs` (not `*.test.cjs`) so
* vitest doesn't pick it up via the `*.test.*` glob — this is a
* standalone Node runner, not a vitest suite.
*
* Run manually with:
* node packages/superdoc/scripts/check-jsdoc-hygiene-ts-tests.cjs
*/

const { findViolations } = require('./check-jsdoc-hygiene-ts.cjs');

const FIXTURES = [
// ─── Negative control ────────────────────────────────────────────
// Clean public method with TS-only types and prose-only JSDoc.
// Asserts the scanner returns ZERO hits — catches the "silent
// false-negative" failure mode where the scanner produces nothing
// and looks like it's working.
{
name: 'negative-control: prose-only JSDoc + TS types',
src: `
/**
* Does the thing.
* @param name The thing to do.
* @returns The result of doing it.
*/
export function doThing(name: string): boolean {
return true;
}
`,
expected: [],
},

// ─── Mixed-tag block ─────────────────────────────────────────────
// A single JSDoc comment that mixes legitimate documentation tags
// (@deprecated) with one type-bearing tag (@param {string}). Must
// report exactly one violation, not three, not zero. Catches the
// "detector iterates all tags and mis-keys" bug.
{
name: 'mixed-tag block: only the typed @param is flagged',
src: `
/**
* Mounts the thing.
* @deprecated Use newThing instead.
* @param {string} foo The input.
* @returns The result.
*/
export function mount(foo: string): string {
return foo;
}
`,
expected: [{ tag: 'param', class: 'declaration-doc-type' }],
},

// ─── @param with type braces ─────────────────────────────────────
{
name: 'typed @param flagged',
src: `
/** @param {Element} el */
export function render(el: HTMLElement): void {}
`,
expected: [{ tag: 'param', class: 'declaration-doc-type' }],
},

// ─── @returns with type braces ───────────────────────────────────
{
name: 'typed @returns flagged',
src: `
/** @returns {boolean} */
export function ready(): boolean { return true; }
`,
expected: [{ tag: 'returns', class: 'declaration-doc-type' }],
},

// ─── @param prose-only ───────────────────────────────────────────
{
name: 'prose-only @param not flagged',
src: `
/** @param el The element. */
export function render(el: HTMLElement): void {}
`,
expected: [],
},

// ─── @type inline ────────────────────────────────────────────────
{
name: 'inline @type cast flagged',
src: `
export function pick(): unknown {
const v = {} as unknown;
/** @type {Element} */
const e = document.body;
return e;
}
`,
expected: [{ tag: 'type', class: 'inline-fake-cast' }],
},

// ─── @typedef ────────────────────────────────────────────────────
{
name: '@typedef always flagged',
src: `
/**
* @typedef {Object} Options
* @property {string} name
*/
export const x = 1;
`,
// Only @typedef fires; nested @property tags inside the typedef
// are not surfaced as top-level tags by ts.getJSDocTags. That's
// intentional for the detector — the parent @typedef IS the
// violation, and the cleanup fix (convert to native interface)
// takes the @property lines with it. Field-level reporting is
// reviewer ergonomics, not detector correctness.
expected: [{ tag: 'typedef', class: 'typedef-style' }],
},

// ─── @callback ───────────────────────────────────────────────────
{
name: '@callback always flagged',
src: `
/**
* @callback Listener
* @param {string} event
* @returns {void}
*/
export const x = 1;
`,
expected: [{ tag: 'callback', class: 'typedef-style' }],
},

// ─── @template ───────────────────────────────────────────────────
{
name: '@template always flagged in .ts',
src: `
/**
* @template T
*/
export function identity<T>(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<T>(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 <word>` 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();
Loading
Loading