From b29bdcaa942cd04bc9dd09ee45d215280cc04890 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 24 May 2026 21:48:42 -0300 Subject: [PATCH 1/4] feat(typecheck): public-method fixture coverage gate Mechanizes the rule documented in CONTRIBUTING.md after #3480: every public SuperDoc method or getter must have at least one consumer-side fixture reference, or be explicitly allowlisted with a reason. Why this exists: the SuperDoc.js -> SuperDoc.ts migration shipped `search(text: string)`, a narrowed version of the previous `string | RegExp` contract. Every existing gate passed - `check:types`, `check:public:superdoc`, the consumer matrix, the deep-type audit, 1054 tests. The bot caught it because no consumer fixture asserted Parameters. The miss was: an unguarded public method can ship with wrong-but-explicit types. This gate prevents the next instance of that class. It does not guarantee the assertion is correct (a typed but wrong assertion would still pass); it guarantees a reviewer was asked to write one. Implementation matches the JSDoc ratchet shape from PR #3474: - tests/consumer-typecheck/check-public-method-coverage.mjs - walks SuperDoc.ts with the TypeScript AST, enumerates public methods + getters (skipping private, static, @internal, # prefix, and the EventEmitter inherited surface). For each, scans every fixture under tests/consumer-typecheck/src/ for one of `Parameters`, `ReturnType`, or `(superdoc|sd).name(`. Fails when a NEW member is uncovered (i.e. not on the debt snapshot) or when a snapshot entry is stale (now covered). - tests/consumer-typecheck/public-method-coverage-allowlist.cjs - the escape hatch for members that are intentionally not consumer- callable (8 entries today: broadcast* lifecycle relays + the internal setActiveEditor / onContentError handlers). - tests/consumer-typecheck/public-method-coverage-debt-snapshot.json - records the 33 currently-uncovered public methods. Future PRs can drain it by adding fixtures and running `--write`. - scripts/check-public-contract.mjs - new stage 4 `public-method-coverage` between jsdoc-ratchet and build. Stage count: 9 -> 10. Verified (all simulated against the actual main HEAD): - baseline -> OK 36 public members; 33 tracked as known debt - simulated new uncovered method (drop one snapshot entry) -> FAIL with "1 NEW public member(s) without any fixture reference" - simulated stale entry (add nonexistent name to snapshot) -> FAIL with "1 stale entry/entries" + `--write` refresh hint - pnpm check:public:superdoc --skip-build -> PASS 9 ran / 1 skipped, 123.4s; new stage runs as [4/10] without disturbing the rest Followup, separate PR: runtime payload tests for SuperDocEventMap events SuperDoc emits directly (cheap events first: ready, editorCreate, editorBeforeCreate). --- scripts/check-public-contract.mjs | 43 ++- .../check-public-method-coverage.mjs | 248 ++++++++++++++++++ .../public-method-coverage-allowlist.cjs | 30 +++ .../public-method-coverage-debt-snapshot.json | 38 +++ 4 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 tests/consumer-typecheck/check-public-method-coverage.mjs create mode 100644 tests/consumer-typecheck/public-method-coverage-allowlist.cjs create mode 100644 tests/consumer-typecheck/public-method-coverage-debt-snapshot.json diff --git a/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs index 0ce7a465f4..8bafd87888 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. build - vite build + the postbuild + * 4. public-method-coverage - ratchet over public SuperDoc + * methods/getters: every member + * must have a Parameters<>, + * ReturnType<>, or call-site + * reference in a consumer fixture, + * or be on the debt snapshot. + * Catches new uncovered surface + * (the class of regression that + * shipped `search(text: string)` + * instead of `string | RegExp`). + * 5. build - vite build + the postbuild * validator chain * (check-tsconfig-type-surface, * ensure-types, audit-bundle, @@ -40,29 +50,29 @@ * Skipped when `--skip-build` is * passed (CI calls `pnpm run build` * separately in its own step). - * 5. consumer-typecheck-matrix - packs superdoc + installs the + * 6. consumer-typecheck-matrix - packs superdoc + installs the * tarball into * tests/consumer-typecheck/ * node_modules/, then runs every * consumer scenario. - * 6. deep-type-audit-supported-root - strict gate on the supported- + * 7. deep-type-audit-supported-root - strict gate on the supported- * root public surface; fails on any * `any` leak. Reuses the install - * from stage 5. - * 7. package-shape - publint + attw against the packed + * from stage 6. + * 8. package-shape - publint + attw against the packed * manifest. Reuses the tarball - * from stage 5. - * 8. export-snapshots - super-editor / legacy / root + * from stage 6. + * 9. export-snapshots - super-editor / legacy / root * no-growth export snapshots. * Reuses the install. - * 9. root-classification-closure - no supported-root or legacy-root + * 10. 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 5 runs before 6-9: stage 5 packs `superdoc.tgz` and - * installs the tarball into the consumer fixture once. Stages 6, 8, - * and 9 reuse the installed fixture; stage 7 reuses the packed tarball + * 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 * directly. Without this ordering each downstream stage would `--pack` * separately and multiply the work. * @@ -122,6 +132,17 @@ 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: 'public-method-coverage', + cwd: REPO_ROOT, + cmd: 'node', + args: ['tests/consumer-typecheck/check-public-method-coverage.mjs'], + blurb: + 'Ratchet over public SuperDoc methods/getters: every member must have ' + + 'a Parameters<>/ReturnType<>/call-site reference in a consumer fixture, ' + + 'or be on the debt snapshot. Catches new uncovered surface; existing ' + + 'debt drains via snapshot refresh.', + }, { name: 'build', cwd: REPO_ROOT, diff --git a/tests/consumer-typecheck/check-public-method-coverage.mjs b/tests/consumer-typecheck/check-public-method-coverage.mjs new file mode 100644 index 0000000000..0ba021e04f --- /dev/null +++ b/tests/consumer-typecheck/check-public-method-coverage.mjs @@ -0,0 +1,248 @@ +#!/usr/bin/env node +/** + * Public-method fixture coverage gate. + * + * Walks `packages/superdoc/src/core/SuperDoc.ts` with the TypeScript AST, + * enumerates the public instance methods + getters, and asserts each has + * at least one consumer-side reference in `tests/consumer-typecheck/src/`. + * "Reference" means any of: + * + * - `Parameters` → parameter shape locked + * - `ReturnType` → return shape locked + * - `superdoc.methodName(` / `sd.methodName(` → call-site exercise + * + * Two gates run here: + * + * 1. RATCHET — A NEW public method/getter that lands without any + * fixture reference fails CI. The contributor must add a fixture + * (preferred) or, for genuinely-internal members, add an + * allowlist entry with a one-line reason. + * + * 2. DEBT SNAPSHOT — The committed debt snapshot at + * `public-method-coverage-debt-snapshot.json` is the set of + * currently-uncovered public members. The ratchet fails when + * the snapshot is stale: a member dropped off (someone added + * a fixture for it — yay! refresh the snapshot to lock the win) + * or a NEW member is uncovered (the regression class we're + * catching). + * + * Refresh the snapshot after intentional changes: + * node tests/consumer-typecheck/check-public-method-coverage.mjs --write + * + * This is a floor, not a ceiling: it guarantees a reviewer was asked + * to write an assertion per new public method. It does NOT guarantee + * the assertion is correct (a typed but wrong assertion would still + * pass). The companion gate is the consumer matrix, which exercises + * the real package shape end-to-end. + * + * Why this exists: the SuperDoc.js → SuperDoc.ts migration introduced + * a regression where `search(text: string)` narrowed the previous + * `string | RegExp` contract. Every existing gate passed — the only + * catcher was a bot review. `search` had a `ReturnType<>` fixture + * but no `Parameters<>` fixture. This script makes that class of miss + * a CI failure for any NEW public method. + * + * Allowlist: `tests/consumer-typecheck/public-method-coverage-allowlist.cjs`. + * Use only for members that are intentionally not consumer-callable + * (e.g. internal lifecycle relays that escaped `private` for runtime + * reasons). Each entry requires a one-line reason. + * + * Wrapper stage: `public-method-coverage` in `scripts/check-public-contract.mjs`. + */ + +import { readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs'; +import { dirname, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..'); +const SUPERDOC_TS = resolve(REPO_ROOT, 'packages/superdoc/src/core/SuperDoc.ts'); +const FIXTURE_DIR = resolve(REPO_ROOT, 'tests/consumer-typecheck/src'); +const ALLOWLIST_PATH = resolve(HERE, 'public-method-coverage-allowlist.cjs'); +const SNAPSHOT_PATH = resolve(HERE, 'public-method-coverage-debt-snapshot.json'); + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const flags = new Set(process.argv.slice(2)); +const writeMode = flags.has('--write'); + +// EventEmitter members; inherited, not SuperDoc's own surface. +const EVENT_EMITTER_MEMBERS = new Set([ + 'on', + 'off', + 'once', + 'emit', + 'addListener', + 'removeListener', + 'removeAllListeners', + 'listeners', + 'listenerCount', + 'eventNames', + 'prependListener', + 'prependOnceListener', + 'rawListeners', +]); + +function loadAllowlist() { + if (!existsSync(ALLOWLIST_PATH)) return {}; + const mod = require(ALLOWLIST_PATH); + if (typeof mod !== 'object' || mod === null) return {}; + return mod; +} + +function loadSnapshot() { + if (!existsSync(SNAPSHOT_PATH)) return []; + const raw = JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf8')); + if (!Array.isArray(raw.knownUncovered)) { + console.error(`[public-method-coverage] invalid snapshot at ${SNAPSHOT_PATH} (missing "knownUncovered" array)`); + process.exit(1); + } + return raw.knownUncovered.slice().sort(); +} + +function writeSnapshot(names) { + const payload = { + $comment: + 'Auto-managed by tests/consumer-typecheck/check-public-method-coverage.mjs. ' + + 'Run with --write to refresh after adding/removing fixture coverage for ' + + 'public SuperDoc members. Each entry is a public method/getter that has ' + + 'no Parameters<>, ReturnType<>, or call-site reference in any consumer fixture.', + knownUncovered: names.slice().sort(), + }; + writeFileSync(SNAPSHOT_PATH, JSON.stringify(payload, null, 2) + '\n'); +} + +/** Walk SuperDoc.ts and return public method/getter metadata. */ +function enumeratePublicMembers() { + const src = readFileSync(SUPERDOC_TS, 'utf8'); + const sf = ts.createSourceFile(SUPERDOC_TS, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + + let cls = null; + for (const stmt of sf.statements) { + if (ts.isClassDeclaration(stmt) && stmt.name?.text === 'SuperDoc') { + cls = stmt; + break; + } + } + if (!cls) { + console.error(`[public-method-coverage] could not find SuperDoc class in ${SUPERDOC_TS}`); + process.exit(1); + } + + const members = []; + for (const m of cls.members) { + if (!ts.isMethodDeclaration(m) && !ts.isGetAccessorDeclaration(m)) continue; + if (!m.name || !ts.isIdentifier(m.name)) continue; + + const name = m.name.text; + const mods = m.modifiers ?? []; + if (mods.some((mod) => mod.kind === ts.SyntaxKind.PrivateKeyword)) continue; + if (mods.some((mod) => mod.kind === ts.SyntaxKind.StaticKeyword)) continue; + if (ts.getJSDocTags(m).some((tag) => tag.tagName?.text === 'internal')) continue; + if (EVENT_EMITTER_MEMBERS.has(name)) continue; + + members.push({ + name, + kind: ts.isGetAccessorDeclaration(m) ? 'getter' : 'method', + }); + } + return members; +} + +function loadFixtures() { + const files = readdirSync(FIXTURE_DIR).filter( + (f) => f.endsWith('.ts') || f.endsWith('.cts') || f.endsWith('.mts'), + ); + return files + .map((f) => `// === ${f} ===\n${readFileSync(join(FIXTURE_DIR, f), 'utf8')}`) + .join('\n'); +} + +function hasAnyReference(fixtureText, name) { + const safe = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const paramRe = new RegExp(`Parameters<\\s*SuperDoc\\[['"]${safe}['"]\\]\\s*>`); + const returnRe = new RegExp(`ReturnType<\\s*SuperDoc\\[['"]${safe}['"]\\]\\s*>`); + const callRe = new RegExp(`(?:superdoc|sd)\\.${safe}\\s*\\(`); + return paramRe.test(fixtureText) || returnRe.test(fixtureText) || callRe.test(fixtureText); +} + +// ─── Main ──────────────────────────────────────────────────────────── + +const members = enumeratePublicMembers(); +const fixtures = loadFixtures(); +const allowlist = loadAllowlist(); +const allowlistedSet = new Set(Object.keys(allowlist)); + +const uncoveredNow = members + .filter((m) => !allowlistedSet.has(m.name)) + .filter((m) => !hasAnyReference(fixtures, m.name)) + .map((m) => m.name) + .sort(); + +if (writeMode) { + writeSnapshot(uncoveredNow); + console.log( + `[public-method-coverage] wrote ${SNAPSHOT_PATH.replace(REPO_ROOT + '/', '')} (${uncoveredNow.length} entries).`, + ); + process.exit(0); +} + +const snapshot = loadSnapshot(); +const snapshotSet = new Set(snapshot); +const uncoveredSet = new Set(uncoveredNow); + +const newUncovered = uncoveredNow.filter((n) => !snapshotSet.has(n)); +const stale = snapshot.filter((n) => !uncoveredSet.has(n)); + +const HR = '='.repeat(72); +console.log('[public-method-coverage] SuperDoc public-surface fixture coverage'); +console.log(HR); +console.log(`Members inspected: ${members.length}`); +console.log(` Methods (non-EventEmitter): ${members.filter((m) => m.kind === 'method').length}`); +console.log(` Getters: ${members.filter((m) => m.kind === 'getter').length}`); +console.log(`Allowlisted (with reason): ${allowlistedSet.size}`); +console.log(`Tracked as known debt: ${uncoveredNow.length - newUncovered.length}`); +console.log(`Snapshot at: ${SNAPSHOT_PATH.replace(REPO_ROOT + '/', '')}`); +console.log(''); + +const failures = []; +if (newUncovered.length > 0) { + failures.push( + `${newUncovered.length} NEW public member(s) without any fixture reference:`, + ); + for (const n of newUncovered) failures.push(` + ${n}`); + failures.push(''); + failures.push( + `Add a consumer fixture under tests/consumer-typecheck/src/ asserting`, + ); + failures.push( + `Parameters']>, ReturnType']>, or a real call site.`, + ); + failures.push( + `If the member is intentionally not consumer-callable, add an entry with`, + ); + failures.push( + `a one-line reason to public-method-coverage-allowlist.cjs.`, + ); +} +if (stale.length > 0) { + if (failures.length > 0) failures.push(''); + failures.push(`${stale.length} stale entry/entries in the debt snapshot (fixture coverage now exists):`); + for (const n of stale) failures.push(` - ${n}`); + failures.push(''); + failures.push( + `Run \`node tests/consumer-typecheck/check-public-method-coverage.mjs --write\``, + ); + failures.push(`to refresh the snapshot and lock in the win.`); +} + +if (failures.length > 0) { + console.log('FAIL fixture coverage drift:'); + for (const line of failures) console.log(line); + process.exit(1); +} + +console.log(`OK ${members.length - allowlistedSet.size} public members; ${uncoveredNow.length} tracked as known debt; ratchet snapshot in sync.`); +process.exit(0); diff --git a/tests/consumer-typecheck/public-method-coverage-allowlist.cjs b/tests/consumer-typecheck/public-method-coverage-allowlist.cjs new file mode 100644 index 0000000000..03515d9a2f --- /dev/null +++ b/tests/consumer-typecheck/public-method-coverage-allowlist.cjs @@ -0,0 +1,30 @@ +/** + * Allowlist for `check-public-method-coverage.mjs`. + * + * The script enumerates public instance methods + getters on the + * `SuperDoc` class and requires each to have at least one consumer-side + * fixture reference (Parameters<>, ReturnType<>, or a real call site). + * Use this file ONLY for members that are intentionally not part of + * the consumer-callable surface but escaped the `private` modifier + * for legitimate reasons (e.g. called by extensions or composables + * that live in the workspace but aren't exposed through the public + * facade). + * + * Each entry MUST carry a one-line reason. The key is the SuperDoc + * member name (no path needed since it scopes to one class). The + * value is the reason. + */ +module.exports = { + // Lifecycle/relay methods called by SuperDoc.vue and extension code. + // Not part of `superdoc.X()` consumer surface; consumers register + // callbacks on Config (`onEditorBeforeCreate`, etc.) and SuperDoc + // relays via these broadcast helpers. + broadcastReady: 'Internal lifecycle relay; called by editor/Vue glue, not by consumers.', + broadcastEditorBeforeCreate: 'Internal lifecycle relay; called by editor/Vue glue, not by consumers.', + broadcastEditorCreate: 'Internal lifecycle relay; called by editor/Vue glue, not by consumers.', + broadcastEditorDestroy: 'Internal lifecycle relay; called by editor/Vue glue, not by consumers.', + broadcastPdfDocumentReady: 'Internal lifecycle relay; called by editor/Vue glue, not by consumers.', + broadcastSidebarToggle: 'Internal lifecycle relay; called by editor/Vue glue, not by consumers.', + setActiveEditor: 'Internal setter; called by editor lifecycle, not by consumers.', + onContentError: 'Internal handler bound to editor `content-error`; consumers use Config.onContentError.', +}; diff --git a/tests/consumer-typecheck/public-method-coverage-debt-snapshot.json b/tests/consumer-typecheck/public-method-coverage-debt-snapshot.json new file mode 100644 index 0000000000..de3dd7b8a6 --- /dev/null +++ b/tests/consumer-typecheck/public-method-coverage-debt-snapshot.json @@ -0,0 +1,38 @@ +{ + "$comment": "Auto-managed by tests/consumer-typecheck/check-public-method-coverage.mjs. Run with --write to refresh after adding/removing fixture coverage for public SuperDoc members. Each entry is a public method/getter that has no Parameters<>, ReturnType<>, or call-site reference in any consumer fixture.", + "knownUncovered": [ + "addCommentsList", + "addSharedUser", + "closeSurface", + "destroy", + "element", + "export", + "exportEditorsToDOCX", + "focus", + "getComment", + "getHTML", + "getPresentationEditorForDocument", + "getZoom", + "lockSuperdoc", + "navigateTo", + "openSurface", + "removeCommentsList", + "removeSharedUser", + "requiredNumberOfEditors", + "save", + "scrollToComment", + "scrollToElement", + "setDisableContextMenu", + "setDocumentMode", + "setHighContrastMode", + "setLocked", + "setShowBookmarks", + "setShowFormattingMarks", + "setTrackedChangesPreferences", + "setZoom", + "state", + "toggleFormattingMarks", + "toggleRuler", + "upgradeToCollaboration" + ] +} From 61071882d2ca6b193978f83cb110c3b83227d54d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 24 May 2026 21:56:17 -0300 Subject: [PATCH 2/4] fix(typecheck): rewrite coverage gate as obligation-based, validate allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught three blockers in the first version: 1. The gate counted any call site as coverage, so the search regression would have slipped through (search-match.ts has sd.search('hello')). 2. Getters had no satisfaction pattern (Parameters/ReturnType are method-shaped); a future getter could only pass via the snapshot. 3. The allowlist contract (non-empty reason, key must match a real member) was unvalidated. Rewrote the gate as obligation-based, not mention-based: For each public member, compute REQUIRED obligations from the AST: - method with >=1 param → requires "parameters" - method with non-void return → requires "returns" - getter → requires "returns" - zero-param void method → requires "call" (otherwise renaming would slip past silently) Satisfaction patterns: - parameters → Parameters - returns (method) → ReturnType - returns (getter) → SuperDoc['name'] or typeof sd.name - call → (superdoc|sd).name( **Call sites no longer satisfy parameters/returns** - they only satisfy the "call" obligation. This is the central fix for the search regression class. The debt snapshot now tracks obligation-level entries like "state:returns" instead of just member names, so partial coverage is visible. Allowlist validation: each key must match an actual public member of SuperDoc; each value must be a non-empty string. Empty reasons, typos, and stale keys all fail with specific messages. Snapshot count: 33 (member-level) → 59 (obligation-level), reflecting the actual unmet obligation surface. Verified: - Baseline: OK 75 obligations across 36 members, 59 tracked as debt. - Sim search regression (remove Parameters) → FAIL with "+ search:parameters". Proves the gate now catches the bug class it was built for. - Sim stale snapshot entry → FAIL with --write hint. - Sim allowlist with empty reason → FAIL with "missing or empty reason". - Sim allowlist key not a member → FAIL with "not a public member of SuperDoc (typo or stale entry)". - pnpm check:public:superdoc --skip-build → PASS 9 ran / 1 skipped, 122.5s; new stage runs as [4/10]. --- .../check-public-method-coverage.mjs | 252 +++++++++++------- .../public-method-coverage-debt-snapshot.json | 96 ++++--- 2 files changed, 212 insertions(+), 136 deletions(-) diff --git a/tests/consumer-typecheck/check-public-method-coverage.mjs b/tests/consumer-typecheck/check-public-method-coverage.mjs index 0ba021e04f..5136ef111c 100644 --- a/tests/consumer-typecheck/check-public-method-coverage.mjs +++ b/tests/consumer-typecheck/check-public-method-coverage.mjs @@ -2,50 +2,56 @@ /** * Public-method fixture coverage gate. * - * Walks `packages/superdoc/src/core/SuperDoc.ts` with the TypeScript AST, - * enumerates the public instance methods + getters, and asserts each has - * at least one consumer-side reference in `tests/consumer-typecheck/src/`. - * "Reference" means any of: + * Obligation-based ratchet over public SuperDoc methods + getters. + * For each public member, the gate computes what fixture coverage is + * meaningful (`parameters`, `returns`, or `call`) and fails when any + * required obligation is unmet AND the member is not on the debt + * snapshot. * - * - `Parameters` → parameter shape locked - * - `ReturnType` → return shape locked - * - `superdoc.methodName(` / `sd.methodName(` → call-site exercise + * Obligations (per member, computed from the AST): * - * Two gates run here: + * - **method with >=1 parameter** → requires `parameters` coverage + * - **method with non-void return** → requires `returns` coverage + * - **getter** → requires `returns` coverage + * - **zero-param method that returns void / Promise** → requires + * `call` coverage (otherwise renaming the method would silently slip + * past) * - * 1. RATCHET — A NEW public method/getter that lands without any - * fixture reference fails CI. The contributor must add a fixture - * (preferred) or, for genuinely-internal members, add an - * allowlist entry with a one-line reason. + * Satisfaction patterns (scanned across every `.ts` / `.cts` / `.mts` + * file under `tests/consumer-typecheck/src/`): * - * 2. DEBT SNAPSHOT — The committed debt snapshot at - * `public-method-coverage-debt-snapshot.json` is the set of - * currently-uncovered public members. The ratchet fails when - * the snapshot is stale: a member dropped off (someone added - * a fixture for it — yay! refresh the snapshot to lock the win) - * or a NEW member is uncovered (the regression class we're - * catching). + * - `parameters` → `Parameters` + * - `returns` (method) → `ReturnType` + * - `returns` (getter) → `SuperDoc['name']` (bare indexed access) or + * `typeof (superdoc|sd).name` + * - `call` → `(superdoc|sd).name(` * - * Refresh the snapshot after intentional changes: - * node tests/consumer-typecheck/check-public-method-coverage.mjs --write + * Call sites do NOT satisfy parameter or return obligations on their + * own (TypeScript would accept a wrong-typed argument if the consumer + * matched the signature). This is the central distinction from a + * "mentioned somewhere" ratchet: the gate must catch the + * `search(text: string)` regression class, where a call site + * `sd.search('hello')` shipped while `Parameters` + * was never asserted. + * + * Two failure modes: * - * This is a floor, not a ceiling: it guarantees a reviewer was asked - * to write an assertion per new public method. It does NOT guarantee - * the assertion is correct (a typed but wrong assertion would still - * pass). The companion gate is the consumer matrix, which exercises - * the real package shape end-to-end. + * 1. RATCHET — A NEW unmet obligation lands (member added, fixture + * removed, or migration narrows a signature) and the obligation + * is not on the debt snapshot. + * 2. SNAPSHOT DRIFT — A snapshot entry is stale (the obligation it + * records is now satisfied). The contributor must run `--write` + * to lock the win. * - * Why this exists: the SuperDoc.js → SuperDoc.ts migration introduced - * a regression where `search(text: string)` narrowed the previous - * `string | RegExp` contract. Every existing gate passed — the only - * catcher was a bot review. `search` had a `ReturnType<>` fixture - * but no `Parameters<>` fixture. This script makes that class of miss - * a CI failure for any NEW public method. + * Refresh the snapshot after intentional changes: + * node tests/consumer-typecheck/check-public-method-coverage.mjs --write * * Allowlist: `tests/consumer-typecheck/public-method-coverage-allowlist.cjs`. * Use only for members that are intentionally not consumer-callable * (e.g. internal lifecycle relays that escaped `private` for runtime - * reasons). Each entry requires a one-line reason. + * reasons). Each entry requires (a) a key that matches an actual public + * member of `SuperDoc`, and (b) a non-empty string reason. The gate + * validates both. * * Wrapper stage: `public-method-coverage` in `scripts/check-public-contract.mjs`. */ @@ -68,21 +74,11 @@ const ts = require('typescript'); const flags = new Set(process.argv.slice(2)); const writeMode = flags.has('--write'); -// EventEmitter members; inherited, not SuperDoc's own surface. const EVENT_EMITTER_MEMBERS = new Set([ - 'on', - 'off', - 'once', - 'emit', - 'addListener', - 'removeListener', - 'removeAllListeners', - 'listeners', - 'listenerCount', - 'eventNames', - 'prependListener', - 'prependOnceListener', - 'rawListeners', + 'on', 'off', 'once', 'emit', + 'addListener', 'removeListener', 'removeAllListeners', + 'listeners', 'listenerCount', 'eventNames', + 'prependListener', 'prependOnceListener', 'rawListeners', ]); function loadAllowlist() { @@ -95,27 +91,26 @@ function loadAllowlist() { function loadSnapshot() { if (!existsSync(SNAPSHOT_PATH)) return []; const raw = JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf8')); - if (!Array.isArray(raw.knownUncovered)) { - console.error(`[public-method-coverage] invalid snapshot at ${SNAPSHOT_PATH} (missing "knownUncovered" array)`); + if (!Array.isArray(raw.knownUnmet)) { + console.error(`[public-method-coverage] invalid snapshot at ${SNAPSHOT_PATH} (missing "knownUnmet" array)`); process.exit(1); } - return raw.knownUncovered.slice().sort(); + return raw.knownUnmet.slice().sort(); } -function writeSnapshot(names) { +function writeSnapshot(entries) { const payload = { $comment: 'Auto-managed by tests/consumer-typecheck/check-public-method-coverage.mjs. ' + - 'Run with --write to refresh after adding/removing fixture coverage for ' + - 'public SuperDoc members. Each entry is a public method/getter that has ' + - 'no Parameters<>, ReturnType<>, or call-site reference in any consumer fixture.', - knownUncovered: names.slice().sort(), + 'Each entry is "memberName:obligation" where obligation is one of ' + + 'parameters | returns | call. Refresh with --write after adding fixtures.', + knownUnmet: entries.slice().sort(), }; writeFileSync(SNAPSHOT_PATH, JSON.stringify(payload, null, 2) + '\n'); } -/** Walk SuperDoc.ts and return public method/getter metadata. */ -function enumeratePublicMembers() { +/** Enumerate public members and compute their obligations. */ +function enumerateObligations() { const src = readFileSync(SUPERDOC_TS, 'utf8'); const sf = ts.createSourceFile(SUPERDOC_TS, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); @@ -143,10 +138,28 @@ function enumeratePublicMembers() { if (ts.getJSDocTags(m).some((tag) => tag.tagName?.text === 'internal')) continue; if (EVENT_EMITTER_MEMBERS.has(name)) continue; - members.push({ - name, - kind: ts.isGetAccessorDeclaration(m) ? 'getter' : 'method', - }); + const isGetter = ts.isGetAccessorDeclaration(m); + const hasParams = !isGetter && (m.parameters?.length ?? 0) > 0; + + // Return-type meaningfulness: meaningful unless explicitly declared void + // / Promise. Undeclared returns are treated as meaningful (i.e. + // the gate prefers requiring an assertion over silently letting it pass). + let returnsMeaningful = true; + if (!isGetter && m.type) { + const rtText = m.type.getText(sf).trim(); + if (rtText === 'void' || rtText === 'Promise') returnsMeaningful = false; + } + + const obligations = []; + if (isGetter) { + obligations.push('returns'); + } else { + if (hasParams) obligations.push('parameters'); + if (returnsMeaningful) obligations.push('returns'); + if (!hasParams && !returnsMeaningful) obligations.push('call'); + } + + members.push({ name, kind: isGetter ? 'getter' : 'method', obligations }); } return members; } @@ -160,41 +173,75 @@ function loadFixtures() { .join('\n'); } -function hasAnyReference(fixtureText, name) { - const safe = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const paramRe = new RegExp(`Parameters<\\s*SuperDoc\\[['"]${safe}['"]\\]\\s*>`); - const returnRe = new RegExp(`ReturnType<\\s*SuperDoc\\[['"]${safe}['"]\\]\\s*>`); - const callRe = new RegExp(`(?:superdoc|sd)\\.${safe}\\s*\\(`); - return paramRe.test(fixtureText) || returnRe.test(fixtureText) || callRe.test(fixtureText); +/** Test whether a specific obligation is satisfied by any fixture. */ +function isSatisfied(fixtures, name, kind, obligation) { + const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (obligation === 'parameters') { + return new RegExp(`Parameters<\\s*SuperDoc\\[['"]${n}['"]\\]\\s*>`).test(fixtures); + } + if (obligation === 'returns') { + if (kind === 'method') { + return new RegExp(`ReturnType<\\s*SuperDoc\\[['"]${n}['"]\\]\\s*>`).test(fixtures); + } + // Getter: accept bare indexed access OR typeof on a SuperDoc instance. + if (new RegExp(`SuperDoc\\[['"]${n}['"]\\](?!\\.)`).test(fixtures)) return true; + if (new RegExp(`typeof\\s+(?:superdoc|sd)\\.${n}\\b`).test(fixtures)) return true; + return false; + } + if (obligation === 'call') { + return new RegExp(`(?:superdoc|sd)\\.${n}\\s*\\(`).test(fixtures); + } + return false; } // ─── Main ──────────────────────────────────────────────────────────── -const members = enumeratePublicMembers(); +const members = enumerateObligations(); const fixtures = loadFixtures(); const allowlist = loadAllowlist(); -const allowlistedSet = new Set(Object.keys(allowlist)); +const allowlistKeys = new Set(Object.keys(allowlist)); +const memberNames = new Set(members.map((m) => m.name)); + +// Validate allowlist BEFORE applying it. +const allowlistFailures = []; +for (const [k, v] of Object.entries(allowlist)) { + if (!memberNames.has(k)) { + allowlistFailures.push(` - ${k}: not a public member of SuperDoc (typo or stale entry)`); + continue; + } + if (typeof v !== 'string' || v.trim().length === 0) { + allowlistFailures.push(` - ${k}: missing or empty reason`); + } +} -const uncoveredNow = members - .filter((m) => !allowlistedSet.has(m.name)) - .filter((m) => !hasAnyReference(fixtures, m.name)) - .map((m) => m.name) - .sort(); +// Compute current unmet obligations (skip allowlisted members entirely). +const unmetNow = []; +for (const m of members) { + if (allowlistKeys.has(m.name)) continue; + for (const ob of m.obligations) { + if (!isSatisfied(fixtures, m.name, m.kind, ob)) { + unmetNow.push(`${m.name}:${ob}`); + } + } +} +unmetNow.sort(); if (writeMode) { - writeSnapshot(uncoveredNow); + writeSnapshot(unmetNow); console.log( - `[public-method-coverage] wrote ${SNAPSHOT_PATH.replace(REPO_ROOT + '/', '')} (${uncoveredNow.length} entries).`, + `[public-method-coverage] wrote ${SNAPSHOT_PATH.replace(REPO_ROOT + '/', '')} (${unmetNow.length} entries).`, ); process.exit(0); } const snapshot = loadSnapshot(); const snapshotSet = new Set(snapshot); -const uncoveredSet = new Set(uncoveredNow); +const unmetSet = new Set(unmetNow); + +const newUnmet = unmetNow.filter((e) => !snapshotSet.has(e)); +const stale = snapshot.filter((e) => !unmetSet.has(e)); -const newUncovered = uncoveredNow.filter((n) => !snapshotSet.has(n)); -const stale = snapshot.filter((n) => !uncoveredSet.has(n)); +const totalObligations = members.reduce((n, m) => n + m.obligations.length, 0); const HR = '='.repeat(72); console.log('[public-method-coverage] SuperDoc public-surface fixture coverage'); @@ -202,35 +249,36 @@ console.log(HR); console.log(`Members inspected: ${members.length}`); console.log(` Methods (non-EventEmitter): ${members.filter((m) => m.kind === 'method').length}`); console.log(` Getters: ${members.filter((m) => m.kind === 'getter').length}`); -console.log(`Allowlisted (with reason): ${allowlistedSet.size}`); -console.log(`Tracked as known debt: ${uncoveredNow.length - newUncovered.length}`); +console.log(`Total obligations: ${totalObligations}`); +console.log(`Allowlisted members: ${allowlistKeys.size}`); +console.log(`Tracked as known debt: ${unmetNow.length - newUnmet.length}`); console.log(`Snapshot at: ${SNAPSHOT_PATH.replace(REPO_ROOT + '/', '')}`); console.log(''); const failures = []; -if (newUncovered.length > 0) { - failures.push( - `${newUncovered.length} NEW public member(s) without any fixture reference:`, - ); - for (const n of newUncovered) failures.push(` + ${n}`); +if (allowlistFailures.length > 0) { + failures.push('public-method-coverage-allowlist contract violations:'); + for (const f of allowlistFailures) failures.push(f); +} +if (newUnmet.length > 0) { + if (failures.length > 0) failures.push(''); + failures.push(`${newUnmet.length} NEW unmet obligation(s):`); + for (const e of newUnmet) failures.push(` + ${e}`); failures.push(''); - failures.push( - `Add a consumer fixture under tests/consumer-typecheck/src/ asserting`, - ); - failures.push( - `Parameters']>, ReturnType']>, or a real call site.`, - ); - failures.push( - `If the member is intentionally not consumer-callable, add an entry with`, - ); - failures.push( - `a one-line reason to public-method-coverage-allowlist.cjs.`, - ); + failures.push(`Add a consumer fixture under tests/consumer-typecheck/src/ that asserts the`); + failures.push(`required shape for each entry above. Obligation key is "memberName:obligation":`); + failures.push(` parameters → Parameters`); + failures.push(` returns (method) → ReturnType`); + failures.push(` returns (getter) → SuperDoc['name'] or typeof sd.name`); + failures.push(` call → sd.name( … ) or superdoc.name( … )`); + failures.push(``); + failures.push(`If the member is intentionally not consumer-callable, add an entry with a`); + failures.push(`one-line reason to public-method-coverage-allowlist.cjs.`); } if (stale.length > 0) { if (failures.length > 0) failures.push(''); - failures.push(`${stale.length} stale entry/entries in the debt snapshot (fixture coverage now exists):`); - for (const n of stale) failures.push(` - ${n}`); + failures.push(`${stale.length} stale entry/entries in the debt snapshot (obligation now satisfied):`); + for (const e of stale) failures.push(` - ${e}`); failures.push(''); failures.push( `Run \`node tests/consumer-typecheck/check-public-method-coverage.mjs --write\``, @@ -244,5 +292,7 @@ if (failures.length > 0) { process.exit(1); } -console.log(`OK ${members.length - allowlistedSet.size} public members; ${uncoveredNow.length} tracked as known debt; ratchet snapshot in sync.`); +console.log( + `OK ${totalObligations} obligation(s) across ${members.length - allowlistKeys.size} members; ${unmetNow.length} tracked as known debt; ratchet snapshot in sync.`, +); process.exit(0); diff --git a/tests/consumer-typecheck/public-method-coverage-debt-snapshot.json b/tests/consumer-typecheck/public-method-coverage-debt-snapshot.json index de3dd7b8a6..251cc1187f 100644 --- a/tests/consumer-typecheck/public-method-coverage-debt-snapshot.json +++ b/tests/consumer-typecheck/public-method-coverage-debt-snapshot.json @@ -1,38 +1,64 @@ { - "$comment": "Auto-managed by tests/consumer-typecheck/check-public-method-coverage.mjs. Run with --write to refresh after adding/removing fixture coverage for public SuperDoc members. Each entry is a public method/getter that has no Parameters<>, ReturnType<>, or call-site reference in any consumer fixture.", - "knownUncovered": [ - "addCommentsList", - "addSharedUser", - "closeSurface", - "destroy", - "element", - "export", - "exportEditorsToDOCX", - "focus", - "getComment", - "getHTML", - "getPresentationEditorForDocument", - "getZoom", - "lockSuperdoc", - "navigateTo", - "openSurface", - "removeCommentsList", - "removeSharedUser", - "requiredNumberOfEditors", - "save", - "scrollToComment", - "scrollToElement", - "setDisableContextMenu", - "setDocumentMode", - "setHighContrastMode", - "setLocked", - "setShowBookmarks", - "setShowFormattingMarks", - "setTrackedChangesPreferences", - "setZoom", - "state", - "toggleFormattingMarks", - "toggleRuler", - "upgradeToCollaboration" + "$comment": "Auto-managed by tests/consumer-typecheck/check-public-method-coverage.mjs. Each entry is \"memberName:obligation\" where obligation is one of parameters | returns | call. Refresh with --write after adding fixtures.", + "knownUnmet": [ + "addCommentsList:parameters", + "addCommentsList:returns", + "addSharedUser:parameters", + "addSharedUser:returns", + "canPerformPermission:parameters", + "canPerformPermission:returns", + "closeSurface:parameters", + "closeSurface:returns", + "destroy:returns", + "element:returns", + "export:parameters", + "export:returns", + "exportEditorsToDOCX:parameters", + "exportEditorsToDOCX:returns", + "focus:returns", + "getComment:parameters", + "getComment:returns", + "getHTML:parameters", + "getHTML:returns", + "getPresentationEditorForDocument:parameters", + "getPresentationEditorForDocument:returns", + "getZoom:returns", + "goToSearchResult:returns", + "lockSuperdoc:parameters", + "lockSuperdoc:returns", + "navigateTo:parameters", + "navigateTo:returns", + "openSurface:parameters", + "openSurface:returns", + "removeCommentsList:returns", + "removeSharedUser:parameters", + "removeSharedUser:returns", + "requiredNumberOfEditors:returns", + "save:returns", + "scrollToComment:parameters", + "scrollToComment:returns", + "scrollToElement:parameters", + "scrollToElement:returns", + "setDisableContextMenu:parameters", + "setDisableContextMenu:returns", + "setDocumentMode:parameters", + "setDocumentMode:returns", + "setHighContrastMode:parameters", + "setHighContrastMode:returns", + "setLocked:parameters", + "setLocked:returns", + "setShowBookmarks:parameters", + "setShowBookmarks:returns", + "setShowFormattingMarks:parameters", + "setShowFormattingMarks:returns", + "setTrackedChangesPreferences:parameters", + "setTrackedChangesPreferences:returns", + "setZoom:parameters", + "setZoom:returns", + "state:returns", + "toggleFormattingMarks:returns", + "toggleRuler:returns", + "upgradeToCollaboration:parameters", + "upgradeToCollaboration:returns" ] } From 6ac67cf3959fd4c35e8b0c1951828bbbbb4e7c9a Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 24 May 2026 22:01:37 -0300 Subject: [PATCH 3/4] docs(typecheck): refresh stage-count docs + obligation language for new gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After adding the public-method-coverage stage: - AGENTS.md: nine → ten stages; added `public-method-coverage` to the staged list and to the umbrella description. - packages/superdoc/scripts/README.md: new row for `check-public-method-coverage.mjs` in the consumer-typecheck infrastructure table (snapshot + allowlist paths, refresh command, the call-site distinction); updated the stage-count sentence (five → six wrapper stages of check:public:superdoc after the policy gates) and added public-method-coverage to the cheap-policy-gate list. - scripts/check-public-contract.mjs: tightened both the stage 4 docstring and the stage `blurb` to use 'obligation' language consistently. The blurb explicitly says call sites do NOT satisfy parameters/returns on their own — the central fix that separated v2 from v1. --- AGENTS.md | 4 ++-- packages/superdoc/scripts/README.md | 10 +++++---- scripts/check-public-contract.mjs | 32 ++++++++++++++++------------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eb59352d55..8db586c013 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,8 +72,8 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE - `pnpm test` - unit tests - `pnpm dev` - dev server from `examples/` - `pnpm check:types` - raw TS compile across all referenced projects (`tsc -b tsconfig.references.json`). Does NOT run the public-interface chain. Legacy alias: `pnpm run type-check`. -- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + 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 nine stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `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 + 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: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 a4a51636d3..8ca6c952a2 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -144,12 +144,14 @@ what an actual consumer would see — not the workspace source. | `check-all-public-types-fixture.mjs` | Asserts every type-only root export has an `AssertNotAny` line in `src/all-public-types.ts`. | Derives the expected set from `superdoc-root-classification.json`. | | `package-shape-gate.mjs` | External package-shape linters (publint + attw) against the packed tarball. | Catches condition ordering, masquerading exports, missing field declarations. | | `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, five run as wrapper stages of `check:public:superdoc` +Of these, six run as wrapper stages of `check:public:superdoc` after the cheap policy gates (`contract-tiers-test`, -`contract-tiers`, `jsdoc-ratchet`) and `build`: -`consumer-typecheck-matrix`, `deep-type-audit-supported-root`, -`package-shape`, `export-snapshots`, `root-classification-closure`. +`contract-tiers`, `jsdoc-ratchet`, `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 the consumer fixture. The rest reuse what matrix produced: `deep-type-audit-supported-root`, `export-snapshots`, and diff --git a/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs index 8bafd87888..5737e226ed 100755 --- a/scripts/check-public-contract.mjs +++ b/scripts/check-public-contract.mjs @@ -29,16 +29,19 @@ * land without `// @ts-check` or * when the allowlist carries * empty/stale entries. - * 4. public-method-coverage - ratchet over public SuperDoc - * methods/getters: every member - * must have a Parameters<>, - * ReturnType<>, or call-site - * reference in a consumer fixture, - * or be on the debt snapshot. - * Catches new uncovered surface - * (the class of regression that - * shipped `search(text: string)` - * instead of `string | RegExp`). + * 4. public-method-coverage - obligation-based ratchet over + * public SuperDoc methods + + * getters. For each member the + * AST computes which obligations + * are meaningful (parameters / + * returns / call); each unmet + * obligation must be on the debt + * snapshot or the gate fails. + * Call sites do NOT satisfy + * parameters/returns obligations + * on their own — that's why + * `search(text: string)` shipped + * under v1 of this gate. * 5. build - vite build + the postbuild * validator chain * (check-tsconfig-type-surface, @@ -138,10 +141,11 @@ const stages = [ cmd: 'node', args: ['tests/consumer-typecheck/check-public-method-coverage.mjs'], blurb: - 'Ratchet over public SuperDoc methods/getters: every member must have ' + - 'a Parameters<>/ReturnType<>/call-site reference in a consumer fixture, ' + - 'or be on the debt snapshot. Catches new uncovered surface; existing ' + - 'debt drains via snapshot refresh.', + 'Obligation-based ratchet over public SuperDoc methods + getters. ' + + 'Each member has computed obligations (parameters / returns / call) ' + + 'that must be satisfied by a typed assertion in a consumer fixture, ' + + 'or be on the debt snapshot. Call sites do NOT satisfy parameters/' + + 'returns on their own (this is why search(text: string) shipped).', }, { name: 'build', From 7e5e94425d80e418d6dd7d0826f5fd5d58ded4ed Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 25 May 2026 06:35:09 -0300 Subject: [PATCH 4/4] fix(typecheck): walk fixture directory recursively in public-method-coverage Codex review caught a real latent gap: the gate's loadFixtures() used readdirSync(FIXTURE_DIR) which only returns direct children. Today's fixtures are flat, so nothing breaks - but typecheck-matrix.mjs already accepts nested src/**/*.ts, so the first nested fixture would have silently produced false unmet obligations from this gate. Replaced with a small manual recursive walker (not the Node 20 readdirSync({ recursive: true }) option, so the gate works on any Node version the repo supports). Concatenated fixture text keeps the relative path label (e.g. // === nested/foo.ts ===) for the same diagnostic value as before. Verified with the exact scenario the bot described: - Created tests/consumer-typecheck/src/tmp-nested/search-param.ts asserting Parameters. - Temporarily removed the flat search:parameters assertion in search-match.ts. - Gate passed: nested assertion satisfied the obligation. - Removed temp file and restored the flat fixture; baseline re-passes. --- .../check-public-method-coverage.mjs | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/tests/consumer-typecheck/check-public-method-coverage.mjs b/tests/consumer-typecheck/check-public-method-coverage.mjs index 5136ef111c..dd155f47ef 100644 --- a/tests/consumer-typecheck/check-public-method-coverage.mjs +++ b/tests/consumer-typecheck/check-public-method-coverage.mjs @@ -164,12 +164,31 @@ function enumerateObligations() { return members; } +/** + * Recursively walk FIXTURE_DIR and return every `.ts` / `.cts` / `.mts` + * file path, relative to FIXTURE_DIR. Manual recursion (not + * `readdirSync(..., { recursive: true })`) so the gate works on any + * Node version this repo supports without depending on the recursive + * option being available. + */ +function listFixtureFiles(dir, rel = '') { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const child = join(dir, entry.name); + const relPath = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + out.push(...listFixtureFiles(child, relPath)); + } else if (entry.isFile() && /\.(c|m)?ts$/.test(entry.name)) { + out.push(relPath); + } + } + return out; +} + function loadFixtures() { - const files = readdirSync(FIXTURE_DIR).filter( - (f) => f.endsWith('.ts') || f.endsWith('.cts') || f.endsWith('.mts'), - ); + const files = listFixtureFiles(FIXTURE_DIR).sort(); return files - .map((f) => `// === ${f} ===\n${readFileSync(join(FIXTURE_DIR, f), 'utf8')}`) + .map((rel) => `// === ${rel} ===\n${readFileSync(join(FIXTURE_DIR, rel), 'utf8')}`) .join('\n'); }