scripts/unverified-register.mjs
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* Regenerate `docs/UNVERIFIED.md` — the cross-release register of every claim a release
* note itself filed under a "尚未验证" section.
*
* WHY THIS EXISTS. Each release note carries its own "what we did not measure" list, and
* those lists only ever grow: v3.141.592654 states the newest gaps, and the 65 historical
* bullets before it were reachable only by opening 24 files one at a time. The audit that
* asked for this register counted the bullets by hand; nobody can do that every release.
* So the notes stay the source of truth and this script derives the index from them — the
* register cannot disagree with the notes because it is not written by hand at all.
*
* THE MARKER IS THE STATUS. A bullet's state is whatever marker it starts with:
*
* `[VERIFIED]` → 已复核 the claim was re-checked, and the bullet carries the record
* `[UNVERIFIED]` → 仍缺 still unmeasured
* `[FIXED]` → 已修 the gap itself was closed
* no marker → 仍缺, shown with `—` in the marker column
*
* Status therefore changes where the evidence lives (the release note), never here. A
* generator that accepted hand-edits would drift from the notes on the next run, which is
* exactly what `test/unverified-register.test.js` compares byte for byte.
*
* Run: node scripts/unverified-register.mjs [--out <path>]
*/
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const RELEASES_DIR = 'docs/releases';
export const REGISTER_FILE = 'docs/UNVERIFIED.md';
export const SECTION_WORD = '尚未验证';
export const STATUS = Object.freeze({
VERIFIED: '已复核',
UNVERIFIED: '仍缺',
FIXED: '已修',
});
const MARKER_STATUS = new Map([
['VERIFIED', STATUS.VERIFIED],
['UNVERIFIED', STATUS.UNVERIFIED],
['FIXED', STATUS.FIXED],
]);
const HEADING_RE = /^(#{1,6})\s+(.*?)\s*$/;
const BULLET_RE = /^ {0,3}[-*+]\s+(.*)$/;
const FENCE_RE = /^\s*(?:```|~~~)/;
// `[UNVERIFIED]`, `[UNVERIFIED]` inside backticks, and the bolded spellings all appear in
// the corpus. Anything else in the bracket position (`[NOTE]`, `[TODO]`) is NOT a marker:
// an unrecognised tag must not silently claim a status.
const MARKER_RE = /^(?:\*\*|__)?`?\[([A-Z][A-Z_]{2,})\]`?(?:\*\*|__)?/;
const normalize = (text) => text.replace(/\s+/g, ' ').trim();
function makeBullet(line, raw) {
const match = raw.match(MARKER_RE);
const marker = match && MARKER_STATUS.has(match[1]) ? match[1] : null;
const body = marker ? raw.slice(match[0].length) : raw;
return {
line,
marker,
status: marker ? MARKER_STATUS.get(marker) : STATUS.UNVERIFIED,
text: normalize(body),
};
}
/**
* Every section whose heading contains 尚未验证, with its top-level bullets.
*
* A section ends at the next heading of the same or a shallower level, so a `###` note
* inside the section does not truncate it. Bullet continuation lines are the indented
* ones — several notes wrap a single bullet over three lines.
*/
export function parseUnverifiedSections(text) {
const lines = text.split(/\r?\n/);
const sections = [];
let section = null;
let bullet = null;
let fence = false;
const flush = () => {
if (section && bullet) section.bullets.push(bullet);
bullet = null;
};
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (FENCE_RE.test(line)) {
flush();
fence = !fence;
continue;
}
if (fence) continue;
const heading = line.match(HEADING_RE);
if (heading) {
flush();
const level = heading[1].length;
if (section && level <= section.level) section = null;
if (!section && heading[2].includes(SECTION_WORD)) {
section = { heading: heading[2], line: i + 1, level, bullets: [] };
sections.push(section);
}
continue;
}
if (!section) continue;
const start = line.match(BULLET_RE);
if (start) {
flush();
bullet = makeBullet(i + 1, start[1]);
continue;
}
if (bullet && /^(?: {2,}|\t)\S/.test(line)) {
bullet.text = normalize(`${bullet.text} ${line}`);
continue;
}
if (!line.trim()) {
flush();
continue;
}
flush();
}
flush();
return sections;
}
export function versionOf(file) {
const match = file.match(/^RELEASE_NOTES_v?(\d+(?:\.\d+)*)\.md$/);
return match ? match[1] : null;
}
function compareEntries(a, b) {
const pa = a.version ? a.version.split('.').map(Number) : null;
const pb = b.version ? b.version.split('.').map(Number) : null;
if (pa && pb) {
const width = Math.max(pa.length, pb.length);
for (let i = 0; i < width; i += 1) {
const delta = (pb[i] ?? 0) - (pa[i] ?? 0);
if (delta) return delta;
}
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
}
if (pa) return -1;
if (pb) return 1;
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
}
/** Every release note that carries at least one 尚未验证 section, newest release first. */
export function collectEntries({ root = ROOT } = {}) {
const dir = join(root, RELEASES_DIR);
const files = readdirSync(dir)
.filter((name) => /^RELEASE_NOTES_.*\.md$/.test(name))
.sort();
const entries = [];
for (const name of files) {
const sections = parseUnverifiedSections(readFileSync(join(dir, name), 'utf8'));
if (sections.length) entries.push({ name, version: versionOf(name), sections });
}
entries.sort(compareEntries);
return { scanned: files.length, entries };
}
const cell = (text) => normalize(text).replace(/\|/g, '\\|');
const markerCell = (bullet) => (bullet.marker ? `\`[${bullet.marker}]\`` : '—');
function countBy(rows, predicate) {
return rows.filter(predicate).length;
}
export function renderRegister({ scanned, entries }) {
const allBullets = entries.flatMap((entry) => entry.sections.flatMap((section) => section.bullets));
const verified = countBy(allBullets, (b) => b.status === STATUS.VERIFIED);
const unverified = countBy(allBullets, (b) => b.status === STATUS.UNVERIFIED);
const fixed = countBy(allBullets, (b) => b.status === STATUS.FIXED);
const unmarked = countBy(allBullets, (b) => !b.marker);
const sectionCount = entries.reduce((sum, entry) => sum + entry.sections.length, 0);
const out = [];
out.push('# 尚未验证登记册 / Unverified register');
out.push('');
out.push('> **自动生成,不要手改。** 来源是 `docs/releases/*.md` 里标题含「尚未验证」的小节;改完那些');
out.push('> 小节后运行 `node scripts/unverified-register.mjs`。`test/unverified-register.test.js` 会重新');
out.push('> 生成并与本文件逐字节比较,任何漂移都是失败。');
out.push('>');
out.push('> 状态只看 bullet 开头的 marker:`[VERIFIED]` → **已复核**(bullet 里要带复核记录),');
out.push('> `[FIXED]` → **已修**,`[UNVERIFIED]` → **仍缺**;没有 marker 的同样按 **仍缺** 计,marker 列');
out.push('> 写 `—`。登记册本身不重新测量任何东西:一条从「仍缺」变成「已复核」,必须先在对应');
out.push('> release note 里补上 marker 和证据,再重新生成本文件。');
out.push('');
out.push('## 总量');
out.push('');
out.push('| 项 | 值 |');
out.push('|---|---|');
out.push(`| 扫描 | \`${RELEASES_DIR}/*.md\` 共 ${scanned} 个文件 |`);
out.push(`| 命中 | ${entries.length} 个文件 / ${sectionCount} 个小节 |`);
out.push(`| bullet | ${allBullets.length} 条 |`);
out.push(`| 已复核 | ${verified} |`);
out.push(`| 仍缺 | ${unverified} |`);
out.push(`| 已修 | ${fixed} |`);
out.push(`| 无 marker | ${unmarked} |`);
out.push('');
if (!entries.length) {
out.push('没有任何 release note 带「尚未验证」小节。');
out.push('');
return `${out.join('\n')}`;
}
out.push('## 按 release');
out.push('');
out.push('| release | 小节 | bullet | 已复核 | 仍缺 | 已修 | 无 marker |');
out.push('|---|---:|---:|---:|---:|---:|---:|');
for (const entry of entries) {
const rows = entry.sections.flatMap((section) => section.bullets);
const cells = [
`[${entry.version ?? entry.name}](releases/${entry.name})`,
entry.sections.length,
rows.length,
countBy(rows, (b) => b.status === STATUS.VERIFIED),
countBy(rows, (b) => b.status === STATUS.UNVERIFIED),
countBy(rows, (b) => b.status === STATUS.FIXED),
countBy(rows, (b) => !b.marker),
];
out.push(`| ${cells.join(' | ')} |`);
}
out.push('');
for (const entry of entries) {
const rows = entry.sections.flatMap((section) => section.bullets);
out.push(`## ${entry.version ?? entry.name} — \`${RELEASES_DIR}/${entry.name}\``);
out.push('');
out.push(
`${entry.sections.length} 个小节 · ${rows.length} 条:`
+ `已复核 ${countBy(rows, (b) => b.status === STATUS.VERIFIED)} · `
+ `仍缺 ${countBy(rows, (b) => b.status === STATUS.UNVERIFIED)} · `
+ `已修 ${countBy(rows, (b) => b.status === STATUS.FIXED)}`
+ `(无 marker ${countBy(rows, (b) => !b.marker)})`,
);
out.push('');
for (const section of entry.sections) {
out.push(`小节 \`## ${section.heading}\`(\`:${section.line}\`)`);
out.push('');
if (!section.bullets.length) {
out.push('(空小节)');
out.push('');
continue;
}
out.push('| # | 位置 | bullet | marker | 状态 |');
out.push('|---:|---|---|---|---|');
section.bullets.forEach((bullet, index) => {
out.push(
`| ${index + 1} | \`${entry.name}:${bullet.line}\` | ${cell(bullet.text)} | `
+ `${markerCell(bullet)} | ${bullet.status} |`,
);
});
out.push('');
}
}
return `${out.join('\n')}`;
}
export function buildRegister({ root = ROOT } = {}) {
return renderRegister(collectEntries({ root }));
}
function parseArgs(argv) {
const options = { out: REGISTER_FILE };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--out') {
const value = argv[i + 1];
if (!value) throw new Error('--out needs a path');
options.out = value;
i += 1;
} else if (arg.startsWith('--out=')) {
options.out = arg.slice('--out='.length);
if (!options.out) throw new Error('--out needs a path');
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
return options;
}
if (resolve(process.argv[1] || '') === resolve(fileURLToPath(import.meta.url))) {
try {
const { out } = parseArgs(process.argv.slice(2));
const target = resolve(ROOT, out);
const body = buildRegister();
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, body, 'utf8');
const bullets = body.match(/^\| \d+ \| `/gm)?.length ?? 0;
console.log(`Wrote ${out} (${bullets} bullets)`);
} catch (error) {
console.error(error?.message || error);
process.exitCode = 2;
}
}