pi 的两条"真实的洞",都落了,而且第一条当场抓到实证。 1. **探针三值**(可用 / 不可用 / 拿不准→红):`RESULT static=5 probe=ok|unknown` 把"欠账余额"和"探针是否健康"拆成两个数字。 **换完第一次运行就报 probe=unknown** —— 一查:探针调的是 `execFileSync`, 而这个文件 import 的是 `spawnSync`,**名字根本没定义**。也就是说 **探针从写下的那天起一次都没跑成过**,旧的两值设计把 `ReferenceError` 和"没有设备"一起吞掉、统一报成"设备不可用":机制在、闸门从没开过, 而它看起来完全健康。这正是 pi 描述的"恒不开闸",只是比预想更彻底。 现在:命令在但跑不成 → unknown → 红;所有候选都不存在(本机没装 hdc)→ 可判的 "没有设备工具" → false,避免没装 SDK 的机器天天假红。 附 `--probe-selftest`(只跑分类器,不跑套件)+ 变异验证(把 unknown 当"不成立"→ 红)。 2. **releaseCandidate = !gitDirty**(从展示升成标签):BUILD_INFO 现在自报 `releaseCandidate`,发布脚本在脏树时会打印"这个包不是发布候选"。 判据 `build-stamp` 断言"标签与 gitDirty 必须一致"。 **实证**:本轮我打的包正是这种情况 —— `gitDirty: true`(含着 gui-lab 未提交的 NarrowStack/index.css),`releaseCandidate: false`,日志里明确说了"不是发布候选"。 3. 附带:`criteria-hygiene` 加一条"用到 `code/prose/bytes` 就必须真的 import"。 理由是同一形状我这轮在三个文件里各犯过一次(最后一次是 `execFileSync`/`spawnSync`), 而它表现为"判据红了"(ReferenceError 抛在判据自己身上),看起来像判据失败、 不像判据写错。这条至少把最常写错的那几个名字变成明确的红。
123 lines
5.2 KiB
JavaScript
123 lines
5.2 KiB
JavaScript
/**
|
||
* 构建产物的**自证**:`dist/BUILD_INFO.json`。
|
||
*
|
||
* # 为什么不是时间戳
|
||
*
|
||
* 原来那条判据是"`dist` 比 `src` 新" —— pi 2026-09-14 指出它有两层代理:
|
||
* ① 看不见"构建是否成功"(实测:构建失败但碰过 dist 时它照样绿,见 CRITERIA.md §6.7.1);
|
||
* ② **`dist` 比 `src` 新也不等于 `dist` 是从这份 `src` 构建的**(`checkout`/`cp`/时钟都骗得过 mtime)。
|
||
* 换成内容指纹之后,②也消掉了:判据说的是"产物记的源状态 == 当前源状态",
|
||
* 而不是"谁的时间更晚"。任何"包不是从这份源码来的"都变成一条可读出的不等。
|
||
*
|
||
* # 这个文件同时是"套件自报来源"的构建侧同款
|
||
*
|
||
* pi 的建议:**一条红/一个包都应自带"它对应哪个源码状态"**(`git status --porcelain` 那一族)。
|
||
* 消费方:
|
||
* - `test/build-stamp.test.mjs` 精确比对(不是比新旧);
|
||
* - `scripts/release-linux.sh` 打完包把同一份信息打进日志(出问题时先看它)。
|
||
*
|
||
* 指纹范围 = **会影响产物的一切**(`src/` + 入口 html + 构建配置 + 生成脚本),
|
||
* 排除生成物 `background-takeover.generated.css`(它由 `gen:bg` 现生成,
|
||
* 内容已由生成脚本决定 —— 计入会让"先 build 后 gen"这种无害顺序变化产生假红)。
|
||
*/
|
||
import { createHash } from 'node:crypto';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
|
||
import { dirname, join, relative, sep } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
export const ELECTRON_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||
|
||
/** 参与指纹的文件/目录(相对 client/electron) */
|
||
const TRACKED = [
|
||
'src',
|
||
'index.html',
|
||
'vite.config.ts',
|
||
'tsconfig.json',
|
||
'package.json',
|
||
'scripts/gen-background-takeover.mjs'
|
||
];
|
||
|
||
/** 生成物:由上面某个文件决定内容,计入会产生假红 */
|
||
const EXCLUDE = new Set(['src/background-takeover.generated.css']);
|
||
|
||
function walk(abs, rel, out) {
|
||
const st = statSync(abs);
|
||
if (st.isDirectory()) {
|
||
for (const name of readdirSync(abs).sort()) {
|
||
walk(join(abs, name), `${rel}/${name}`, out);
|
||
}
|
||
return;
|
||
}
|
||
const key = rel.split(sep).join('/');
|
||
if (EXCLUDE.has(key)) return;
|
||
out.push(key);
|
||
}
|
||
|
||
/**
|
||
* 当前源码状态。
|
||
*
|
||
* 返回 `{ gitRev, gitDirty, srcHash }`:
|
||
* - `gitRev`:HEAD 短哈希(读不到 git 时给 `''`,此时只看指纹);
|
||
* - `gitDirty`:工作树是否有未提交改动(**只作信息**:共享工作区常年是脏的,
|
||
* 判据不拿它当红/绿依据,拿它当"复核时先看这一行");
|
||
* - `srcHash`:影响产物的那批文件的**内容**指纹。
|
||
*/
|
||
export function srcState(root = ELECTRON_DIR) {
|
||
const files = [];
|
||
for (const t of TRACKED) {
|
||
const abs = join(root, t);
|
||
try { walk(abs, t, files); } catch { /* 缺文件就不计(例如还没有 dist 时) */ }
|
||
}
|
||
files.sort();
|
||
const h = createHash('sha256');
|
||
for (const f of files) {
|
||
h.update(f).update('\0').update(readFileSync(join(root, f))).update('\0');
|
||
}
|
||
let gitRev = '';
|
||
let gitDirty = false;
|
||
const repoRoot = join(root, '..', '..');
|
||
try {
|
||
gitRev = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim();
|
||
gitDirty = execFileSync('git', ['status', '--porcelain'], { cwd: repoRoot, encoding: 'utf8' }).trim().length > 0;
|
||
} catch { /* 不在 git 里(例如解包后的目录):指纹仍然有效 */ }
|
||
return { gitRev, gitDirty, srcHash: h.digest('hex'), files: files.length };
|
||
}
|
||
|
||
/** 写 `dist/BUILD_INFO.json`(构建路径的最后一步) */
|
||
export function writeBuildInfo(root = ELECTRON_DIR, buildCmd = process.env.npm_lifecycle_event || '') {
|
||
const st = srcState(root);
|
||
const info = {
|
||
gitRev: st.gitRev,
|
||
gitDirty: st.gitDirty,
|
||
srcHash: st.srcHash,
|
||
srcFiles: st.files,
|
||
buildCmd,
|
||
builtAt: new Date().toISOString(),
|
||
// 说明这份文件是什么、谁在比它(读到此文件的人不用去翻代码)
|
||
/*
|
||
* **标签**,不是展示(pi 2026-09-14 §1)。
|
||
*
|
||
* 共享树上永远有人在写别的文件,所以"脏树"是常态 —— 拿它当红会让门禁天天假红。
|
||
* 但"从脏树打出来的包"和"从干净树打出来的包"**在门禁上必须可区分**:
|
||
* 前者含别人的半成品,只能自用,不能给人装。所以给一个是/否:
|
||
*
|
||
* releaseCandidate = !gitDirty
|
||
*
|
||
* 部署路径可以据此拒绝安装(判据见 build-stamp 的"脏树产物不得自称发布候选")。
|
||
*/
|
||
releaseCandidate: !st.gitDirty,
|
||
note: '构建自证:test/build-stamp.test.mjs 用 srcHash/gitRev 精确比对,不是比时间戳'
|
||
};
|
||
const out = join(root, 'dist', 'BUILD_INFO.json');
|
||
mkdirSync(dirname(out), { recursive: true });
|
||
writeFileSync(out, `${JSON.stringify(info, null, 2)}\n`);
|
||
return info;
|
||
}
|
||
|
||
// 直接执行时 = 构建的最后一步
|
||
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
||
const info = writeBuildInfo();
|
||
console.log(`[build-info] dist/BUILD_INFO.json 已写:rev=${info.gitRev || '(无 git)'} srcHash=${info.srcHash.slice(0, 12)} dirty=${info.gitDirty}`);
|
||
}
|