Skip to content

Commit 7427f69

Browse files
os-justinclaude
andauthored
fix(scripts): probe a second injection candidate in check:doc-examples (#8750)
`check:doc-examples` was red on `main`. The `@example` on `stripImportedDefaults` (packages/types/src/zod/imported-defaults.ts:318) spells the symbol's own name, and the gate's ONE transformation could not supply it: the injection probe asked the package's ROOT specifier and only that one, and `stripImportedDefaults` is deliberately package-internal (objectui#8317 puts the strip at the import boundary, not at a consumer). The block was therefore judged with the name unbound -> TS2304. The gate's own justification for the injection is SCOPE ("read in the IDE beside the declaration it documents"), not publication. Candidate 1 models that as "importable from the public entry", which answers NO for every symbol a package exports to its own modules and nothing else, leaving only two bad routes: export it (widening a published API for a docs gate) or write a ledger row (converting a checked example into an unchecked one). So the probe now tries a SECOND candidate, first-that-imports-wins: the built declaration of the symbol's own source file (`packages/NAME/dist/a/b.js`), offered only when that per-file twin is on disk and still probed in the same program. A bundling build (tsup, rolldown) emits no twin, so the conservative "not injected" answer stands there unchanged. Measured, same tree, built closure: before 125 block(s) - 34 compile, 91 fail, 90 declared (90 rows), exit 1 after 125 block(s) - 35 compile, 90 fail, 90 declared (90 rows), exit 0 The compiled tier grew and the ledger did not: this is a repair, not a declaration. The withheld list drops 4 -> 1 and `MetadataCache`'s row still declares the TS2304 its example produces for a different free name. Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3fbdd4a commit 7427f69

3 files changed

Lines changed: 221 additions & 55 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
---
3+
4+
CI tooling only, no published package source changed: `check:doc-examples` now
5+
probes a second injection candidate — the built declaration of the documented
6+
symbol's own module — so a deliberately package-internal symbol's `@example` can
7+
compile without widening any published surface (objectui#8743).

‎scripts/__tests__/check-doc-example-types.test.ts‎

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { afterAll, describe, expect, it } from 'vitest';
22
import fs from 'node:fs';
3+
import os from 'node:os';
34
import path from 'node:path';
45
import { fileURLToPath } from 'node:url';
56

@@ -18,6 +19,7 @@ import {
1819
funnelLines,
1920
judge,
2021
ledgerKey,
22+
builtTwinSpecifier,
2123
listExampleSources,
2224
preludeFor,
2325
} from '../check-doc-example-types.mjs';
@@ -130,23 +132,76 @@ describe('the exported owner is read from the AST, never from the text', () => {
130132

131133
describe('the documented symbol import is injected only when all three conditions hold', () => {
132134
const block = { symbol: 'useThing', package: '@object-ui/x', body: 'const r = useThing();' };
135+
const fromRoot = new Map([['@object-ui/x useThing', '@object-ui/x']]);
133136

134137
it('injects when the block references the symbol and does not import it', () => {
135-
expect(preludeFor(block, new Set())).toBe("import { useThing } from '@object-ui/x';\n");
138+
expect(preludeFor(block, fromRoot)).toBe("import { useThing } from '@object-ui/x';\n");
136139
});
137140

138141
it('does NOT inject when the block never references the symbol', () => {
139-
expect(preludeFor({ ...block, body: 'const r = 1;' }, new Set())).toBe('');
142+
expect(preludeFor({ ...block, body: 'const r = 1;' }, fromRoot)).toBe('');
140143
});
141144

142145
it('does NOT inject when the block already imports the symbol itself', () => {
143146
expect(
144-
preludeFor({ ...block, body: "import { useThing } from 'somewhere';\nuseThing();" }, new Set()),
147+
preludeFor({ ...block, body: "import { useThing } from 'somewhere';\nuseThing();" }, fromRoot),
145148
).toBe('');
146149
});
147150

148-
it('does NOT inject when the package entry does not export the symbol — the gate never blames an example for this transformation', () => {
149-
expect(preludeFor(block, new Set(['@object-ui/x useThing']))).toBe('');
151+
it('does NOT inject when NO probed specifier could import the symbol — the gate never blames an example for this transformation', () => {
152+
expect(preludeFor(block, new Map())).toBe('');
153+
});
154+
155+
it('injects the specifier the probe RESOLVED, not the package name — objectui#8743', () => {
156+
const twin = '/repo/packages/x/dist/internal/thing.js';
157+
expect(preludeFor(block, new Map([['@object-ui/x useThing', twin]]))).toBe(
158+
`import { useThing } from '${twin}';\n`,
159+
);
160+
});
161+
});
162+
163+
// ── instrument: the second injection candidate (objectui#8743) ───────────────
164+
165+
describe("candidate 2: the documented symbol's own built declaration", () => {
166+
const roots: string[] = [];
167+
const fixture = (files: Record<string, string>) => {
168+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-example-twin-'));
169+
roots.push(root);
170+
for (const [rel, body] of Object.entries(files)) {
171+
const abs = path.join(root, rel);
172+
fs.mkdirSync(path.dirname(abs), { recursive: true });
173+
fs.writeFileSync(abs, body);
174+
}
175+
return root;
176+
};
177+
afterAll(() => {
178+
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
179+
});
180+
181+
it('is the dist twin of the source file when that per-file declaration exists', () => {
182+
const root = fixture({ 'packages/types/dist/zod/imported-defaults.d.ts': 'export {};\n' });
183+
expect(builtTwinSpecifier('packages/types/src/zod/imported-defaults.ts', root)).toBe(
184+
path.join(root, 'packages/types/dist/zod/imported-defaults.js'),
185+
);
186+
});
187+
188+
it('is null when the build BUNDLES its declarations, so there is no per-file twin', () => {
189+
const root = fixture({ 'packages/data-objectstack/dist/index.d.ts': 'export {};\n' });
190+
expect(builtTwinSpecifier('packages/data-objectstack/src/cache/MetadataCache.ts', root)).toBe(
191+
null,
192+
);
193+
});
194+
195+
it('is null for a path that is not `packages/NAME/src/...`', () => {
196+
const root = fixture({ 'apps/console/dist/main.d.ts': 'export {};\n' });
197+
expect(builtTwinSpecifier('apps/console/src/main.ts', root)).toBe(null);
198+
});
199+
200+
it('is an ABSOLUTE specifier, which THE BOUND never refuses', () => {
201+
const root = fixture({ 'packages/types/dist/a.d.ts': 'export {};\n' });
202+
const twin = builtTwinSpecifier('packages/types/src/a.ts', root);
203+
expect(twin).not.toBe(null);
204+
expect(path.isAbsolute(twin as string)).toBe(true);
150205
});
151206
});
152207

‎scripts/check-doc-example-types.mjs‎

Lines changed: 153 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -94,23 +94,58 @@
9494
* block almost never imports it (measured: 8 of 124 import anything at all). The
9595
* gate therefore prepends ONE line:
9696
*
97-
* import { SYMBOL } from 'PACKAGE';
97+
* import { SYMBOL } from 'SPECIFIER';
9898
*
9999
* and only when all three hold, each re-checked per run:
100100
*
101101
* - the block's text references SYMBOL;
102102
* - the block does not already import SYMBOL itself;
103-
* - PACKAGE's BUILT entry really exports SYMBOL — probed in the same program,
104-
* never assumed. Measured: 111 of 114 documented symbols are on their
105-
* package's public entry; the 3 that are not are reported by name.
103+
* - some probed SPECIFIER really exports SYMBOL — compiled in the same
104+
* program, never assumed. TWO candidates are probed, in this order, and the
105+
* first one that imports wins:
106106
*
107-
* ⚠️ The probe asks the package's ROOT specifier and only that one, so a symbol
108-
* published under a SUBPATH reads as "not on a public entry" here. That is a
109-
* deliberately conservative answer — it withholds the injection rather than
110-
* guessing a subpath — but it means the printed list is "symbols this gate did
111-
* not inject", NOT a list of defects. On this corpus 2 of the 3 are subpath
112-
* exports (`@object-ui/types/zod`) and both blocks compile anyway; the third,
113-
* `MetadataCache`, is genuinely absent from its package's only export.
107+
* 1. the package's ROOT specifier, e.g. `@object-ui/types`;
108+
* 2. the BUILT declaration of the symbol's OWN source file — the `dist`
109+
* twin of `packages/NAME/src/a/b.ts`, offered only when that twin is
110+
* on disk.
111+
*
112+
* The count reached by each candidate is printed every run, so both stay derived.
113+
*
114+
* ### Why candidate 2 exists, and why it widens nothing (objectui#8743)
115+
*
116+
* The justification above is SCOPE, not publication. Candidate 1 alone models
117+
* scope as "importable from the package's public entry", which is a publication
118+
* test, and it answers NO for every symbol a package exports to its own modules
119+
* and to nothing else. Such a block is then judged WITHOUT the import it needs,
120+
* so any example that spells its own symbol's name is TS2304 — not because the
121+
* example is wrong, but because this file could not name the symbol.
122+
*
123+
* `stripImportedDefaults` landed exactly there and reddened `main`. It is
124+
* deliberately package-internal — objectui#8317's design is that the strip
125+
* happens at the import boundary, not that consumers call it — so with candidate
126+
* 1 as the only route the remaining two are: EXPORT it, widening a published
127+
* surface (and moving `@object-ui/types`' public API) for a docs gate; or write
128+
* a ledger row, converting a checked example into an unchecked one. Both are
129+
* worse than the defect. Candidate 2 is the third route: the declaring module IS
130+
* an internal symbol's scope, and its built `.d.ts` is the same artifact tier
131+
* candidate 1 resolves to. Nothing is exported, nothing new is published, and
132+
* the example stays COMPILED.
133+
*
134+
* ⛔ Candidate 2 is not a licence to reference anything: it names the symbol the
135+
* block documents and nothing else, which is the same one-line bound candidate 1
136+
* has always had. It is not the `declare var NAME: any` pass refused below —
137+
* the types come from the real built declaration, so the call is judged against
138+
* the shipped signature.
139+
*
140+
* ⚠️ It is still PROBED, never assumed, and a bundling build has no twin to
141+
* probe: `tsup`/rolldown emit one `dist/index.d.ts` and no per-file declaration,
142+
* so the candidate is absent and the conservative answer stands. That is why the
143+
* printed list remains "symbols this gate did not inject", NOT a list of defects.
144+
*
145+
* ⚠️ Neither candidate guesses a SUBPATH. A symbol published only under one
146+
* (`@object-ui/types/zod`) is not reached by candidate 1 and does not need to
147+
* be — those blocks import themselves, which is what a reader copying them does,
148+
* and `alreadyImported` then withholds the prelude anyway.
114149
*
115150
* Prepended, not appended, because an `import` must precede the code that uses
116151
* it; the printed line numbers therefore carry an offset, which `formatDiagnostic`
@@ -995,55 +1030,112 @@ export const UNGATED_EXAMPLES = {
9951030
// ── The run ──────────────────────────────────────────────────────────────────
9961031

9971032
/**
998-
* Which documented symbols the built package entries really export.
1033+
* The `dist` twin of one source file, as an ABSOLUTE specifier, or `null`.
1034+
*
1035+
* `packages/NAME/src/a/b.ts` -> `ROOT/packages/NAME/dist/a/b.js`. Absolute, not
1036+
* relative: the virtual directory the sibling compiles blocks in is that file's
1037+
* private detail, and an absolute specifier is one THE BOUND never refuses
1038+
* (`resolvesOnlyThroughRootManifest` exempts specifiers starting with `.` or
1039+
* `/`), so this candidate can never be mistaken for a bare package import.
9991040
*
1000-
* Probed, never assumed: one throwaway module per `PACKAGE SYMBOL` pair, handed
1001-
* to the same `compileSnippets()` the blocks go through, so the answer comes
1002-
* from the same resolution the verdict does. A pair that cannot be imported is
1003-
* reported by name and its block is judged WITHOUT the injected import, so the
1004-
* gate never blames an example for this file's own transformation.
1041+
* Existence is checked on the `.d.ts` — that is what the program reads, since
1042+
* `moduleResolution: Bundler` maps the `.js` specifier onto it — so a package
1043+
* whose build BUNDLES its declarations has no twin here and is declined. This is
1044+
* a cheap pre-filter, not the answer: the probe below is the authority.
10051045
*
1006-
* @param {{ root: string, blocks: {package: string, symbol: string}[], paths: object, declaredSpecifiers: string[] }} options
1007-
* @returns {Set<string>} the `PACKAGE SYMBOL` pairs that are NOT on a public entry
1046+
* @param {string} file repo-relative source path of the documented symbol
1047+
* @param {string} root
1048+
* @returns {string | null}
10081049
*/
1009-
export function probeExportedSymbols({ root, blocks, paths, declaredSpecifiers }) {
1010-
const pairs = [...new Set(blocks.map((b) => `${b.package} ${b.symbol}`))].sort();
1011-
const probes = pairs.map((pair, index) => {
1012-
const [pkg, symbol] = pair.split(' ');
1013-
return {
1014-
doc: `probe/${pkg}`,
1015-
fenceLine: index,
1016-
language: 'ts',
1017-
quoteDepth: 0,
1018-
fragmentReason: null,
1019-
// `[typeof S]` rather than `typeof S`: a tuple accepts a value position for
1020-
// a name that is only a type, so this probe answers "is it importable"
1021-
// without also asking "is it a value", which is a different question.
1022-
body: `import { ${symbol} } from '${pkg}';\nexport type P = [typeof ${symbol}];\n`,
1023-
pair,
1024-
};
1025-
});
1026-
if (probes.length === 0) return new Set();
1050+
export function builtTwinSpecifier(file, root = repoRoot) {
1051+
const parts = file.split('/');
1052+
if (parts.length < 4 || parts[0] !== PACKAGES_DIR || parts[2] !== SOURCE_SUBDIR) return null;
1053+
const stem = parts.slice(3).join('/').replace(/\.tsx?$/, '');
1054+
const distDir = join(root, PACKAGES_DIR, parts[1], 'dist');
1055+
if (!existsSync(join(distDir, `${stem}.d.ts`))) return null;
1056+
return join(distDir, `${stem}.js`);
1057+
}
1058+
1059+
/**
1060+
* Which specifier, if any, brings each documented symbol into scope.
1061+
*
1062+
* Probed, never assumed: one throwaway module per `PAIR SPECIFIER` candidate,
1063+
* handed to the same `compileSnippets()` the blocks go through, so the answer
1064+
* comes from the same resolution the verdict does. Candidates are tried in the
1065+
* order `injectionCandidates` lists them and the FIRST that imports wins; a pair
1066+
* no candidate can import is reported by name and its block is judged WITHOUT an
1067+
* injected import, so the gate never blames an example for this transformation.
1068+
*
1069+
* @param {{ root: string, blocks: {package: string, symbol: string, file: string}[], paths: object, declaredSpecifiers: string[] }} options
1070+
* @returns {Map<string, string>} `PACKAGE SYMBOL` -> the specifier that imported it
1071+
*/
1072+
export function probeInjectionSpecifiers({ root, blocks, paths, declaredSpecifiers }) {
1073+
/** @type {Map<string, string[]>} */
1074+
const candidatesOf = new Map();
1075+
for (const block of [...blocks].sort((a, b) =>
1076+
`${a.package} ${a.symbol}`.localeCompare(`${b.package} ${b.symbol}`),
1077+
)) {
1078+
const pair = `${block.package} ${block.symbol}`;
1079+
if (!candidatesOf.has(pair)) candidatesOf.set(pair, []);
1080+
const list = candidatesOf.get(pair);
1081+
for (const candidate of [block.package, builtTwinSpecifier(block.file, root)]) {
1082+
if (candidate && !list.includes(candidate)) list.push(candidate);
1083+
}
1084+
}
1085+
1086+
const probes = [];
1087+
for (const [pair, candidates] of candidatesOf) {
1088+
const symbol = pair.split(' ')[1];
1089+
for (const specifier of candidates) {
1090+
probes.push({
1091+
doc: `probe/${pair}`,
1092+
fenceLine: probes.length,
1093+
language: 'ts',
1094+
quoteDepth: 0,
1095+
fragmentReason: null,
1096+
// `[typeof S]` rather than `typeof S`: a tuple accepts a value position for
1097+
// a name that is only a type, so this probe answers "is it importable"
1098+
// without also asking "is it a value", which is a different question.
1099+
body: `import { ${symbol} } from '${specifier}';\nexport type P = [typeof ${symbol}];\n`,
1100+
pair,
1101+
specifier,
1102+
});
1103+
}
1104+
}
1105+
if (probes.length === 0) return new Map();
1106+
10271107
const run = compileSnippets({ root, compiled: probes, paths, declaredSpecifiers });
1028-
const missing = new Set();
1108+
const failed = new Set();
1109+
const fail = (block) => failed.add(`${block.pair}|${block.specifier}`);
10291110
for (const { block, diagnostics } of run.semanticFailures) {
1030-
if (diagnostics.some((d) => d.code === 2305 || d.code === 2307)) missing.add(block.pair);
1111+
if (diagnostics.some((d) => d.code === 2305 || d.code === 2307)) fail(block);
1112+
}
1113+
// A candidate this harness could not even parse or was refused by THE BOUND is
1114+
// not an importable specifier either; counting only the semantic arm would let
1115+
// one through on a technicality.
1116+
for (const { block } of run.parseFailures) fail(block);
1117+
for (const { block } of run.boundFailures) fail(block);
1118+
1119+
const resolved = new Map();
1120+
for (const probe of probes) {
1121+
if (resolved.has(probe.pair)) continue;
1122+
if (!failed.has(`${probe.pair}|${probe.specifier}`)) resolved.set(probe.pair, probe.specifier);
10311123
}
1032-
return missing;
1124+
return resolved;
10331125
}
10341126

10351127
/**
10361128
* The ONE transformation, applied per block. See the header.
10371129
*
10381130
* @param {{ symbol: string, package: string, body: string }} block
1039-
* @param {Set<string>} missing pairs the export probe could not import
1131+
* @param {Map<string, string>} injectableFrom pair -> the specifier that imports it
10401132
*/
1041-
export function preludeFor(block, missing) {
1133+
export function preludeFor(block, injectableFrom) {
10421134
const references = new RegExp(`\\b${block.symbol}\\b`).test(block.body);
10431135
const alreadyImported = new RegExp(`import[^;]*\\b${block.symbol}\\b[^;]*from`).test(block.body);
1044-
const onPublicEntry = !missing.has(`${block.package} ${block.symbol}`);
1045-
return references && !alreadyImported && onPublicEntry
1046-
? `import { ${block.symbol} } from '${block.package}';\n`
1136+
const specifier = injectableFrom.get(`${block.package} ${block.symbol}`);
1137+
return references && !alreadyImported && specifier
1138+
? `import { ${block.symbol} } from '${specifier}';\n`
10471139
: '';
10481140
}
10491141

@@ -1213,15 +1305,20 @@ function main() {
12131305
return EXIT_CODES.couldNotRun;
12141306
}
12151307

1216-
const missing = probeExportedSymbols({
1308+
const injectableFrom = probeInjectionSpecifiers({
12171309
root: repoRoot,
12181310
blocks: census.blocks,
12191311
paths: state.paths,
12201312
declaredSpecifiers: state.declaredSpecifiers,
12211313
});
1314+
const pairs = [...new Set(census.blocks.map((b) => `${b.package} ${b.symbol}`))].sort();
1315+
const withheld = pairs.filter((pair) => !injectableFrom.has(pair));
1316+
const viaTwin = pairs.filter(
1317+
(pair) => injectableFrom.has(pair) && injectableFrom.get(pair) !== pair.split(' ')[0],
1318+
);
12221319

12231320
const compiled = census.blocks.map((block) => {
1224-
const prelude = preludeFor(block, missing);
1321+
const prelude = preludeFor(block, injectableFrom);
12251322
return {
12261323
doc: block.file,
12271324
// `formatDiagnostic` prints `fenceLine + line`. The prelude shifts the
@@ -1306,9 +1403,16 @@ function main() {
13061403
console.log(` src leaks ${run.srcLeaks.length}`);
13071404
console.log(
13081405
` injection ${compiled.filter((b) => b.injected).length} of ${compiled.length} block(s) received the documented symbol's import; ` +
1309-
`${missing.size} documented symbol(s) are NOT on a public entry`,
1406+
`${pairs.length - withheld.length} of ${pairs.length} documented symbol(s) are importable, ` +
1407+
`${viaTwin.length} of them only through their module's built declaration`,
13101408
);
1311-
for (const pair of [...missing].sort()) console.log(` not on a public entry: ${pair}`);
1409+
for (const pair of viaTwin) {
1410+
console.log(
1411+
` via its module's built declaration: ${pair} ` +
1412+
`(${relative(repoRoot, injectableFrom.get(pair)).split(sep).join('/')})`,
1413+
);
1414+
}
1415+
for (const pair of withheld) console.log(` NOT importable from any probed specifier: ${pair}`);
13121416
console.log('');
13131417

13141418
const clean = results.length - codesOf.size;

0 commit comments

Comments
 (0)