Ctrl K
ComunidadGuías…

scripts/unverified-register.mjs

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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;
  }
}