This repository was archived by the owner on Sep 9, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(compiler): 合并方言扩展名/类型声明/浏览器资源清单/错误码/二进制穿透/发布契约六项 #195
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
b605366
feat(compiler): 随包发类型声明,下游不用再自己写 ambient 声明
lbb00 6079796
fix(compiler): 让类型 fixture 真的检查发出去的声明
lbb00 a03c4eb
feat(compiler): 发布浏览器静态资源清单,构建期挡住产物漂移
lbb00 041e8f2
fix(compiler): 静态资源自检认目录、认所有 import 类型,并和 exports 对账
lbb00 e7c7fba
feat(compiler): 给 pool 的失败一个稳定的错误码
lbb00 5896ec9
fix(compiler): 按 review 意见收紧错误码的边界
lbb00 8f78ae2
fix(compiler): node:fs 的 EACCES 不再当成编译器的错误码漏出去
lbb00 0d5e7fa
feat(compiler): 让图片等二进制资源原样穿过编译池
lbb00 31e6916
fix(compiler): 按 review 意见补上池路径与 BOM 的字节保真
lbb00 82daa05
fix(compiler): .wasm 等二进制产物按扩展名直接给字节,不再靠内容猜
lbb00 678edeb
feat(compiler): 方言扩展名有了一份权威声明,宿主不用再各抄一遍
lbb00 ba26690
chore(ci): 核对发布产物,并停止发布测试文件
lbb00 9501fa5
fix(ci): 发布契约检查前先构建,并按发布后的清单核对
lbb00 d09b5c4
fix(ci): 单独构建 devtools 的发布入口,别让契约检查在 CI 上必挂
lbb00 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| #!/usr/bin/env node | ||
|
|
||
| // 两类问题只在包发布出去之后才暴露,本仓自己的构建和测试都看不见: | ||
| // | ||
| // 1. exports/main/types/bin 指向的文件没进 tarball(`files` 少写一个目录就会 | ||
| // 这样)。安装方一 import 就是 ERR_MODULE_NOT_FOUND,而本仓 workspace 里 | ||
| // 同一个 import 一直是好的——它读的是源码目录,不是 tarball。 | ||
| // 2. tarball 里混进测试文件。它们不是 API 的一部分,却出现在安装方的 | ||
| // node_modules 里,占体积、也让人以为可以 import。 | ||
| // | ||
| // 所以这里拿 `npm pack --dry-run` 的真实打包清单来核对,而不是读 `files` 字段 | ||
| // 猜。--ignore-scripts:只要清单,不重跑各包的 prepack 构建。 | ||
|
|
||
| import { execFileSync } from 'node:child_process' | ||
| import { existsSync, readFileSync } from 'node:fs' | ||
| import { join, resolve } from 'node:path' | ||
| import { fileURLToPath } from 'node:url' | ||
| import { NPM_PACKAGES } from './npm-packages.js' | ||
|
|
||
| // exports 的值可以是字符串,也可以嵌套条件对象(types/require/default…), | ||
| // 两种都要收集到叶子上的相对路径。 | ||
| function collectTargets(node, out) { | ||
| if (typeof node === 'string') { | ||
| if (node.startsWith('./')) out.push(node) | ||
| return out | ||
| } | ||
| if (node && typeof node === 'object') { | ||
| for (const value of Object.values(node)) collectTargets(value, out) | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| const normalize = (target) => (target.startsWith('./') ? target : `./${target}`) | ||
|
|
||
| // typesVersions 的叶子是一个路径数组,写法上 './dist/x.d.ts' 和 'dist/x.d.ts' 都常见, | ||
| // 所以这里不像 exports 那样按 './' 前缀筛,而是把每个字符串都当路径。 | ||
| function collectTypesVersionTargets(node, out) { | ||
| if (typeof node === 'string') { | ||
| out.push(normalize(node)) | ||
| return out | ||
| } | ||
| if (node && typeof node === 'object') { | ||
| for (const value of Object.values(node)) collectTypesVersionTargets(value, out) | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| // pnpm publish 会用 publishConfig 里的同名字段覆盖发布出去的 package.json——design | ||
| // 和 view-anchor 就靠这个:源码里 main 指向 ./src/index.ts,发到 npm 上指向 | ||
| // ./dist/index.js。安装方看到的是覆盖之后的清单,所以核对也必须按覆盖后的来,否则 | ||
| // 恰恰是这两个最需要检查的包被按源码字段放行了。 | ||
| const OVERLAID_FIELDS = ['main', 'browser', 'types', 'typings', 'exports', 'bin', 'imports', 'typesVersions'] | ||
|
|
||
| // publishConfig 还能覆盖 files、改发布根目录(directory)。这两个 pnpm 认、`npm pack` | ||
| // 不认,打包清单会跟真正发出去的 tarball 对不上——而这个检查全靠打包清单。本仓现在 | ||
| // 没有包这么写;真有人加了,宁可在这里明确报错,也不要给出一个悄悄失真的结论。 | ||
| const UNMODELLED_PUBLISH_CONFIG = ['files', 'directory'] | ||
|
|
||
| export function publishedManifest(pkgJson) { | ||
| const published = { ...pkgJson } | ||
| for (const field of OVERLAID_FIELDS) { | ||
| if (pkgJson.publishConfig && field in pkgJson.publishConfig) published[field] = pkgJson.publishConfig[field] | ||
| } | ||
| return published | ||
| } | ||
|
|
||
| /** | ||
| * 这个包的 publishConfig 里有没有本脚本模型不了的字段。 | ||
| * @param {Record<string, any>} pkgJson | ||
| * @returns {string[]} | ||
| */ | ||
| export function unmodelledPublishConfig(pkgJson) { | ||
| return UNMODELLED_PUBLISH_CONFIG.filter((field) => pkgJson.publishConfig && field in pkgJson.publishConfig) | ||
| } | ||
|
|
||
| /** | ||
| * 一个包声明的、安装方能直接解析到的所有文件路径。 | ||
| * | ||
| * @param {Record<string, any>} sourcePkgJson 仓库里的 package.json(publishConfig 覆盖在内部处理) | ||
| * @returns {string[]} 形如 './dist/index.js',去重 | ||
| */ | ||
| export function entryTargets(sourcePkgJson) { | ||
| const pkgJson = publishedManifest(sourcePkgJson) | ||
| const targets = collectTargets(pkgJson.exports, []) | ||
| // imports 里的 '#internal' 只有包自己 import 得到,但同样要求文件真的发出去; | ||
| // 值可能是外部包名(不带 './'),collectTargets 已经把这类滤掉了。 | ||
| collectTargets(pkgJson.imports, targets) | ||
| if (typeof pkgJson.main === 'string') targets.push(normalize(pkgJson.main)) | ||
| // browser 的字符串形式是入口;对象形式是"把 A 换成 B"的替换表,只有本地相对路径的 | ||
| // 那一侧需要真的发出去,值写成 false(禁用某个模块)或包名的都不是本包的文件。 | ||
| if (typeof pkgJson.browser === 'string') targets.push(normalize(pkgJson.browser)) | ||
| else if (pkgJson.browser && typeof pkgJson.browser === 'object') collectTargets(pkgJson.browser, targets) | ||
| if (typeof pkgJson.types === 'string') targets.push(normalize(pkgJson.types)) | ||
| if (typeof pkgJson.typings === 'string') targets.push(normalize(pkgJson.typings)) | ||
| collectTypesVersionTargets(pkgJson.typesVersions, targets) | ||
| const bin = typeof pkgJson.bin === 'string' ? { [String(pkgJson.name)]: pkgJson.bin } : pkgJson.bin | ||
| for (const value of Object.values(bin || {})) { | ||
| if (typeof value === 'string') targets.push(normalize(value)) | ||
| } | ||
| return [...new Set(targets)] | ||
| } | ||
|
|
||
| // subpath pattern('./dist/shared/*.js')匹配的是一组文件,只要求至少命中一个。 | ||
| function patternToRegExp(target) { | ||
| const literals = target.split('*').map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')) | ||
| return new RegExp(`^${literals.join('.+')}$`) | ||
| } | ||
|
|
||
| // 三种写法都算测试文件:测试目录下的、`x.test.ts` / `x.spec.ts`、以及 `test-x.js` | ||
| // 这种以 test- 打头的脚本(packages/compiler 的 scripts/ 里有二十来个)。最后一种 | ||
| // 有例外——包可能故意把测试辅助工具当 API 发出去,所以下面对"被声明为入口"的文件 | ||
| // 放行。 | ||
| const TEST_FILE = /(^|\/)(test|tests|__tests__|__mocks__|__snapshots__|fixture|fixtures|test-fixtures|types-fixture)\/|(^|\/)test-[^/]*\.[cm]?[jt]sx?$|\.(test|spec)\.[^/]+$/ | ||
|
|
||
| /** | ||
| * 核对一个包的 package.json 与它真实的打包清单。 | ||
| * | ||
| * @param {Record<string, any>} pkgJson | ||
| * @param {string[]} packedPaths npm pack 报告的 tarball 内相对路径 | ||
| * @returns {string[]} 每行一个问题;契约成立时为空 | ||
| */ | ||
| export function checkPackedFiles(pkgJson, packedPaths) { | ||
| const packed = packedPaths.map(normalize) | ||
| const packedSet = new Set(packed) | ||
| const targets = entryTargets(pkgJson) | ||
| const declared = new Set(targets) | ||
| const problems = [] | ||
|
|
||
| for (const target of targets) { | ||
| if (target.includes('*')) { | ||
| const pattern = patternToRegExp(target) | ||
| if (!packed.some((file) => pattern.test(file))) { | ||
| problems.push(`${target} 是 exports 里的 subpath pattern,但 tarball 里没有任何文件匹配它`) | ||
| } | ||
| continue | ||
| } | ||
| if (!packedSet.has(target)) { | ||
| problems.push(`${target} 被 package.json 声明为入口,但不在 tarball 里(检查 files 字段)`) | ||
| } | ||
| } | ||
|
|
||
| const tests = packedPaths.filter((file) => TEST_FILE.test(file) && !declared.has(normalize(file))) | ||
| if (tests.length > 0) { | ||
| const shown = tests.slice(0, 5).join(', ') | ||
| problems.push(`tarball 里有 ${tests.length} 个测试文件,用 files 的 "!" 规则排除掉:${shown}${tests.length > 5 ? ' …' : ''}`) | ||
| } | ||
|
|
||
| return problems | ||
| } | ||
|
|
||
| /** | ||
| * 声明为入口、但磁盘上找不到的文件。空数组就是"构建产物齐了",可以按 tarball 核对; | ||
| * 非空说明这个包没构建(或构建没产出全),此时 tarball 里当然什么都没有,直接按 tarball | ||
| * 报会刷出一长串"没进 tarball",把"你忘了构建"埋在噪音里。subpath pattern 匹配的是一组 | ||
| * 文件,不在这里判断。 | ||
| * | ||
| * @param {Record<string, any>} pkgJson | ||
| * @param {string} pkgDir 包目录的绝对路径 | ||
| * @returns {string[]} | ||
| */ | ||
| export function missingEntryFiles(pkgJson, pkgDir) { | ||
| return entryTargets(pkgJson) | ||
| .filter((target) => !target.includes('*')) | ||
| .filter((target) => !existsSync(join(pkgDir, target.replace(/^\.\//, '')))) | ||
| } | ||
|
|
||
| function packedPathsOf(dir) { | ||
| // prepack 之类的脚本会往 stdout 写构建日志,混在 --json 前面。 | ||
| const raw = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { | ||
| cwd: dir, | ||
| encoding: 'utf8', | ||
| stdio: ['ignore', 'pipe', 'inherit'], | ||
| maxBuffer: 256 * 1024 * 1024, | ||
| }) | ||
| const start = raw.indexOf('[') | ||
| if (start < 0) throw new Error(`npm pack --json 没有输出 JSON:\n${raw.slice(0, 500)}`) | ||
| return JSON.parse(raw.slice(start))[0].files.map((file) => file.path) | ||
| } | ||
|
|
||
| // 直接跑这个文件时才执行检查;被 test 文件 import 时不执行。字符串拼 `file://` | ||
| // 在 Windows(`file:///C:/…`)和路径里有空格/中文(URL 转义)时都对不上,所以两边 | ||
| // 都换算成本地路径再比。 | ||
| const entryPath = process.argv[1] ? resolve(process.argv[1]) : '' | ||
| if (fileURLToPath(import.meta.url) === entryPath) { | ||
| let failed = false | ||
| for (const { name, dir } of NPM_PACKAGES) { | ||
| const abs = join(process.cwd(), dir) | ||
| const pkgJson = JSON.parse(readFileSync(join(abs, 'package.json'), 'utf8')) | ||
| const unmodelled = unmodelledPublishConfig(pkgJson) | ||
| if (unmodelled.length > 0) { | ||
| failed = true | ||
| console.error(`❌ ${name}`) | ||
| console.error(` publishConfig 里的 ${unmodelled.join('、')} 只有 pnpm 认,\`npm pack\` 不认,本检查的打包清单会跟真正发出去的 tarball 对不上。要么别这么写,要么先把本脚本改成读 pnpm 打出来的真 tarball。`) | ||
| continue | ||
| } | ||
| const missing = missingEntryFiles(pkgJson, abs) | ||
| if (missing.length > 0) { | ||
| failed = true | ||
| console.error(`❌ ${name}`) | ||
| console.error(` 这些入口文件不在磁盘上,说明这个包没构建或构建产物不全,先构建再跑本检查:${missing.join(', ')}`) | ||
| continue | ||
| } | ||
| const problems = checkPackedFiles(pkgJson, packedPathsOf(dir)) | ||
| if (problems.length === 0) { | ||
| console.log(`✅ ${name}`) | ||
| continue | ||
| } | ||
| failed = true | ||
| console.error(`❌ ${name}`) | ||
| for (const problem of problems) console.error(` ${problem}`) | ||
| } | ||
| if (failed) { | ||
| console.error('\n发布产物契约不成立。上面每一条都会在安装方那里才炸,本仓的构建和测试看不见。') | ||
| process.exit(1) | ||
| } | ||
| console.log('\n发布产物契约成立:入口都在 tarball 里,没有发出测试文件。') | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new tarball check still reports the compiler package as test-free while publishing
scripts/toolchain-setup-node-native.js: that file explicitly identifies itself as test-only and repository-wide search finds it consumed only by the two excluded test scripts, but its name matches none of this regex's conventions. Add it to the package exclusion or model the actual published-script allowlist so this contract does not give a false clean result.AGENTS.md reference: AGENTS.md:L24-L24
Useful? React with 👍 / 👎.