Skip to content

Commit d804415

Browse files
authored
Merge pull request #3357 from superdoc-dev/caio-pizzol/SD-3176-legacy-no-growth-gates
feat(ci): no-growth gates for legacy public surfaces (SD-3176)
2 parents 2b4e9a1 + daef44f commit d804415

11 files changed

Lines changed: 610 additions & 0 deletions

.github/workflows/ci-superdoc.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,15 @@ jobs:
154154
# ESM, missing CDN files, unpublished `source` paths.
155155
run: node tests/consumer-typecheck/package-shape-gate.mjs
156156

157+
- name: Legacy public no-growth gates (SD-3176)
158+
# No-growth snapshots for the legacy public compatibility surfaces.
159+
# See tests/consumer-typecheck/snapshots/README.md for the policy.
160+
# Runs after the matrix step so the packed-and-installed fixture
161+
# is available for Snapshot B (resolved named exports).
162+
run: |
163+
node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --check
164+
node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --check
165+
157166
unit-tests:
158167
needs: build
159168
runs-on: ubuntu-latest

.github/workflows/release-stable.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ jobs:
127127
- name: Package shape gates
128128
run: node tests/consumer-typecheck/package-shape-gate.mjs
129129

130+
- name: Legacy public no-growth gates (SD-3176)
131+
run: |
132+
node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --check
133+
node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --check
134+
130135
- name: Release stable packages (orchestrator)
131136
id: stable_release
132137
env:

.github/workflows/release-superdoc.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ jobs:
149149
# the packed tarball. Same step as PR CI.
150150
run: node tests/consumer-typecheck/package-shape-gate.mjs
151151

