|
94 | 94 | * block almost never imports it (measured: 8 of 124 import anything at all). The |
95 | 95 | * gate therefore prepends ONE line: |
96 | 96 | * |
97 | | - * import { SYMBOL } from 'PACKAGE'; |
| 97 | + * import { SYMBOL } from 'SPECIFIER'; |
98 | 98 | * |
99 | 99 | * and only when all three hold, each re-checked per run: |
100 | 100 | * |
101 | 101 | * - the block's text references SYMBOL; |
102 | 102 | * - 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: |
106 | 106 | * |
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. |
114 | 149 | * |
115 | 150 | * Prepended, not appended, because an `import` must precede the code that uses |
116 | 151 | * it; the printed line numbers therefore carry an offset, which `formatDiagnostic` |
@@ -995,55 +1030,112 @@ export const UNGATED_EXAMPLES = { |
995 | 1030 | // ── The run ────────────────────────────────────────────────────────────────── |
996 | 1031 |
|
997 | 1032 | /** |
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. |
999 | 1040 | * |
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. |
1005 | 1045 | * |
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} |
1008 | 1049 | */ |
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 | + |
1027 | 1107 | 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}`); |
1029 | 1110 | 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); |
1031 | 1123 | } |
1032 | | - return missing; |
| 1124 | + return resolved; |
1033 | 1125 | } |
1034 | 1126 |
|
1035 | 1127 | /** |
1036 | 1128 | * The ONE transformation, applied per block. See the header. |
1037 | 1129 | * |
1038 | 1130 | * @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 |
1040 | 1132 | */ |
1041 | | -export function preludeFor(block, missing) { |
| 1133 | +export function preludeFor(block, injectableFrom) { |
1042 | 1134 | const references = new RegExp(`\\b${block.symbol}\\b`).test(block.body); |
1043 | 1135 | 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` |
1047 | 1139 | : ''; |
1048 | 1140 | } |
1049 | 1141 |
|
@@ -1213,15 +1305,20 @@ function main() { |
1213 | 1305 | return EXIT_CODES.couldNotRun; |
1214 | 1306 | } |
1215 | 1307 |
|
1216 | | - const missing = probeExportedSymbols({ |
| 1308 | + const injectableFrom = probeInjectionSpecifiers({ |
1217 | 1309 | root: repoRoot, |
1218 | 1310 | blocks: census.blocks, |
1219 | 1311 | paths: state.paths, |
1220 | 1312 | declaredSpecifiers: state.declaredSpecifiers, |
1221 | 1313 | }); |
| 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 | + ); |
1222 | 1319 |
|
1223 | 1320 | const compiled = census.blocks.map((block) => { |
1224 | | - const prelude = preludeFor(block, missing); |
| 1321 | + const prelude = preludeFor(block, injectableFrom); |
1225 | 1322 | return { |
1226 | 1323 | doc: block.file, |
1227 | 1324 | // `formatDiagnostic` prints `fenceLine + line`. The prelude shifts the |
@@ -1306,9 +1403,16 @@ function main() { |
1306 | 1403 | console.log(` src leaks ${run.srcLeaks.length}`); |
1307 | 1404 | console.log( |
1308 | 1405 | ` 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`, |
1310 | 1408 | ); |
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}`); |
1312 | 1416 | console.log(''); |
1313 | 1417 |
|
1314 | 1418 | const clean = results.length - codesOf.size; |
|
0 commit comments