Skip to content

Commit 6d65d9a

Browse files
authored
feat(types): harden package shape (SD-2978) (#3251)
* feat(types): harden package shape (SD-2978) * fix(types): gate stable release with same package-shape checks (SD-2978) The original SD-2978 commit only added consumer matrix + package-shape gates to release-superdoc.yml. Between sessions, release-stable.yml landed as the new central orchestrator for the npm `latest` publish lane, and SuperDoc's stable releases now route through that workflow instead of release-superdoc.yml (which is now `@next` only). Without this patch the stable lane would publish without the matrix or publint/attw gates running, defeating the purpose of "package-shape honest in CI" because the most-consumed dist-tag would still be unverified at publish time. Adds the same three steps (matrix, deep-type-audit, package-shape-gate) between Build packages and the orchestrator step in release-stable.yml. * fix(types): teach deep audit to read nested types: { import, require } (SD-2978) The deep audit assumed `entry.types` is always a string. SD-2978's manifest changes nest it as `{ import: '...d.ts', require: '...d.cts' }` for the three entries that publish CJS. The audit threw ERR_INVALID_ARG_TYPE on `path.resolve(root, entry.types)` when entry.types was an object. Add a small helper that picks the ESM target from either shape (string or condition object). Walking the .d.ts side is sufficient because the .d.cts is a generated shim of the same surface. Verified: audit now exits 0 with the same 1799 findings as pre-fix. * fix(types): make sanitizer re-entrant + route pack:local through pack:es (SD-2978) Code review found that `pnpm run pack` was broken by the new prepack/postpack lifecycle. pnpm wraps prepack/postpack around scripts named exactly `pack`, and the user `pack` script itself invokes `pnpm pack` which triggers a second prepack. The inner prepack hit the "backup exists" guard and exited 1, the outer postpack was skipped, and the workspace was left with `package.json` mutated and `.package.json.prepack-backup` orphaned. Two changes: 1. Make `prepare` re-entrant. If the backup file exists AND the current manifest already looks sanitized (no `source` conditions, no `unpkg` or `jsdelivr` fields), no-op so the inner prepack falls through and the inner postpack can restore cleanly. If the backup exists but the manifest is NOT sanitized, fail loudly with a clear message — that means the workspace is in an inconsistent state from a previous failed pack and the developer needs to clean up. `restore` was already idempotent (no-op when backup missing). 2. Route `pack:local` through `pack:es` directly. Both ultimately do the same thing, but going through `pack:es` (whose name does not collide with the lifecycle trigger) avoids the double-fire on the common local-pack path. Verified with a synthetic harness covering 5 cases: clean run, double-fire (outer + inner), failed run (state inspection), retry after failure (self-heal), inconsistent state (loud refusal). All pass. Verified live in the worktree: - pnpm run pack:es: tarball created, manifest restored, no orphan backup - pnpm run pack: same (was broken before this commit, now works)
1 parent f31c05b commit 6d65d9a

14 files changed

Lines changed: 477 additions & 51 deletions

.github/workflows/ci-superdoc.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,13 @@ jobs:
147147
cd tests/consumer-typecheck
148148
node deep-type-audit.mjs
149149
150+
- name: Package shape gates
151+
# External package-shape linters (publint + attw) running against
152+
# the packed tarball. Catches manifest issues that the in-repo
153+
# consumer matrix does not see: condition ordering, masquerading
154+
# ESM, missing CDN files, unpublished `source` paths.
155+
run: node tests/consumer-typecheck/package-shape-gate.mjs
156+
150157
unit-tests:
151158
needs: build
152159
runs-on: ubuntu-latest

.github/workflows/release-stable.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,24 @@ jobs:
106106
- name: Build packages
107107
run: pnpm run build
108108

109+
# Public-type contract gates: same gates as PR CI and
110+
# release-superdoc.yml. The stable orchestrator publishes the npm
111+
# `latest` tag, so a regression that bypassed PR CI would otherwise
112+
# ship to every consumer pinned to `^1` or `latest`. Run identical
113+
# checks here so the stable lane cannot silently relax the gate.
114+
- name: Consumer typecheck (matrix)
115+
run: |
116+
cd tests/consumer-typecheck
117+
node typecheck-matrix.mjs
118+
119+
- name: Deep public-type audit (report-only)
120+
run: |
121+
cd tests/consumer-typecheck
122+
node deep-type-audit.mjs
123+
124+
- name: Package shape gates
125+
run: node tests/consumer-typecheck/package-shape-gate.mjs
126+
109127
- name: Release stable packages (orchestrator)
110128
id: stable_release
111129
env:

.github/workflows/release-superdoc.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ jobs:
118118
- name: Build packages
119119
run: pnpm run build
120120

121-
# Public-type contract gate: same gates as PR CI (ci-superdoc.yml).
121+
# Public-type contract gates: same gates as PR CI (ci-superdoc.yml).
122122
# Runs before publishing so a release cannot ship a regression that
123123
# bypassed PR CI (manual republish, hotfix branch, recovery flow).
124124
- name: Consumer typecheck (matrix)
@@ -134,6 +134,11 @@ jobs:
134134
cd tests/consumer-typecheck
135135
node deep-type-audit.mjs
136136
137+
- name: Package shape gates
138+
# External package-shape linters (publint + attw) running against
139+
# the packed tarball. Same step as PR CI.
140+
run: node tests/consumer-typecheck/package-shape-gate.mjs
141+
137142
# PR preview: publish with pr-<number> dist-tag
138143
- name: Publish PR preview
139144
if: inputs.pr_number

packages/superdoc/package.json

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,19 @@
1919
],
2020
"exports": {
2121
".": {
22-
"types": "./dist/superdoc/src/index.d.ts",
22+
"types": {
23+
"import": "./dist/superdoc/src/index.d.ts",
24+
"require": "./dist/superdoc/src/index.d.cts"
25+
},
2326
"source": "./src/index.js",
2427
"import": "./dist/superdoc.es.js",
2528
"require": "./dist/superdoc.cjs"
2629
},
2730
"./types": {
28-
"types": "./dist/super-editor/src/types.d.ts",
31+
"types": {
32+
"import": "./dist/super-editor/src/types.d.ts",
33+
"require": "./dist/super-editor/src/types.d.cts"
34+
},
2935
"source": "./src/types.ts",
3036
"import": "./dist/types.es.js",
3137
"require": "./dist/types.cjs"
@@ -39,8 +45,11 @@
3945
"import": "./dist/super-editor/docx-zipper.es.js"
4046
},
4147
"./super-editor": {
48+
"types": {
49+
"import": "./dist/superdoc/src/super-editor.d.ts",
50+
"require": "./dist/superdoc/src/super-editor.d.cts"
51+
},
4252
"source": "./src/super-editor.js",
43-
"types": "./dist/superdoc/src/super-editor.d.ts",
4453
"import": "./dist/super-editor.es.js",
4554
"require": "./dist/super-editor.cjs"
4655
},
@@ -112,8 +121,6 @@
112121
},
113122
"main": "./dist/superdoc.cjs",
114123
"module": "./dist/superdoc.es.js",
115-
"unpkg": "./dist/superdoc.min.js",
116-
"jsdelivr": "./dist/superdoc.min.js",
117124
"scripts": {
118125
"dev": "concurrently -k -n VITE,WORD -c cyan,magenta \"vite\" \"node ../../devtools/word-benchmark-sidecar/server.js --stay-alive-on-reuse\"",
119126
"dev:collab": "concurrently -k -n VITE,COLLAB,WORD -c cyan,green,magenta \"vite\" \"pnpm run collab-server\" \"node ../../devtools/word-benchmark-sidecar/server.js --stay-alive-on-reuse\"",
@@ -130,7 +137,9 @@
130137
"build:cdn": "vite build --config vite.config.cdn.js",
131138
"watch:cdn": "vite build --watch --config vite.config.cdn.js",
132139
"clean": "rm -rf dist",
133-
"pack:local": "pnpm run pack",
140+
"prepack": "node ./scripts/sanitize-pack-manifest.cjs prepare",
141+
"postpack": "node ./scripts/sanitize-pack-manifest.cjs restore",
142+
"pack:local": "pnpm run pack:es",
134143
"pack": "pnpm run build:es && pnpm pack && mv $(ls superdoc-*.tgz) ./superdoc.tgz",
135144
"pack:es": "pnpm run build:es && pnpm pack && mv $(ls superdoc-*.tgz) ./superdoc.tgz",
136145
"test": "vitest"

packages/superdoc/scripts/audit-declarations.cjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env node
22
/**
33
* Audit the published declaration surface for leaks the package boundary RFC
4-
* (SD-2829) classifies as forbidden. Walks every `.d.ts` file under `dist/`
4+
* (SD-2829) classifies as forbidden. Walks every declaration file under `dist/`
55
* and reports:
66
*
77
* Rule 1 (FAIL in strict mode): private workspace specifier in an emitted
@@ -90,7 +90,7 @@ function collectDtsFiles(dir) {
9090
files.push(...collectDtsFiles(fullPath));
9191
continue;
9292
}
93-
if (entry.name.endsWith('.d.ts')) {
93+
if (entry.name.endsWith('.d.ts') || entry.name.endsWith('.d.cts')) {
9494
files.push(fullPath);
9595
}
9696
}
@@ -159,7 +159,7 @@ const relocatedInShim = RELOCATION_GUARD_PACKAGES.filter((pkg) =>
159159

160160
console.log('[audit-declarations] Declaration surface audit');
161161
console.log('='.repeat(72));
162-
console.log(`Scanned: ${dtsFiles.length} .d.ts files under ${path.relative(process.cwd(), distRoot)}/`);
162+
console.log(`Scanned: ${dtsFiles.length} declaration files under ${path.relative(process.cwd(), distRoot)}/`);
163163
console.log();
164164

165165
const violations = [];

packages/superdoc/scripts/check-export-coverage.cjs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,14 @@ function entryAllowlistedAsset(value) {
3333
}
3434

3535
function entryHasTypes(value) {
36-
if (typeof value === 'string') return false;
37-
if (typeof value !== 'object' || value === null) return false;
38-
return typeof value.types === 'string';
36+
return collectTypesTargets(value).length > 0;
3937
}
4038

41-
function typesTargetExists(value) {
42-
if (!entryHasTypes(value)) return false;
43-
return fs.existsSync(path.resolve(packageRoot, value.types));
39+
function collectTypesTargets(value) {
40+
if (typeof value !== 'object' || value === null) return [];
41+
if (typeof value.types === 'string') return [value.types];
42+
if (typeof value.types !== 'object' || value.types === null) return [];
43+
return Object.values(value.types).filter((target) => typeof target === 'string');
4444
}
4545

4646
const violations = [];
@@ -53,8 +53,10 @@ for (const [subpath, value] of Object.entries(packageJson.exports || {})) {
5353
violations.push({ subpath, reason: 'missing `types` field in conditional exports' });
5454
continue;
5555
}
56-
if (!typesTargetExists(value)) {
57-
violations.push({ subpath, reason: `\`types\` target does not exist: ${value.types}` });
56+
for (const target of collectTypesTargets(value)) {
57+
if (!fs.existsSync(path.resolve(packageRoot, target))) {
58+
violations.push({ subpath, reason: `\`types\` target does not exist: ${target}` });
59+
}
5860
}
5961
}
6062

packages/superdoc/scripts/ensure-types.cjs

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,84 @@ const SHARED_COMMON_DTS_TARGETS = typeSurface.sharedCommonDtsTargets;
9393
console.log(`[ensure-types] ✓ Emitted ${SHARED_COMMON_DTS_TARGETS.length} shared/common declarations`);
9494
}
9595

96-
const requiredEntryPoints = typeSurface.requiredEntryPoints;
96+
// SD-2978: the package advertises CJS runtime entry points for `.`, `./types`,
97+
// and `./super-editor`. Node16/NodeNext TypeScript consumers resolving those
98+
// entries through `require` need CJS declaration entry points (`.d.cts`) so the
99+
// type graph is honest about the runtime module kind. Generate named CJS
100+
// declaration shims from the ESM entry declarations. A plain
101+
// `export * from './entry.js'` is not valid here: TypeScript still treats that
102+
// as a CJS declaration importing an ESM declaration and raises TS1479.
103+
const cjsDeclarationShims = [
104+
{
105+
file: path.join(distRoot, 'superdoc/src/index.d.cts'),
106+
source: path.join(distRoot, 'superdoc/src/index.d.ts'),
107+
target: './index.js',
108+
},
109+
{
110+
file: path.join(distRoot, 'super-editor/src/types.d.cts'),
111+
source: path.join(distRoot, 'super-editor/src/types.d.ts'),
112+
target: './types.js',
113+
},
114+
{
115+
file: path.join(distRoot, 'superdoc/src/super-editor.d.cts'),
116+
source: path.join(distRoot, 'superdoc/src/super-editor.d.ts'),
117+
target: './super-editor.js',
118+
},
119+
];
120+
121+
function isValidIdentifier(name) {
122+
return /^[$A-Z_a-z][$\w]*$/.test(name);
123+
}
97124

98-
for (const entry of requiredEntryPoints) {
99-
const fullPath = path.join(distRoot, entry);
100-
if (!fs.existsSync(fullPath)) {
101-
console.error(`[ensure-types] Missing ${entry}`);
125+
function emitCjsDeclarationShim({ file, source, target }) {
126+
const ts = require('typescript');
127+
const program = ts.createProgram([source], {
128+
target: ts.ScriptTarget.ES2022,
129+
module: ts.ModuleKind.Node16,
130+
moduleResolution: ts.ModuleResolutionKind.Node16,
131+
skipLibCheck: true,
132+
noEmit: true,
133+
});
134+
const checker = program.getTypeChecker();
135+
const sourceFile = program.getSourceFile(source);
136+
const moduleSymbol = sourceFile && checker.getSymbolAtLocation(sourceFile);
137+
138+
if (!moduleSymbol) {
139+
console.error(`[ensure-types] Could not inspect exports for ${path.relative(distRoot, source)}`);
102140
process.exit(1);
103141
}
142+
143+
const importRef = `import('${target}', { with: { "resolution-mode": "import" } })`;
144+
const importLines = [
145+
'// Generated by scripts/ensure-types.cjs. Do not edit by hand.',
146+
];
147+
const exportLines = [];
148+
149+
for (const symbol of checker.getExportsOfModule(moduleSymbol).sort((a, b) => a.name.localeCompare(b.name))) {
150+
const name = symbol.getName();
151+
if (name === 'default' || !isValidIdentifier(name)) continue;
152+
153+
const resolved = (symbol.flags & ts.SymbolFlags.Alias) ? checker.getAliasedSymbol(symbol) : symbol;
154+
const hasValue = Boolean(resolved.flags & ts.SymbolFlags.Value);
155+
const hasType = Boolean(resolved.flags & ts.SymbolFlags.Type);
156+
157+
if (hasType) {
158+
const typeAlias = `__Cjs_${name}`;
159+
importLines.push(`import type { ${name} as ${typeAlias} } from '${target}' with { "resolution-mode": "import" };`);
160+
if (hasValue) {
161+
exportLines.push(`export type ${name} = ${typeAlias};`);
162+
} else {
163+
exportLines.push(`export type { ${typeAlias} as ${name} };`);
164+
}
165+
}
166+
167+
if (hasValue) {
168+
exportLines.push(`export declare const ${name}: typeof ${importRef}.${name};`);
169+
}
170+
}
171+
172+
fs.mkdirSync(path.dirname(file), { recursive: true });
173+
fs.writeFileSync(file, `${importLines.concat(exportLines).join('\n')}\n`);
104174
}
105175

106176
const indexPath = path.join(distRoot, 'superdoc/src/index.d.ts');
@@ -416,6 +486,22 @@ if (fs.readFileSync(superEditorFacadePath, 'utf8') !== expectedSuperEditorFacade
416486
console.log('[ensure-types] ✓ Normalized superdoc/super-editor facade types');
417487
}
418488

489+
for (const shim of cjsDeclarationShims) {
490+
emitCjsDeclarationShim(shim);
491+
}
492+
console.log(`[ensure-types] ✓ Emitted ${cjsDeclarationShims.length} CJS declaration shims`);
493+
494+
const requiredEntryPoints = typeSurface.requiredEntryPoints;
495+
496+
for (const entry of requiredEntryPoints) {
497+
const fullPath = path.join(distRoot, entry);
498+
if (!fs.existsSync(fullPath)) {
499+
console.error(`[ensure-types] Missing ${entry}`);
500+
process.exit(1);
501+
}
502+
}
503+
console.log('[ensure-types] ✓ Verified type entry points');
504+
419505
// ---------------------------------------------------------------------------
420506
// SD-2942: the auto-generated `_internal-shims.d.ts` mechanism was removed
421507
// after SD-2893 drained every shim entry to zero. Previously this script
@@ -447,5 +533,3 @@ if (fs.existsSync(legacyShimPath)) {
447533
fs.unlinkSync(legacyShimPath);
448534
console.log('[ensure-types] ✓ Removed legacy _internal-shims.d.ts');
449535
}
450-
451-
console.log('[ensure-types] ✓ Verified type entry points');

0 commit comments

Comments
 (0)