152+
- name: Legacy public no-growth gates (SD-3176)
153+
# Same gate as PR CI. Catches releases that bypass PR CI.
154+
run: |
155+
node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --check
156+
node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --check
157+
152158
# PR preview: publish with pr-<number> dist-tag
153159
- name: Publish PR preview
154160
if: inputs.pr_number
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env node
2+
/**
3+
* SD-3176: no-growth gate for `@superdoc/super-editor` package-level exports map.
4+
*
5+
* Snapshots the keys of `packages/super-editor/package.json#exports`. New
6+
* subpath entries (e.g. a fresh `./foo`) fail CI. Removing entries also fails
7+
* the diff so the change gets explicit reviewer attention.
8+
*
9+
* Companion to `snapshot-superdoc-legacy-exports.mjs`, which catches growth
10+
* of resolved named exports through `superdoc/super-editor` and the three
11+
* other legacy subpaths.
12+
*
13+
* Usage:
14+
* node snapshot-super-editor-package-exports.mjs --check
15+
* node snapshot-super-editor-package-exports.mjs --write
16+
*
17+
* `--write` regenerates the snapshot. Only run it when the change is
18+
* intentional and tied to SD-3175 (path-as-contract facade umbrella).
19+
*/
20+
import { readFileSync, writeFileSync } from 'node:fs';
21+
import { fileURLToPath } from 'node:url';
22+
import { dirname, resolve } from 'node:path';
23+
24+
const HERE = dirname(fileURLToPath(import.meta.url));
25+
const REPO_ROOT = resolve(HERE, '..', '..');
26+
const PKG = resolve(REPO_ROOT, 'packages', 'super-editor', 'package.json');
27+
const SNAPSHOT = resolve(HERE, 'snapshots', 'super-editor-package-exports.txt');
28+
29+
const args = process.argv.slice(2);
30+
const mode = args.includes('--write') ? 'write' : args.includes('--check') ? 'check' : null;
31+
if (!mode) {
32+
console.error('Usage: snapshot-super-editor-package-exports.mjs --write | --check');
33+
process.exit(2);
34+
}
35+
36+
const pkg = JSON.parse(readFileSync(PKG, 'utf8'));
37+
if (!pkg.exports || typeof pkg.exports !== 'object') {
38+
console.error(`[SD-3176] ${PKG} has no exports map.`);
39+
process.exit(1);
40+
}
41+
42+
const current = Object.keys(pkg.exports).sort().join('\n') + '\n';
43+
44+
if (mode === 'write') {
45+
writeFileSync(SNAPSHOT, current, 'utf8');
46+
console.log(`[SD-3176] Wrote ${SNAPSHOT}`);
47+
process.exit(0);
48+
}
49+
50+
let baseline;
51+
try {
52+
baseline = readFileSync(SNAPSHOT, 'utf8');
53+
} catch (err) {
54+
console.error(`[SD-3176] Snapshot not found: ${SNAPSHOT}`);
55+
console.error('Run with --write to seed the baseline.');
56+
process.exit(1);
57+
}
58+
59+
if (baseline === current) {
60+
console.log('[SD-3176] super-editor package exports map: no growth.');
61+
process.exit(0);
62+
}
63+
64+
const baseSet = new Set(baseline.split('\n').filter(Boolean));
65+
const curSet = new Set(current.split('\n').filter(Boolean));
66+
const added = [...curSet].filter((k) => !baseSet.has(k));
67+
const removed = [...baseSet].filter((k) => !curSet.has(k));
68+
69+
console.error('[SD-3176] @superdoc/super-editor package.json#exports drifted:');
70+
if (added.length) console.error(' added: ' + added.join(', '));
71+
if (removed.length) console.error(' removed: ' + removed.join(', '));
72+
console.error('');
73+
console.error('Per SD-3175 (path-as-contract facade), @superdoc/super-editor is legacy compatibility surface');
74+
console.error('and must not grow. If this change is intentional (e.g. an approved compat shim), regenerate:');
75+
console.error(' node tests/consumer-typecheck/snapshot-super-editor-package-exports.mjs --write');
76+
console.error('and link the PR to SD-3175 or a child ticket for reviewer sign-off.');
77+
process.exit(1);
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
#!/usr/bin/env node
2+
/**
3+
* SD-3176: no-growth gate for legacy `superdoc/*` subpaths.
4+
*
5+
* Snapshots the resolved named exports visible through these subpaths against
6+
* the packed-and-installed tarball:
7+
* - superdoc/super-editor (the dangerous one; `export *` from @superdoc/super-editor)
8+
* - superdoc/converter
9+
* - superdoc/docx-zipper
10+
* - superdoc/file-zipper
11+
*
12+
* Source parsing is insufficient because `superdoc/src/super-editor.js` is
13+
* `export * from '@superdoc/super-editor'`. The contract that ships is what
14+
* a consumer sees through the published declarations. The TypeScript compiler
15+
* resolves the re-export chain for us.
16+
*
17+
* Requires the fixture to be packed-and-installed first. CI runs this after
18+
* `typecheck-matrix.mjs`, which already packs and installs the tarball.
19+
*
20+
* Usage:
21+
* node snapshot-superdoc-legacy-exports.mjs --check
22+
* node snapshot-superdoc-legacy-exports.mjs --write
23+
*
24+
* `--write` regenerates the snapshots. Only run it when the change is
25+
* intentional and tied to SD-3175 (path-as-contract facade umbrella).
26+
*/
27+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
28+
import { fileURLToPath } from 'node:url';
29+
import { dirname, resolve, join } from 'node:path';
30+
import { createRequire } from 'node:module';
31+
32+
const HERE = dirname(fileURLToPath(import.meta.url));
33+
const SNAPSHOT_DIR = resolve(HERE, 'snapshots');
34+
const FIXTURE_SUPERDOC = resolve(HERE, 'node_modules', 'superdoc');
35+
36+
const args = process.argv.slice(2);
37+
const mode = args.includes('--write') ? 'write' : args.includes('--check') ? 'check' : null;
38+
if (!mode) {
39+
console.error('Usage: snapshot-superdoc-legacy-exports.mjs --write | --check');
40+
process.exit(2);
41+
}
42+
43+
if (!existsSync(FIXTURE_SUPERDOC)) {
44+
console.error('[SD-3176] superdoc is not installed in the fixture.');
45+
console.error('Run `node tests/consumer-typecheck/typecheck-matrix.mjs` first (it packs and installs the tarball),');
46+
console.error('or `npm install ../../packages/superdoc/superdoc.tgz --no-save` from tests/consumer-typecheck.');
47+
process.exit(1);
48+
}
49+
50+
// Use the typescript installed in the fixture so the version matches what
51+
// consumer-side tests already use.
52+
const req = createRequire(join(FIXTURE_SUPERDOC, 'package.json'));
53+
let ts;
54+
try {
55+
ts = req('typescript');
56+
} catch {
57+
const fixtureReq = createRequire(join(HERE, 'package.json'));
58+
ts = fixtureReq('typescript');
59+
}
60+
61+
const superdocPkg = JSON.parse(readFileSync(join(FIXTURE_SUPERDOC, 'package.json'), 'utf8'));
62+
63+
const SUBPATHS = [
64+
'./super-editor',
65+
'./converter',
66+
'./docx-zipper',
67+
'./file-zipper',
68+
];
69+
70+
function resolveTypesEntries(exportsValue) {
71+
// Returns { import: string|null, require: string|null }. Either can be set.
72+
// Snapshot is keyed on the `import` branch; `require` is a parity check.
73+
if (typeof exportsValue === 'string') return { import: exportsValue, require: null };
74+
if (exportsValue && typeof exportsValue === 'object') {
75+
if (typeof exportsValue.types === 'string') {
76+
return { import: exportsValue.types, require: null };
77+
}
78+
if (exportsValue.types && typeof exportsValue.types === 'object') {
79+
return {
80+
import: exportsValue.types.import ?? exportsValue.types.default ?? null,
81+
require: exportsValue.types.require ?? null,
82+
};
83+
}
84+
}
85+
return { import: null, require: null };
86+
}
87+
88+
function snapshotName(subpath) {
89+
return 'superdoc-' + subpath.replace(/^\.\//, '').replace(/\//g, '-') + '.txt';
90+
}
91+
92+
function listExportedNames(entryFile) {
93+
const program = ts.createProgram({
94+
rootNames: [entryFile],
95+
options: {
96+
moduleResolution: ts.ModuleResolutionKind.Bundler,
97+
module: ts.ModuleKind.ESNext,
98+
target: ts.ScriptTarget.ESNext,
99+
noEmit: true,
100+
skipLibCheck: true,
101+
allowJs: false,
102+
declaration: false,
103+
},
104+
});
105+
const checker = program.getTypeChecker();
106+
const source = program.getSourceFile(entryFile);
107+
if (!source) throw new Error('Cannot load source: ' + entryFile);
108+
const symbol = checker.getSymbolAtLocation(source) ?? source.symbol;
109+
if (!symbol) return [];
110+
const exports = checker.getExportsOfModule(symbol);
111+
return [...new Set(exports.map((e) => e.getName()))].sort();
112+
}
113+
114+
let failed = false;
115+
116+
for (const subpath of SUBPATHS) {
117+
const entries = resolveTypesEntries(superdocPkg.exports?.[subpath]);
118+
if (!entries.import) {
119+
console.error(`[SD-3176] No ESM types entry for ${subpath} in installed superdoc.`);
120+
failed = true;
121+
continue;
122+
}
123+
const importFile = resolve(FIXTURE_SUPERDOC, entries.import);
124+
if (!existsSync(importFile)) {
125+
console.error(`[SD-3176] Types file missing for ${subpath}: ${importFile}`);
126+
failed = true;
127+
continue;
128+
}
129+
130+
let names;
131+
try {
132+
names = listExportedNames(importFile);
133+
} catch (err) {
134+
console.error(`[SD-3176] Failed to enumerate ${subpath}: ${err.message}`);
135+
failed = true;
136+
continue;
137+
}
138+
139+
// CJS parity check: when the entry advertises both `types.import` and
140+
// `types.require`, both declaration files must enumerate the same names.
141+
// `ensure-types.cjs` generates the .d.cts from the .d.ts today, so this
142+
// is currently a no-op; it guards against a silent regression in the
143+
// generator producing a divergent CJS surface.
144+
if (entries.require) {
145+
const requireFile = resolve(FIXTURE_SUPERDOC, entries.require);
146+
if (!existsSync(requireFile)) {
147+
console.error(`[SD-3176] CJS types file missing for ${subpath}: ${requireFile}`);
148+
failed = true;
149+
continue;
150+
}
151+
let cjsNames;
152+
try {
153+
cjsNames = listExportedNames(requireFile);
154+
} catch (err) {
155+
console.error(`[SD-3176] Failed to enumerate CJS for ${subpath}: ${err.message}`);
156+
failed = true;
157+
continue;
158+
}
159+
const importSet = new Set(names);
160+
const requireSet = new Set(cjsNames);
161+
const onlyImport = [...importSet].filter((n) => !requireSet.has(n));
162+
const onlyRequire = [...requireSet].filter((n) => !importSet.has(n));
163+
if (onlyImport.length || onlyRequire.length) {
164+
console.error(`[SD-3176] ${subpath}: ESM/CJS declaration export sets differ.`);
165+
if (onlyImport.length) console.error(' import-only: ' + onlyImport.join(', '));
166+
if (onlyRequire.length) console.error(' require-only: ' + onlyRequire.join(', '));
167+
console.error(' Fix the CJS generator (packages/superdoc/scripts/ensure-types.cjs) so the two stay in sync.');
168+
failed = true;
169+
continue;
170+
}
171+
}
172+
173+
const current = names.join('\n') + '\n';
174+
const snapshotPath = join(SNAPSHOT_DIR, snapshotName(subpath));
175+
176+
if (mode === 'write') {
177+
writeFileSync(snapshotPath, current, 'utf8');
178+
console.log(`[SD-3176] Wrote ${snapshotPath} (${names.length} names)`);
179+
continue;
180+
}
181+
182+
let baseline;
183+
try {
184+
baseline = readFileSync(snapshotPath, 'utf8');
185+
} catch {
186+
console.error(`[SD-3176] Snapshot missing for ${subpath}: ${snapshotPath}`);
187+
console.error(' Run with --write to seed the baseline.');
188+
failed = true;
189+
continue;
190+
}
191+
192+
if (baseline === current) {
193+
console.log(`[SD-3176] ${subpath}: no growth (${names.length} names).`);
194+
continue;
195+
}
196+
197+
const baseSet = new Set(baseline.split('\n').filter(Boolean));
198+
const curSet = new Set(current.split('\n').filter(Boolean));
199+
const added = [...curSet].filter((k) => !baseSet.has(k));
200+
const removed = [...baseSet].filter((k) => !curSet.has(k));
201+
202+
console.error(`[SD-3176] superdoc${subpath.slice(1)} exports drifted:`);
203+
if (added.length) console.error(' added: ' + added.join(', '));
204+
if (removed.length) console.error(' removed: ' + removed.join(', '));
205+
failed = true;
206+
}
207+
208+
if (failed && mode === 'check') {
209+
console.error('');
210+
console.error('Per SD-3175 (path-as-contract facade), these legacy subpaths are no-growth.');
211+
console.error('If a change is intentional, regenerate the affected snapshot and link the PR');
212+
console.error('to SD-3175 or a child ticket for reviewer sign-off:');
213+
console.error(' node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --write');
214+
process.exit(1);
215+
}
216+
217+
process.exit(0);

0 commit comments

Comments
 (0)