diff --git a/.claude/skills/checklist-test/SKILL.md b/.claude/skills/checklist-test/SKILL.md index e714937f039..e31591602d2 100644 --- a/.claude/skills/checklist-test/SKILL.md +++ b/.claude/skills/checklist-test/SKILL.md @@ -48,6 +48,7 @@ node scripts/checklist-select.mjs --json | `surface:api` | 全部 API 面测试项(便宜 —— 不需要浏览器构建) | | `since:v17` | 某个 release 引入的全部项(release-sweep 过滤器) | | **`file:packages/plugins/plugin-approvals/src/approval-service.ts`** | **`source[]` 引用该文件的项 ——「测覆盖这个文件的一切」**(带 `/` 或代码扩展名的裸路径也自动按 `file:` 解析) | +| `touched:` | `source[]` 或 `automated.ref` 引用的文件在 `..HEAD` 里改过的项(增量回归) | | `all` | 整份清单 | `--json` 给出可运行清单(id · priority · surface · revision)。**Blocked 项默认排 @@ -169,8 +170,7 @@ expected-fail 探针 —— 永不在真实缺陷上把条款打成绿。 立单不是终点,收口是每一轮运行的一部分。RUNNER 的抽取义务条款给的是**抽什么**;本节 给的是**一张记录什么时候才算关闭**,以及关闭这件事在别处怎么被读到。零新标签 —— -下面 -每个状态都用现有载体承载。 +下面每个状态都用现有载体承载。 **开着的 `qa-run` 数 = 未清的收口债**,与裸 `finding` 计数同一构造:它是可直接读的健 康指标,所以⛔ 永不靠关掉记录让它变好看,只靠把下面几件事做完。 @@ -215,29 +215,47 @@ QA-source: #运行记录号 · area.item · 条款 `platform-core.console-login`)—— 解析器、run 记录的逐条款表、抽取卡三处同一把定位 符,才互相 grep 得到;⛔ 不写只有 item 段的短形。**写进 GitHub 正文/评论时⛔ 不要用尖 括号占位符**:`<`+字母会被写入层当标签吃掉,一行溯源就此消失在库里 —— 占位用中文词 -或 -直接填实值。 +或直接填实值。**唯一例外是 npm 冒烟**(§6):定位字段写冒烟选择器、条款写格号,如 +`QA-source: #运行记录号 · smoke:npm-18.2.0 · C3` —— 下一轮冒烟凭这一行重判它。 **Supersede 协议** —— 重跑覆盖了旧记录的地面时,旧记录关闭(`not_planned` + 一行 `Superseded-by:` 指向新记录),但**先把新一轮没有重新判定的判定结转过去**:结转行 (`Carried-forward:`,引旧记录号)落在**新记录**上,supersede 行落在**旧记录**上。⛔ 永不用「新的一轮跑过了」直接关掉旧记录:没被重新覆盖的判定会随它一起消失,而记录被 -关 -闭这件事在别处不留任何痕迹。 +关闭这件事在别处不留任何痕迹。 -**重测触发,三条且只有三条**: +**重测触发,四条且只有四条**: 1. **发版候选** ⇒ 完整 Tier 1 重跑(§1 的分层)。 2. **QA 抽取的修复卡落地** ⇒ **接受该卡的座位**把受影响的项追加到波次锚卡的重测清 - 单 —— 与跨仓联动同一纪律:第一产者是接受座位。 + 单 —— 与跨仓联动同一纪律:第一产者是接受座位。安全加固没有卡:落地它的 PM 会话自 + 己重跑该项,⛔ 不在任何卡上点名它。 3. **维护者点名目标**。 +4. **常驻回归**(维护者所定,§6)⇒ 每周回归与发版后 npm 冒烟。 ⛔ 其它一切都不是重测触发:凭手感「再跑一遍」只增加收口债,不增加覆盖。 -**验证重测**不是第四条触发:由同一 PR 对该项的清单编辑(§3)引发,⛔ 不立 `qa-run` 记录。 +**验证重测**不是第五条触发:由同一 PR 对该项的清单编辑(§3)引发,⛔ 不立 `qa-run` 记录。 逐项判定表进 PR 正文,该项 `history` 行的 `ref` 指向该 PR;`partial`/`blocked` 的缺口进 PR 正文与 `fixtures.knownGaps`;产品缺陷 `fail` 照常抽卡、溯源指向该 PR;条款过期在同一 diff 修。 +## 6. 常驻回归 —— 两次发版之间也重跑 + +维护者所定的两条例行(裁定评论 6501246005);定时任务由派发的 PM 会话建,⛔ 不是仓库代码,也 +不是合并门禁。 + +- **每周回归**,对 `main` HEAD,四张 `qa-run` 记录,一个选择器一张: + - 欠测清单:标题选择器写 `owed:weekly`(⛔ 不带冒号的词会被读成区),清单里每个 id + 按 §0 裸 id 逐项解析; + - `touched:<上周 touched 记录的 subject sha>`;首周取最近一张发版全量重跑记录的 subject; + - `priority:P0` 与 `surface:api`。 + §5 的 Supersede 让每条例行至多开着一张:选择器周周相同的三张直接适用;`touched:` 的 sha + 每周都变,本周那张照样 supersede 上周那张,判定结转照 §5。 +- **发版后 npm 冒烟**:每次发布后跑 `smoke:npm-<版本>`,形状照上一张同类记录;上一轮冒烟抽出 + 的修复卡在这里重判(它们的溯源行见 §5 的冒烟例外)。 +- **欠测清单**由 `node scripts/qa/retest-owed.mjs` 只读算出(⛔ 不写 GitHub): + QA 抽取的修复卡关闭晚于该项最新判定的 subject,该项即欠测 —— 就是 §5 触发 2 的清单。 + ## Guardrails - **不伪造覆盖。** 缺 fixture → `blocked(fixture)` 带理由;console 没构建 → 构建它 diff --git a/package.json b/package.json index f4831aedf83..34597113ade 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "check:adr-symbol-anchors": "node scripts/check-adr-symbol-anchors.mjs --self-test && node scripts/check-adr-symbol-anchors.mjs", "check:scripts-symbol-anchors": "node scripts/check-scripts-symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs", "check:spec-docblock-symbol-anchors": "node scripts/check-spec-docblock-symbol-anchors.mjs --self-test && node scripts/check-spec-docblock-symbol-anchors.mjs", - "check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs --self-test && node scripts/check-platform-checklist.mjs", + "check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/qa/qa-rollup.mjs --self-test && node scripts/qa/retest-owed.mjs --self-test && node scripts/check-platform-checklist.mjs --self-test && node scripts/check-platform-checklist.mjs", "gen:checklist-status": "node scripts/gen-checklist-status.mjs --self-test && node scripts/gen-checklist-status.mjs", "check:org-identifier": "node scripts/check-org-identifier.mjs --self-test && node scripts/check-org-identifier.mjs", "check:runner-env-posture": "node scripts/check-runner-env-posture.mjs --self-test && node scripts/check-runner-env-posture.mjs", diff --git a/scripts/checklist-select.mjs b/scripts/checklist-select.mjs index b6425ed6395..fb939ed0dd3 100644 --- a/scripts/checklist-select.mjs +++ b/scripts/checklist-select.mjs @@ -20,6 +20,8 @@ // since:vN every item introduced in release vN (prefix match: since:v16 ⊇ v16.0) // file: ★ items whose `source[]` cites this framework file (or its // basename / containing dir) — "test whatever covers this file" +// touched: items whose `source[]` paths or `automated.ref` files changed +// in ..HEAD — "test whatever the last N commits moved" // all every active item // // Prefix-less conveniences (paste a filename/path, no prefix to remember): @@ -40,7 +42,32 @@ // // Output: a table (id · priority · surface · blocked?) to stderr for humans, and — with // --json — a machine list to stdout for the runner to fan out over. +// +// ## `touched:` — what counts as "cites a changed file" +// +// The changed set is `git diff --no-renames --name-only HEAD`: the two trees, +// so a depth-1 fetch of is enough, and a rename lists its OLD path too (an item +// still citing the old path is exactly the item the rename touched). An item is +// touched when a path it cites is in that set, read the same way every time: +// +// - every path-shaped token of each `source[]` entry and of `automated.ref`, cut at +// the `#` that opens a symbol anchor (the path before it is kept) and at the prose +// that follows it; +// `dir/{a,b}.ts` brace lists are expanded first; +// - a cited path matches a changed path that holds it as a whole run of segments: +// the same path; any path under it (a directory source matches by prefix); or a +// longer path ending in it (`__tests__/x.test.ts` written after the directory it +// sits in, or an `e2e/live/…` path from the retired objectui repo, now under +// `ui/`). Always on a `/` boundary — `packages/foo` never matches `packages/foobar`; +// - a bare file name (no `/`, a source-file extension) matches by basename, the way +// refs name a sibling of the test file named before it. +// +// Issue references, ADR ids and prose cite no path and match nothing. A citation no +// tracked file holds any more (a file deleted or moved) matches only a change to that +// old path; the CLI counts such citations and says so, so a quiet answer is never read +// as "nothing moved". +import { execFileSync } from 'node:child_process'; import { readdirSync, readFileSync, existsSync } from 'node:fs'; import { join, basename, dirname } from 'node:path'; import { isEntrypoint } from './invoked-as.mjs'; @@ -96,6 +123,15 @@ export function selectItems(selector, items, coverage = { metadataKinds: {} }, o if (key === 'priority') return active.filter((it) => it.priority === val); if (key === 'surface') return active.filter((it) => it.surface === val); if (key === 'since') return active.filter((it) => it.since === val || (it.since || '').startsWith(`${val}.`)); + if (key === 'touched') { + // The diff is I/O and lives in the CLI; this resolver stays pure. Asked to + // resolve `touched:` with no changed set, it refuses rather than answering + // "nothing touched" — an empty set and a missing one must not read alike. + if (!Array.isArray(opts.changedPaths)) { + throw new TypeError(`selectItems: '${selector}' needs opts.changedPaths (the files changed in ${val}..HEAD)`); + } + return active.filter((it) => isTouched(it, opts.changedPaths)); + } if (key === 'file') { const p = val.replace(/^\.?\//, ''); const base = basename(p); @@ -146,8 +182,64 @@ export function selectItems(selector, items, coverage = { metadataKinds: {} }, o * environment cannot run today, and hiding it is a fixture problem. A planned * item has nothing to run at all. */ -export function selectPlanned(selector, items, coverage = { metadataKinds: {} }) { - return selectItems(selector, items, coverage, { status: 'planned' }); +export function selectPlanned(selector, items, coverage = { metadataKinds: {} }, opts = {}) { + return selectItems(selector, items, coverage, { ...opts, status: 'planned' }); +} + +/** A file name a bare citation may use: a source, config or doc extension. */ +const CITED_FILE_EXT = /\.(?:[cm]?[jt]sx?|jsonc?|mdx?|ya?ml|sql|css|html|sh)$/; + +/** + * The paths an item cites, read off `source[]` and `automated.ref` by the rules in + * the header's `touched:` section. Pure. + * + * @param {object} item + * @returns {{paths: string[], basenames: string[]}} paths carry a `/`; basenames do not + */ +export function citedPaths(item) { + const texts = [...(item.source || []), item.automated?.ref || '']; + const paths = new Set(); + const basenames = new Set(); + for (const text of texts) { + // `dir/{a,b}.ts` → `dir/a.ts dir/b.ts`; a brace group holding a space is prose, not a list. + const expanded = String(text).replace( + /([A-Za-z0-9_@./-]*)\{([A-Za-z0-9_@./-]+(?:,[A-Za-z0-9_@./-]+)+)\}([A-Za-z0-9_@./-]*)/g, + (_, pre, list, post) => list.split(',').map((x) => `${pre}${x}${post}`).join(' '), + ); + for (const raw of expanded.match(/[A-Za-z0-9_@.][A-Za-z0-9_@./-]*/g) || []) { + const token = raw.replace(/^\.\//, '').replace(/[./]+$/, ''); + if (token.includes('/')) { + if (!token.includes('//')) paths.add(token); + } else if (CITED_FILE_EXT.test(token)) { + basenames.add(token); + } + } + } + return { paths: [...paths], basenames: [...basenames] }; +} + +/** + * Whether `path` holds `cited` as a whole run of its `/`-separated segments. Pure. + * + * @param {string} path a repo-relative file path + * @param {string} cited a cited path (no leading or trailing `/`) + * @returns {boolean} + */ +export function holdsSegments(path, cited) { + return `/${path}/`.includes(`/${cited}/`); +} + +/** + * Whether any path the item cites is among `changed` (repo-relative paths), by + * the header's `touched:` rules. Pure. + * + * @param {object} item + * @param {string[]} changed + * @returns {boolean} + */ +export function isTouched(item, changed) { + const { paths, basenames } = citedPaths(item); + return changed.some((c) => paths.some((p) => holdsSegments(c, p)) || basenames.includes(basename(c))); } function isBlocked(it) { @@ -186,8 +278,10 @@ function isBlocked(it) { // runnable pool to prove a planned item is in NONE of them, and against the // planned pool to prove it is still reachable — the two halves of "skipped by // every selector, reported as `planned`". +// 31 → 45: the `touched:` selector — each matching rule driven from its own +// side, the refusal without a changed set, and the planned pool kept apart. const SELF_TEST_BATTERIES = Object.freeze({ - 'checklist-select self-test': 31, + 'checklist-select self-test': 45, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -276,6 +370,37 @@ function selfTest() { eq(plannedIds('packages/foo/baz.ts'), ['b.promised'], 'the prefix-less path form keeps the pool it was asked about'); eq(plannedIds('area:a'), [], 'an area with no planned items reports none — the pool is not a fallback'); eq(plannedIds('b.gone'), [], 'retired is not planned — two different absences, not one'); + // ── touched: — items citing a file changed in ..HEAD ─────────── + // The changed set arrives as data (the CLI owns the git diff), so every rule + // of the header's `touched:` section is driven here, each from its own side. + const TFIX = [ + { id: 't.exact', status: 'active', priority: 'P1', surface: 'api', source: ['packages/x/src/a.ts'] }, + { id: 't.anchor', status: 'active', priority: 'P1', surface: 'api', source: ['packages/x/src/b.ts#makeB (the B door, #123)'] }, + { id: 't.dir', status: 'active', priority: 'P1', surface: 'api', source: ['packages/x/src/dir/ (every file under it)'] }, + { id: 't.ref', status: 'active', priority: 'P1', surface: 'api', source: ['#3358'], automated: { kind: 'test', ref: 'packages/qa/t/one.test.ts + two.dogfood.test.ts, all in-process' } }, + { id: 't.brace', status: 'active', priority: 'P1', surface: 'api', source: ['packages/y/src/{left,right}.tsx'] }, + { id: 't.prose', status: 'active', priority: 'P1', surface: 'api', source: ['ADR-0087 D2', '#5519/#5569'] }, + { id: 't.blocked', status: 'active', priority: 'P1', surface: 'api', source: ['packages/x/src/a.ts'], blocked: { by: 'fixture', ref: '#1' } }, + { id: 't.planned', status: 'planned', priority: 'P1', surface: 'api', source: ['packages/x/src/a.ts'] }, + { id: 't.retired', status: 'retired', priority: 'P1', surface: 'api', source: ['packages/x/src/a.ts'], retiredReason: 'x' }, + ]; + const touched = (changed) => selectItems('touched:abc1234', TFIX, COV, { changedPaths: changed }).map((i) => i.id).sort(); + eq(touched(['packages/x/src/a.ts']), ['t.blocked', 't.exact'], 'touched: an exact source path — runnable pool only (blocked rides along for the CLI to hide)'); + eq(touched(['packages/x/src/b.ts']), ['t.anchor'], 'touched: the #symbol anchor and its prose are stripped before comparing'); + eq(touched(['packages/x/src/dir/deep/c.ts']), ['t.dir'], 'touched: a directory source matches by prefix'); + eq(touched(['packages/x/src/dirt.ts']), [], 'touched: the prefix holds on a / boundary — dirt.ts is not under dir'); + eq(touched(['ui/packages/x/src/a.ts']), ['t.blocked', 't.exact'], 'touched: a cited path matches at the end of a longer one (objectui-rooted, now under ui/)'); + eq(touched(['packages/xx/src/a.ts']), [], 'touched: the trailing match holds on a / boundary too'); + eq(touched(['packages/qa/t/one.test.ts']), ['t.ref'], 'touched: an automated.ref path counts'); + eq(touched(['packages/qa/dogfood/two.dogfood.test.ts']), ['t.ref'], 'touched: a bare ref file name matches by basename'); + eq(touched(['packages/y/src/left.tsx']), ['t.brace'], 'touched: a {a,b} brace list is expanded — its FIRST member too, which no basename reaches'); + eq(touched(['docs/adr/0087-x.md', '3358', '5519']), [], 'touched: issue refs and ADR ids cite no path'); + eq(touched([]), [], 'touched: an empty changed set selects nothing'); + eq(selectPlanned('touched:abc1234', TFIX, COV, { changedPaths: ['packages/x/src/a.ts'] }).map((i) => i.id), ['t.planned'], 'touched: a planned item is reported from its own pool, never run'); + let refused = null; + try { selectItems('touched:abc1234', TFIX, COV); } catch (e) { refused = e; } + eq(refused instanceof TypeError, true, 'touched: no changed set is a refusal, never an empty answer'); + eq(citedPaths(TFIX[1]).paths, ['packages/x/src/b.ts'], 'citedPaths: the anchor is cut off the path'); // ── The floor: every declared battery RAN, and ran its cases (#13489) ──── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -337,7 +462,7 @@ function main() { if (!selector) { console.error('usage: node scripts/checklist-select.mjs [--json] [--include-blocked]'); - console.error(' selectors: | area: | capability: | priority:P0 | surface:api | since:vN | file: | all'); + console.error(' selectors: | area: | capability: | priority:P0 | surface:api | since:vN | file: | touched: | all'); process.exit(2); } if (!existsSync(AREAS_DIR)) { @@ -347,10 +472,12 @@ function main() { const items = loadItems(); const coverage = existsSync(COVERAGE) ? JSON.parse(readFileSync(COVERAGE, 'utf8')) : { metadataKinds: {} }; - let matched = selectItems(selector, items, coverage); + const touchedSha = selector.startsWith('touched:') ? selector.slice('touched:'.length) : null; + const opts = touchedSha === null ? {} : { changedPaths: changedSince(touchedSha) }; + let matched = selectItems(selector, items, coverage, opts); const droppedBlocked = includeBlocked ? [] : matched.filter(isBlocked); if (!includeBlocked) matched = matched.filter((it) => !isBlocked(it)); - const planned = selectPlanned(selector, items, coverage); + const planned = selectPlanned(selector, items, coverage, opts); if (json) { process.stdout.write(JSON.stringify(matched.map((it) => ({ id: it.id, priority: it.priority, surface: it.surface, since: it.since, revision: it.revision })), null, 2) + '\n'); @@ -374,6 +501,7 @@ function main() { console.error(` ${it.priority} ${String(it.surface ?? '-').padEnd(8)} ${it.id}${it.since ? ` → target ${it.since}` : ''}`); } } + if (touchedSha !== null) reportMovedCitations(items); if (matched.length === 0) { // A selector that matched ONLY planned items is answered, not refused: the // ledger has something to say about it and said it above. "Nothing matched" @@ -382,11 +510,64 @@ function main() { console.error('\n (nothing to run: every match is planned — report them as `planned` and stop)'); return; } + // The same for a quiet window: no cited file moved is an answer about the + // window, not a broken selector. + if (touchedSha !== null) { + console.error(` (nothing to run: no runnable item cites a file changed in ${touchedSha}..HEAD)`); + return; + } console.error(' (nothing matched — check the selector; try `all` or `area:`)'); process.exit(1); } } +/** The files changed between and HEAD, or exit 2 naming the fetch that fixes it. */ +function changedSince(sha) { + if (!/^[0-9a-f]{4,40}$/i.test(sha)) { + console.error(`checklist-select: touched:${sha} — not a commit sha (4 to 40 hex characters)`); + process.exit(2); + } + const git = (args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + try { + git(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]); + } catch { + console.error( + `checklist-select: touched:${sha} — that commit is not in this clone (a shallow clone holds only recent history).\n` + + ' The diff needs only its tree, so fetch it and re-run:\n' + + ' git fetch --depth=1 origin \n' + + ' or, from a short sha, git fetch --shallow-since= origin main', + ); + process.exit(2); + } + const changed = git(['diff', '--no-renames', '--name-only', sha, 'HEAD']).split('\n').filter(Boolean); + console.error(`touched:${sha} — ${changed.length} file(s) changed in ${sha}..HEAD`); + return changed; +} + +/** + * Say how many cited file paths no longer exist at HEAD. Such a citation matches + * only a change to its OLD path, so a quiet `touched:` answer could hide work + * under the new one — the count keeps that blind side in view. + */ +function reportMovedCitations(items) { + const tracked = execFileSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) + .split('\n').filter(Boolean); + const held = (p) => existsSync(join(ROOT, p)) || tracked.some((t) => holdsSegments(t, p)); + const absent = new Set(); + let citing = 0; + for (const it of items.filter((i) => i.status === 'active')) { + const gone = citedPaths(it).paths.filter((p) => CITED_FILE_EXT.test(p) && !held(p)); + if (gone.length) citing += 1; + for (const p of gone) absent.add(p); + } + if (absent.size === 0) return; + const sample = [...absent].sort().slice(0, 3).join(', '); + console.error( + `\n note: ${citing} active item(s) cite ${absent.size} file path(s) no tracked file holds (e.g. ${sample}) —\n` + + ' a change under such a file\'s new path is invisible to touched:.', + ); +} + // The dispatch runs only when node ran THIS file. Imported for `selectItems` // (the skill's front half is a pure resolver), the old top-level CLI printed a // usage block to the importer's stderr and killed it with exit 2 mid-import. diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index 5285944732e..064283880e5 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -1564,7 +1564,16 @@ export const CEILINGS = new Map([ // comment 6500750765 — +13 measured lines (a security finding gets no card and // the PM session dispatches its fix, the runner only tests, runner effort by // card shape per backend, the spend line). - ['.claude/skills/checklist-test/SKILL.md', 259], + // 259 → 276: the ruling of record on #27377 — the maintainer's four + // standing-regression options and 「这些都应该写到 checklist skills 中」, recorded + // verbatim in comment 6501246005 — +17 measured lines: 20 ruled (the + // `touched:` selector row, retest trigger 4, the npm smoke locator + // exception in §5, §6 standing regression) less 3 repaid by rejoining §5 + // wraps that had broken mid-word. + // 276 → 277: comment 6500750765 again, +1 measured line: retest trigger 2's + // security-hardening clause (no card exists, so the PM session that landed the + // hardening re-runs the item itself). + ['.claude/skills/checklist-test/SKILL.md', 277], ['.claude/skills/checklist-author/SKILL.md', 62], // 157 → 140 (#25753): lines that named objectui as a sibling repo retired; landed count. ['.claude/skills/dogfood-verification/SKILL.md', 140], diff --git a/scripts/qa/retest-owed.mjs b/scripts/qa/retest-owed.mjs new file mode 100644 index 00000000000..40e4cc570a1 --- /dev/null +++ b/scripts/qa/retest-owed.mjs @@ -0,0 +1,951 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * retest-owed — which checklist items does a landed QA-sourced fix still owe a + * retest? + * + * node scripts/qa/retest-owed.mjs # the table (reads GitHub, writes nothing) + * node scripts/qa/retest-owed.mjs --json # the same rows, machine-readable + * node scripts/qa/retest-owed.mjs --as-of # the answer as it stood at that instant + * node scripts/qa/retest-owed.mjs --repo # another repository (default objectstack-ai/objectstack) + * node scripts/qa/retest-owed.mjs --self-test # the pure core, offline + * + * ## What this answers + * + * `checklist-test` SKILL §5 trigger 2 says the seat accepting a QA-extracted + * fix appends the affected items to the wave anchor's retest list. That step is + * discipline, and discipline nobody performs leaves the list empty while the + * fixes land: on one release wave twenty such fix cards closed and the anchor's + * retest list still read "None yet". This reader computes the list instead. + * + * A fix card is any CLOSED issue whose body carries a §5 provenance line + * + * QA-source: #RECORD · area.item · clause + * + * and it OWES a retest when it closed after the subject commit of the newest + * `qa-run` record that judged `area.item`. Per item the output names the fix + * card(s), the record that last judged the item, and whether a record whose + * subject postdates the fix has already re-judged it. + * + * ## It is a generated VIEW, and it writes nothing + * + * Same rule as `qa-rollup.mjs`: no cache, no snapshot, no state file, and ⛔ no + * GitHub write of any kind. The issues stay the only source of truth; re-run it + * and you get today's answer. Appending its rows to an anchor, or running the + * owed items, is a session's act, never this script's. + * + * ## Why a separate script and not a `qa-rollup` mode + * + * `qa-rollup` reads record TITLES and promises, in its header and on every + * render, that it parses no body. This question cannot be answered from titles: + * which items a record judged lives in its body, and which fix a card made lives + * in the card's body. A mode that read bodies would break the promise the + * roll-up prints. What the two share is the title contract, so this file + * imports `parseRunTitle` rather than growing a second reader of it. + * + * ## What "the record judged the item" means here, and its blind side + * + * Record bodies are not one shape (measured over 160 records: per-item tables, + * per-clause tables, `### area.item — PASS` headings, verdict bullets, prose). + * So a record counts as judging an item on any of three facts, and on nothing + * else: + * + * 1. it is the record a fix card's `QA-source:` line names for that item; + * 2. its title selector IS the item id and declares a PASS, PARTIAL or FAIL; + * 3. one of its body lines names the item as the line's FIRST checklist id + * and carries an oracle-consulted verdict word (pass / partial / fail and + * their inflections), and the same line says nothing about not-run. + * + * `blocked` and `not-run` are not judgments: RUNNER's verdicts say nothing was + * consulted. Protocol lines (`QA-source:`, `Carried-forward:`, `Extracted:`, + * `Parked-on:`, `Superseded-by:`) are skipped: a carried verdict is not a new + * one. + * + * Every miss of rule 3 lands on the SAFE side: a re-judgment written in a shape + * the rule cannot see leaves the item OWED, never cleared. When a later record + * names the item without a readable verdict line, the row says so + * ("unconfirmed: #N names it"), so a reader knows which record to open. What the + * rule can get wrong in the unsafe direction is a verdict line that names the + * item first while judging something else; the first-id rule and the not-run + * veto keep that narrow, and the self-test pins both. + * + * Per-item ids are read in their bare `area.item` form only — the one locator + * §5 prescribes for record tables, extraction cards and the resolver alike. A + * record that wrote area-local short ids is invisible to rule 3. + * + * ## The post-publish npm smoke + * + * A `QA-source:` line whose locator is `smoke:npm-VERSION` names a cell of a + * published-package smoke, not a checklist item. Its retest is the NEXT such + * smoke, so it is re-judged by any `smoke:npm-*` record whose subject postdates + * the fix. Any other non-item locator is listed under "not read", never dropped. + * + * ## Time is three-valued, because a sha cannot always be resolved + * + * A record's subject time is the committer time of its title's `sha8`, read + * from the local clone first and from the REST commits endpoint second (cloud + * clones are shallow). When neither answers, the record's `created_at` still + * bounds it from above: a run cannot test a commit that did not exist yet. So + * an unresolved record created before the fix closed cannot clear it, and one + * created after it makes the row `unknown` rather than owed or cleared. + * + * ## Cost, and the endpoints it is allowed to use + * + * Repository-scoped REST only: cloud sessions are bound to their repositories + * and refuse the search API and the `/repositories/ID/...` form of the next + * links GitHub returns (measured: 403). Page numbers are no way round it — + * they stop at 10,000 results (measured: `page=100` answers 422). So a listing + * follows each `rel="next"` link with its query carried onto the + * `/repos/OWNER/REPO/...` path it started from. + * + * - the `qa-run` records: one call per 100 (2 calls for 160 records); + * - closed issues updated since the oldest record was filed — a fix card can + * only cite a record that already existed, so that bound is exact + * (measured: 99 calls, about two minutes, for 9,900 issues and PRs); + * - one commits call per subject sha the local clone lacks. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { isEntrypoint } from '../invoked-as.mjs'; +import { authHint, isUsableToken, parseRunTitle } from './qa-rollup.mjs'; + +// ── The self-test's own battery roster and floor ──────────────────────────── +// +// Copied from `qa-rollup.mjs`, never imported (AGENTS.md "Writing a +// `--self-test`"): what is pinned is the registered battery NAMES, each at or +// above its own count, and the roster's size. A pinned TOTAL would stay +// "right" while one battery fell from 9 cases to 3. +const SELF_TEST_BATTERIES = Object.freeze({ + 'QA-source lines are read in their one shape': 9, + 'locator kinds': 5, + 'a record judges an item only on a verdict line': 12, + 'owed, re-judged and unknown are decided on subject time': 9, + 'npm smoke cells are re-judged by the next smoke': 3, + 'nothing read is dropped': 6, + 'as-of replays the past': 3, + 'the render names every section': 8, + 'a listing stays on the repository path': 2, +}); + +// DELETING an entry silences that battery's floor exactly as effectively as +// zeroing it, so the roster's own size is pinned too. +const SELF_TEST_BATTERY_FLOOR = 9; + +// The key an assertion is filed under when no battery is open. It is not a +// declared battery, so it reds by the same set difference. +const UNATTRIBUTED_BATTERY = '(no battery open)'; + +/** Cases registered per battery: `battery()` opens one, `registerCase()` files into it. */ +const batteryCases = new Map(); +let openBattery = null; + +/** Open a battery. Every assertion after this line is attributed to it. */ +function battery(name) { + openBattery = name; +} + +/** Called by the self-test's assertion sink, once per assertion. */ +function registerCase() { + const name = openBattery ?? UNATTRIBUTED_BATTERY; + batteryCases.set(name, (batteryCases.get(name) ?? 0) + 1); +} + +/** The floor: every declared battery ran, and ran its cases. */ +function batteryFloorFailures() { + const declared = Object.keys(SELF_TEST_BATTERIES); + const problems = []; + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + problems.push( + `SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ` + + `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, + ); + } + for (const [name, count] of batteryCases) { + if (declared.includes(name)) continue; + problems.push( + `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + + 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', + ); + } + for (const name of declared) { + const count = batteryCases.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + problems.push( + count === 0 + ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + + 'The verdict below would have claimed those cases hold.' + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + + `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, + ); + } + if (problems.length) { + problems.push( + 'A battery below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + + 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + + 'skips) and restore it.', + ); + } + return problems; +} + +const SEP = '·'; // MIDDLE DOT, the field separator §5 and the record titles use + +/** A bare checklist id, `area.item`: two kebab-case segments joined by one dot. */ +const ITEM_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** The same shape, found inside a line; a following `.x` makes it a filename, not an id. */ +const ITEM_ID_IN_TEXT = /(?]\s*)?(?:QA-source|Carried-forward|Extracted|Parked-on|Superseded-by):/i; + +/** A closed issue whose close is not a landed fix. */ +const NOT_A_LANDING = new Set(['not_planned', 'duplicate']); + +/** + * Read every `QA-source:` line out of a card body. + * + * Strict, like the title contract: a line is a provenance line only when it + * STARTS with `QA-source:` (prose that quotes one — "carry a `QA-source:` line" + * — is not one), and it is read only in §5's one shape, three `·` fields: + * `#RECORD` (or `owner/repo#RECORD`), a locator, a clause. Anything else is + * returned as `malformed` with the reason, so the caller lists it. + * + * Pure. + * + * @param {string|null|undefined} body + * @returns {{entries: Array<{repo: string|null, record: number, locator: string, + * kind: string, clause: string, line: string}>, + * malformed: Array<{line: string, reason: string}>}} + */ +export function parseQaSourceLines(body) { + const entries = []; + const malformed = []; + for (const raw of String(body ?? '').split(/\r?\n/)) { + const m = /^\s*QA-source:\s*(.*)$/.exec(raw); + if (!m) continue; + const line = raw.trim(); + const fields = m[1].split(SEP).map((f) => f.trim()); + if (fields.length !== 3) { + malformed.push({ line, reason: `${fields.length} ${SEP}-separated field(s), expected 3 (#RECORD ${SEP} area.item ${SEP} clause)` }); + continue; + } + const [refField, locator, clause] = fields; + const ref = /^(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+))?#(\d+)$/.exec(refField); + if (!ref) { + malformed.push({ line, reason: `record field ${JSON.stringify(refField)} is not #N or owner/repo#N` }); + continue; + } + if (locator === '' || clause === '') { + malformed.push({ line, reason: 'empty locator or clause field' }); + continue; + } + entries.push({ repo: ref[1] ?? null, record: Number(ref[2]), locator, kind: locatorKind(locator), clause, line }); + } + return { entries, malformed }; +} + +/** + * What a `QA-source:` locator names. + * + * @param {string} locator + * @returns {'item'|'npm-smoke'|'unrecognized'} + */ +export function locatorKind(locator) { + if (ITEM_ID.test(locator)) return 'item'; + if (NPM_SMOKE.test(locator)) return 'npm-smoke'; + return 'unrecognized'; +} + +/** + * Which checklist items one record judged, and with which verdict words — + * facts 2 and 3 of the header (fact 1 comes from the cards, not the record). + * + * `areas` is the checklist's area vocabulary: an id-shaped token whose area is + * not a checklist area (a hostname, a dotted config key) is not an item. + * + * Pure. + * + * @param {{title: string, body?: string|null}} record + * @param {Set} areas + * @returns {{judged: Map>, mentioned: Set}} + */ +export function readRecordItems(record, areas) { + const judged = new Map(); + const mentioned = new Set(); + const mark = (id, word) => { + if (!judged.has(id)) judged.set(id, new Set()); + judged.get(id).add(word); + }; + + const parsed = parseRunTitle(record.title); + if (parsed.ok) { + const id = parsed.selector.replace(/^item:/, ''); + if (ITEM_ID.test(id) && areas.has(id.split('.')[0])) { + mentioned.add(id); + for (const word of ['PASS', 'PARTIAL', 'FAIL']) { + if ((parsed.counts[word] ?? 0) > 0) mark(id, word.toLowerCase()); + } + } + } + + for (const raw of String(record.body ?? '').split(/\r?\n/)) { + if (PROTOCOL_LINE.test(raw)) continue; + const line = raw.replace(/[`*]/g, ''); + const ids = (line.match(ITEM_ID_IN_TEXT) ?? []).filter((id) => areas.has(id.split('.')[0])); + if (ids.length === 0) continue; + for (const id of ids) mentioned.add(id); + if (NOT_RUN_WORD.test(line)) continue; + const word = JUDGED_WORD.exec(line); + if (!word) continue; + mark(ids[0], normaliseVerdict(word[1])); + } + return { judged, mentioned }; +} + +function normaliseVerdict(word) { + const w = word.toLowerCase(); + if (w.startsWith('pass')) return 'pass'; + if (w.startsWith('fail')) return 'fail'; + return 'partial'; +} + +/** + * The item verdict RUNNER would derive from the words a record wrote for it: + * any fail → FAIL; only pass → PASS; anything else judged → PARTIAL. Display + * only — whether a record judged at all is what decides a row. + * + * @param {Set} words + * @returns {string} + */ +export function deriveVerdict(words) { + if (words.has('fail')) return 'FAIL'; + if ([...words].every((w) => w === 'pass')) return 'PASS'; + return 'PARTIAL'; +} + +/** + * Decide every row. + * + * Pure: the cards, the records and the subject times arrive as plain data, so + * the self-test drives every branch, including the ones no live corpus shows on + * the day it is run. + * + * @param {object} input + * @param {Array<{number: number, title?: string, body?: string, state: string, + * state_reason?: string|null, closed_at?: string|null}>} input.cards + * closed issues carrying a `QA-source:` line + * @param {Array<{number: number, title: string, body?: string, created_at: string}>} input.records + * the `qa-run` records + * @param {Map} input.subjectTimes sha8 → ISO committer time, null when unresolved + * @param {Set} input.areas the checklist's area vocabulary + * @param {Map} [input.itemStatus] item id → its checklist `status` + * @param {string|null} [input.asOf] ISO instant; records filed and cards closed after it are ignored + * @param {string} [input.repo] the repository the records live in + * @returns {{owed: object[], unknown: object[], rejudged: object[], + * notRead: object[], notLanded: object[]}} + */ +export function computeRetestOwed({ cards, records, subjectTimes, areas, itemStatus = new Map(), asOf = null, repo = '' }) { + const before = (iso) => asOf === null || (typeof iso === 'string' && iso <= asOf); + + const recs = records + .filter((r) => before(r.created_at)) + .map((r) => { + const parsed = parseRunTitle(r.title); + const items = readRecordItems(r, areas); + const sha = parsed.ok ? parsed.sha : null; + return { + number: r.number, + createdAt: r.created_at, + selector: parsed.ok ? parsed.selector : null, + sha, + subjectTime: sha ? (subjectTimes.get(sha) ?? null) : null, + ...items, + }; + }); + const byNumber = new Map(recs.map((r) => [r.number, r])); + + const notRead = []; + const notLanded = []; + // key → { kind, item, label, cards: [{card, entry}] } + const groups = new Map(); + + for (const card of cards) { + if (card.state !== 'closed' || !before(card.closed_at)) continue; + const { entries, malformed } = parseQaSourceLines(card.body); + if (NOT_A_LANDING.has(card.state_reason ?? '')) { + notLanded.push({ card: card.number, stateReason: card.state_reason, closedAt: card.closed_at }); + continue; + } + for (const m of malformed) notRead.push({ card: card.number, line: m.line, reason: m.reason }); + for (const entry of entries) { + if (entry.repo && repo && entry.repo !== repo) { + notRead.push({ card: card.number, line: entry.line, reason: `the record lives in ${entry.repo}, not ${repo}` }); + continue; + } + if (entry.kind === 'unrecognized') { + notRead.push({ + card: card.number, + line: entry.line, + reason: `locator ${JSON.stringify(entry.locator)} is neither a checklist item id (area.item) nor smoke:npm-VERSION`, + }); + continue; + } + const key = entry.kind === 'item' ? entry.locator : `${entry.locator} ${SEP} ${entry.clause}`; + if (!groups.has(key)) groups.set(key, { kind: entry.kind, item: entry.locator, label: key, cards: [] }); + groups.get(key).cards.push({ card, entry }); + } + } + + // The time a record's subject is known to be no later than: its committer + // time when resolved, else its own filing time (an upper bound). + const upperBound = (r) => r.subjectTime ?? r.createdAt; + + const owed = []; + const unknown = []; + const rejudged = []; + + for (const group of [...groups.values()].sort((a, b) => a.label.localeCompare(b.label))) { + const judgesFor = (entry) => { + const set = new Map(); + if (group.kind === 'item') { + for (const r of recs) if (r.judged.has(group.item)) set.set(r.number, r); + } else { + for (const r of recs) if (r.selector && /^smoke:npm-/.test(r.selector)) set.set(r.number, r); + } + const source = byNumber.get(entry.record); + if (source) set.set(source.number, source); + return [...set.values()]; + }; + + const perCard = group.cards.map(({ card, entry }) => { + const closedAt = card.closed_at; + const judges = judgesFor(entry); + const after = judges + .filter((r) => r.subjectTime && r.subjectTime > closedAt) + .sort((a, b) => a.subjectTime.localeCompare(b.subjectTime) || a.number - b.number); + const maybe = judges.filter((r) => !r.subjectTime && r.createdAt > closedAt); + const last = [...judges].sort((a, b) => upperBound(b).localeCompare(upperBound(a)) || b.number - a.number)[0] ?? null; + let status = 'owed'; + if (after.length) status = 'rejudged'; + else if (maybe.length) status = 'unknown'; + const mentionedBy = status === 'owed' && group.kind === 'item' + ? recs + .filter((r) => !r.judged.has(group.item) && r.mentioned.has(group.item) && upperBound(r) > closedAt) + .map((r) => r.number) + .sort((a, b) => a - b) + : []; + return { + card: card.number, + closedAt, + record: entry.record, + clause: entry.clause, + status, + last, + rejudgedBy: after[0] ?? null, + unresolved: maybe.map((r) => r.number), + mentionedBy, + }; + }); + + for (const status of ['owed', 'unknown', 'rejudged']) { + const these = perCard.filter((c) => c.status === status); + if (these.length === 0) continue; + const last = these.map((c) => c.last).filter(Boolean) + .sort((a, b) => upperBound(b).localeCompare(upperBound(a)) || b.number - a.number)[0] ?? null; + const row = { + item: group.label, + kind: group.kind, + itemStatus: group.kind === 'item' ? (itemStatus.get(group.item) ?? 'absent') : null, + fixCards: these.map((c) => ({ number: c.card, closedAt: c.closedAt, record: c.record, clause: c.clause })), + lastJudged: last ? describeRecord(last, group) : null, + rejudgedBy: status === 'rejudged' + ? describeRecord(these.map((c) => c.rejudgedBy).sort((a, b) => b.subjectTime.localeCompare(a.subjectTime))[0], group) + : null, + unresolved: [...new Set(these.flatMap((c) => c.unresolved))].sort((a, b) => a - b), + mentionedBy: [...new Set(these.flatMap((c) => c.mentionedBy))].sort((a, b) => a - b), + }; + ({ owed, unknown, rejudged })[status].push(row); + } + } + + return { owed, unknown, rejudged, notRead, notLanded }; +} + +function describeRecord(r, group) { + const words = group.kind === 'item' ? r.judged.get(group.item) : null; + return { + number: r.number, + sha: r.sha, + subjectTime: r.subjectTime, + createdAt: r.createdAt, + verdict: words && words.size ? deriveVerdict(words) : null, + }; +} + +/** + * Render the model as markdown, ready to paste into a wave anchor's retest list. + * + * Pure. + * + * @param {object} model + * @returns {string} + */ +export function renderMarkdown(model) { + const { computedOn, corpus, owed, unknown, rejudged, notRead, notLanded } = model; + const L = []; + const cardList = (row) => row.fixCards.map((c) => `#${c.number} (${c.closedAt.slice(0, 10)})`).join(', '); + const rec = (d) => (d ? `#${d.number} \`${d.sha ?? '?'}\`${d.subjectTime ? ` ${d.subjectTime.slice(0, 10)}` : ' (subject unresolved)'}${d.verdict ? ` ${d.verdict}` : ''}` : '—'); + const itemCell = (row) => `\`${row.item}\`${row.itemStatus && row.itemStatus !== 'active' ? ` (${row.itemStatus})` : ''}`; + + L.push('## Retest owed — QA-sourced fixes that landed after the last verdict on their item'); + L.push(''); + L.push('_A generated view. Nothing is written anywhere; the `qa-run` records and the fix cards remain'); + L.push('the only source of truth. Re-run to refresh._'); + L.push(''); + L.push('**computedOn**'); + L.push(''); + L.push(`- repository: \`${computedOn.repo}\``); + L.push(`- as of: ${computedOn.asOf ?? 'now'}`); + L.push(`- records: ${corpus.records} \`qa-run\` records (${corpus.recordsParsed} with a canonical title)`); + L.push(`- fix cards: ${corpus.cards} closed issues carry a \`QA-source:\` line, out of ${corpus.scanned} closed issues and PRs updated since ${corpus.since}`); + L.push(`- subject times: ${corpus.shasResolved}/${corpus.shas} subject shas resolved`); + L.push(`- API: ${computedOn.apiCalls} REST call(s), repository-scoped`); + L.push(`- generated: ${computedOn.generatedAt}`); + L.push(''); + + L.push(`### Owed — ${owed.length} row(s)`); + L.push(''); + if (owed.length === 0) { + L.push('Every landed QA-sourced fix has been re-judged by a record whose subject postdates it.'); + } else { + L.push('| item | fix card(s) | last judged by | re-judged? |'); + L.push('|---|---|---|---|'); + for (const row of owed) { + const note = row.mentionedBy.length ? `no — unconfirmed: ${row.mentionedBy.map((n) => `#${n}`).join(', ')} names it without a verdict line` : 'no'; + L.push(`| ${itemCell(row)} | ${cardList(row)} | ${rec(row.lastJudged)} | ${note} |`); + } + } + L.push(''); + + if (unknown.length > 0) { + L.push(`### Unknown — ${unknown.length} row(s)`); + L.push(''); + L.push('A record filed after the fix closed may have re-judged the item, but its subject sha did not'); + L.push('resolve, so whether its subject postdates the fix cannot be told. Never counted as re-judged.'); + L.push(''); + L.push('| item | fix card(s) | unresolved record(s) |'); + L.push('|---|---|---|'); + for (const row of unknown) L.push(`| ${itemCell(row)} | ${cardList(row)} | ${row.unresolved.map((n) => `#${n}`).join(', ')} |`); + L.push(''); + } + + L.push(`### Re-judged — ${rejudged.length} row(s)`); + L.push(''); + if (rejudged.length > 0) { + L.push('| item | fix card(s) | re-judged by |'); + L.push('|---|---|---|'); + for (const row of rejudged) L.push(`| ${itemCell(row)} | ${cardList(row)} | ${rec(row.rejudgedBy)} |`); + } else { + L.push('None.'); + } + L.push(''); + + L.push(`### Not read — ${notRead.length} line(s)`); + L.push(''); + if (notRead.length > 0) { + L.push('`QA-source:` lines this reader cannot place on a checklist item or an npm smoke cell. Listed,'); + L.push('never dropped: each is a provenance line to correct, or a retest to track by hand.'); + L.push(''); + L.push('| card | line | why |'); + L.push('|---|---|---|'); + for (const n of notRead) L.push(`| #${n.card} | ${escapeCell(n.line)} | ${escapeCell(n.reason)} |`); + } else { + L.push('None.'); + } + L.push(''); + + L.push(`### Closed without a fix — ${notLanded.length} card(s)`); + L.push(''); + L.push(notLanded.length + ? `${notLanded.map((n) => `#${n.card} (${n.stateReason})`).join(', ')} — closed as not planned or duplicate, so no fix landed and no retest is owed.` + : 'None.'); + L.push(''); + + L.push('### How a record counts as judging an item'); + L.push(''); + L.push('It is the record a fix card\'s `QA-source:` line names; or its title selector is the item id'); + L.push('with a PASS, PARTIAL or FAIL count; or one of its body lines names the item first and carries'); + L.push('a pass / partial / fail verdict with no not-run on the same line. A re-judgment written in any'); + L.push('other shape leaves the row owed — the safe side — and "unconfirmed" points at it.'); + return L.join('\n'); +} + +function escapeCell(s) { + return String(s).replace(/\|/g, '\\|'); +} + +/* ------------------------------------------------------------------ * + * I/O shell. Everything above is pure and self-tested; everything + * below talks to git and to GitHub, and writes nothing anywhere. + * ------------------------------------------------------------------ */ + +function git(root, args) { + try { + return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return null; + } +} + +function repoRoot() { + const here = dirname(fileURLToPath(import.meta.url)); + return git(here, ['rev-parse', '--show-toplevel']) ?? join(here, '..', '..'); +} + +/** The checklist's area vocabulary and every item's status, read from the tree. */ +export function readChecklist(root) { + const dir = join(root, 'docs', 'qa', 'platform-checklist', 'areas'); + const areas = new Set(); + const itemStatus = new Map(); + if (!existsSync(dir)) return { areas, itemStatus }; + for (const file of readdirSync(dir).sort()) { + if (!file.endsWith('.json')) continue; + const doc = JSON.parse(readFileSync(join(dir, file), 'utf8')); + areas.add(doc.area ?? file.replace(/\.json$/, '')); + for (const it of doc.items ?? []) itemStatus.set(it.id, it.status); + } + return { areas, itemStatus }; +} + +function makeClient(token) { + let calls = 0; + const get = async (url) => { + const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'objectstack-retest-owed' }; + if (isUsableToken(token)) headers.Authorization = `Bearer ${token}`; + const res = await fetch(url, { headers }); + calls += 1; + return res; + }; + return { get, calls: () => calls }; +} + +/** Every page of a repository-scoped listing, following GitHub's next links (see the header). */ +async function listAll(client, first) { + const out = []; + let url = `${first}${first.includes('?') ? '&' : '?'}per_page=100`; + while (url) { + const res = await client.get(url); + if (!res.ok) throw new Error(`GitHub API ${res.status} ${res.statusText} for ${url}\n${authHint(res.status, process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '')}`); + out.push(...(await res.json())); + const next = /<([^>]+)>;\s*rel="next"/.exec(res.headers.get('link') ?? ''); + url = next ? onPathOf(url, next[1]) : null; + } + return out; +} + +/** `next`'s query on `current`'s path: the `/repositories/ID` path GitHub links to is refused here. */ +export function onPathOf(current, next) { + const moved = new URL(current); + moved.search = new URL(next).search; + return moved.toString(); +} + +/** sha8 → committer time: the local clone first, the REST commits endpoint second. */ +async function resolveSubjectTimes(client, root, repo, shas) { + const times = new Map(); + for (const sha of shas) { + const local = git(root, ['log', '-1', '--format=%cI', `${sha}^{commit}`]); + if (local) { + times.set(sha, new Date(local).toISOString().replace('.000Z', 'Z')); + continue; + } + const res = await client.get(`https://api.github.com/repos/${repo}/commits/${sha}`); + if (!res.ok) { + times.set(sha, null); + continue; + } + const body = await res.json(); + times.set(sha, body?.commit?.committer?.date ?? null); + } + return times; +} + +async function main(argv) { + const repo = valueOf(argv, '--repo') ?? 'objectstack-ai/objectstack'; + const asJson = argv.includes('--json'); + const asOfRaw = valueOf(argv, '--as-of'); + let asOf = null; + if (asOfRaw !== null) { + const t = new Date(asOfRaw); + if (Number.isNaN(t.getTime())) { + console.error(`retest-owed: --as-of ${JSON.stringify(asOfRaw)} is not a date or ISO instant`); + process.exit(2); + } + asOf = t.toISOString().replace('.000Z', 'Z'); + } + const root = repoRoot(); + const { areas, itemStatus } = readChecklist(root); + if (areas.size === 0) { + console.error('retest-owed: no checklist areas under docs/qa/platform-checklist/areas — refusing to read every id as unknown'); + process.exit(1); + } + const client = makeClient(process.env.GH_TOKEN || process.env.GITHUB_TOKEN || ''); + const api = `https://api.github.com/repos/${repo}`; + + const records = (await listAll(client, `${api}/issues?labels=qa-run&state=all`)).filter((i) => !i.pull_request); + if (records.length === 0) { + console.error(`retest-owed: no \`qa-run\` records in ${repo} — nothing could have judged anything`); + process.exit(1); + } + const since = records.map((r) => r.created_at).sort()[0]; + const scanned = await listAll(client, `${api}/issues?state=closed&since=${encodeURIComponent(since)}`); + const cards = scanned.filter((i) => !i.pull_request && /^\s*QA-source:/m.test(i.body ?? '')); + + const shas = [...new Set(records.map((r) => parseRunTitle(r.title)).filter((p) => p.ok).map((p) => p.sha))].sort(); + const subjectTimes = await resolveSubjectTimes(client, root, repo, shas); + + const result = computeRetestOwed({ cards, records, subjectTimes, areas, itemStatus, asOf, repo }); + const model = { + computedOn: { repo, asOf, apiCalls: client.calls(), generatedAt: new Date().toISOString() }, + corpus: { + records: records.length, + recordsParsed: records.filter((r) => parseRunTitle(r.title).ok).length, + since, + scanned: scanned.length, + cards: cards.length, + shas: shas.length, + shasResolved: [...subjectTimes.values()].filter(Boolean).length, + }, + ...result, + }; + process.stdout.write(asJson ? `${JSON.stringify(model, null, 2)}\n` : `${renderMarkdown(model)}\n`); +} + +function valueOf(argv, flag) { + const i = argv.indexOf(flag); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null; +} + +/* ------------------------------------------------------------------ * + * --self-test: the pure core, offline. No network, no git. + * ------------------------------------------------------------------ */ + +// Set by `selfTest()` only after its verdict is printed, and read at the +// dispatch: a `return` above that line prints nothing and would still exit 0. +let selfTestReachedVerdict = false; + +function selfTest() { + const failures = []; + let checked = 0; + const check = (cond, msg) => { + registerCase(); + checked += 1; + if (!cond) failures.push(msg); + }; + + const AREAS = new Set(['records-forms', 'i18n', 'approvals', 'cli']); + + // --- QA-source lines are read in their one shape ----------------------- + battery('QA-source lines are read in their one shape'); + const canonical = parseQaSourceLines('Title line\n\nQA-source: #26450 · i18n.surface-matrix · n1\nmore'); + check(canonical.entries.length === 1 && canonical.malformed.length === 0, 'one canonical line → one entry'); + check(canonical.entries[0]?.record === 26450 && canonical.entries[0]?.locator === 'i18n.surface-matrix' && canonical.entries[0]?.clause === 'n1', 'record, locator and clause are read'); + const xrepo = parseQaSourceLines('QA-source: objectstack-ai/objectstack#25479 · approvals.record-page-decisions · negative[1]'); + check(xrepo.entries[0]?.repo === 'objectstack-ai/objectstack' && xrepo.entries[0]?.record === 25479, 'the owner/repo#N form names its repository'); + const quoted = parseQaSourceLines('- Carry `QA-source: #24783 · records-forms.x · acceptance[4]`, a revision bump'); + check(quoted.entries.length === 0 && quoted.malformed.length === 0, 'prose QUOTING a provenance line is not one'); + const twoField = parseQaSourceLines('QA-source: #26639 · observation 3'); + check(twoField.entries.length === 0 && /2 ·-separated field\(s\), expected 3/.test(twoField.malformed[0]?.reason ?? ''), 'a two-field line is malformed, by its field count'); + const noHash = parseQaSourceLines('QA-source: 18.1.0 re-cut thread · Docker image · Build and push'); + check(/record field/.test(noHash.malformed[0]?.reason ?? ''), 'a record field without #N is malformed, naming that field'); + const indented = parseQaSourceLines(' QA-source: #1 · cli.flag-command-error-ux · a0'); + check(indented.entries.length === 1, 'leading whitespace is allowed'); + const two = parseQaSourceLines('QA-source: #1 · cli.a-b · a0\nQA-source: #2 · cli.c-d · a1'); + check(two.entries.length === 2, 'every line of a card is read'); + check(parseQaSourceLines(null).entries.length === 0, 'an empty body reads as no lines, not a throw'); + + // --- locator kinds ------------------------------------------------------- + battery('locator kinds'); + check(locatorKind('records-forms.record-discussion-mentions') === 'item', 'area.item → item'); + check(locatorKind('smoke:npm-18.2.0') === 'npm-smoke', 'smoke:npm-VERSION → npm-smoke'); + check(locatorKind('smoke:npm-19.0.0-next.3') === 'npm-smoke', 'a prerelease smoke is a smoke'); + check(locatorKind('ux-walkthrough') === 'unrecognized', 'a bare word is not an item'); + check(locatorKind('records-forms') === 'unrecognized', 'an area alone is not an item'); + + // --- a record judges an item only on a verdict line ---------------------- + battery('a record judges an item only on a verdict line'); + const body = [ + '| item | revision | verdict | clauses |', + '|---|---|---|---|', + '| i18n.surface-matrix | 12 | PASS | 15 |', + '| `records-forms.field-history-tracking` | 9 | FAIL | 9 |', + '### approvals.inbox-keyboard-flow (r3) — PASS', + '| cli.verify-verdict-exit-mapping | 2 | unit | NOT-RUN | earlier PASS stands |', + '| records-forms.form-dirty-guard | 6 | BLOCKED | 4 |', + '- records-forms.cascading-options: pass (4/4), see records-forms.cascading-multilevel-and-clear', + 'Carried-forward: #100 · records-forms.carried · PASS', + 'The run named records-forms.mention-only in passing.', + '| host.example | 1 | PASS | 1 |', + '| `showcase-smoke.spec.ts` | 1 | PASS | 1 |', + ].join('\n'); + const seen = readRecordItems({ title: 'QA run · release-x (5/6) · aaaaaaaa · 2026-10-27 · 4 PASS / 1 FAIL', body }, AREAS); + check(seen.judged.get('i18n.surface-matrix')?.has('pass'), 'an item-level table row is a judgment'); + check(seen.judged.get('records-forms.field-history-tracking')?.has('fail'), 'a back-ticked id is read'); + check(seen.judged.get('approvals.inbox-keyboard-flow')?.has('pass'), 'a `### id — PASS` heading is a judgment'); + check(!seen.judged.has('cli.verify-verdict-exit-mapping') && seen.mentioned.has('cli.verify-verdict-exit-mapping'), 'a NOT-RUN line is a mention, never a judgment'); + check(!seen.judged.has('records-forms.form-dirty-guard'), 'BLOCKED alone consulted nothing'); + check(seen.judged.get('records-forms.cascading-options')?.has('pass'), 'a verdict bullet judges its first id'); + check(!seen.judged.has('records-forms.cascading-multilevel-and-clear'), 'a second id on a verdict line is a cross-reference, not a judgment'); + check(!seen.judged.has('records-forms.carried') && !seen.mentioned.has('records-forms.carried'), 'a Carried-forward line is not a judgment'); + check(seen.mentioned.has('records-forms.mention-only') && !seen.judged.has('records-forms.mention-only'), 'a mention without a verdict word is a mention'); + check(!seen.mentioned.has('host.example'), 'an id-shaped token outside the area vocabulary is not an item'); + check(!seen.judged.has('showcase-smoke.spec'), 'a filename is not an id'); + const single = readRecordItems({ title: 'QA run · cli.flag-command-error-ux (1/1) · bbbbbbbb · 2026-10-19 · 1 PASS', body: 'prose only' }, AREAS); + check(single.judged.get('cli.flag-command-error-ux')?.has('pass'), 'a record whose selector IS the item judged it, from the title'); + + // --- owed, re-judged and unknown are decided on subject time -------------- + battery('owed, re-judged and unknown are decided on subject time'); + const rec = (number, sha, created, extra = '') => ({ number, title: `QA run · release-x (1/1) · ${sha} · ${created.slice(0, 10)} · 1 PASS`, body: extra, created_at: created }); + const card = (number, closed, line, stateReason = 'completed') => ({ number, state: 'closed', state_reason: stateReason, closed_at: closed, body: `Repro.\n${line}\n` }); + const RECORDS = [ + rec(10, 'aaaaaaaa', '2026-10-27T05:00:00Z', '| i18n.surface-matrix | 1 | FAIL |'), + rec(20, 'bbbbbbbb', '2026-10-29T05:00:00Z', '| i18n.surface-matrix | 2 | PASS |\n| records-forms.x-y | 1 | PASS |'), + rec(30, 'cccccccc', '2026-10-30T05:00:00Z', '| approvals.a-b | 1 | PASS |'), + rec(40, 'dddddddd', '2026-10-26T05:00:00Z', 'Mentions cli.late-mention only.'), + ]; + const TIMES = new Map([ + ['aaaaaaaa', '2026-10-27T01:00:00Z'], + ['bbbbbbbb', '2026-10-29T03:00:00Z'], + ['cccccccc', null], + ['dddddddd', '2026-10-26T01:00:00Z'], + ]); + const CARDS = [ + card(101, '2026-10-27T09:00:00Z', 'QA-source: #10 · i18n.surface-matrix · n1'), + card(102, '2026-10-29T09:00:00Z', 'QA-source: #10 · i18n.surface-matrix · n2'), + card(103, '2026-10-28T09:00:00Z', 'QA-source: #30 · approvals.a-b · a0'), + card(104, '2026-10-31T09:00:00Z', 'QA-source: #30 · approvals.a-b · a1'), + card(105, '2026-10-26T09:00:00Z', 'QA-source: #40 · cli.late-mention · a0'), + ]; + const r1 = computeRetestOwed({ cards: CARDS, records: [...RECORDS, rec(50, 'eeeeeeee', '2026-10-30T00:00:00Z', 'The run touched cli.late-mention.')], subjectTimes: new Map([...TIMES, ['eeeeeeee', '2026-10-29T23:00:00Z']]), areas: new Set(['i18n', 'approvals', 'cli', 'records-forms']) }); + const row = (list, item) => list.find((r) => r.item === item); + check(row(r1.rejudged, 'i18n.surface-matrix')?.fixCards.map((c) => c.number).join() === '101', 'a fix closed before a later subject is re-judged'); + check(row(r1.rejudged, 'i18n.surface-matrix')?.rejudgedBy?.number === 20, 're-judged names the record whose subject postdates the fix'); + check(row(r1.owed, 'i18n.surface-matrix')?.fixCards.map((c) => c.number).join() === '102', 'a fix closed after the newest subject is owed'); + check(row(r1.owed, 'i18n.surface-matrix')?.lastJudged?.number === 20, 'owed names the record that last judged the item'); + check(row(r1.unknown, 'approvals.a-b')?.unresolved.join() === '30', 'an unresolved record filed after the fix makes the row unknown, never re-judged'); + check(row(r1.owed, 'approvals.a-b')?.fixCards.map((c) => c.number).join() === '104', 'an unresolved record filed BEFORE the fix cannot clear it'); + check(row(r1.owed, 'cli.late-mention')?.mentionedBy.join() === '50', 'a later record that only names the item is pointed at as unconfirmed'); + check(row(r1.owed, 'cli.late-mention')?.lastJudged?.number === 40, 'the QA-source record counts as a judge of its own item'); + check(!r1.owed.some((r) => r.item === 'records-forms.x-y'), 'a judged item no fix card names owes nothing'); + + // --- npm smoke cells are re-judged by the next smoke ---------------------- + battery('npm smoke cells are re-judged by the next smoke'); + const smokeRecs = [ + { number: 60, title: 'QA run · smoke:npm-18.2.0 (25/25) · ffffffff · 2026-10-27 · 22 PASS / 3 FAIL', body: 'A1 PASS', created_at: '2026-10-27T11:00:00Z' }, + { number: 70, title: 'QA run · smoke:npm-18.3.0 (25/25) · 99999999 · 2026-11-03 · 25 PASS', body: 'D3 PASS', created_at: '2026-11-03T11:00:00Z' }, + ]; + const smokeTimes = new Map([['ffffffff', '2026-10-26T20:00:00Z'], ['99999999', '2026-11-02T20:00:00Z']]); + const smokeCards = [card(201, '2026-10-27T17:00:00Z', 'QA-source: #60 · smoke:npm-18.2.0 · D3')]; + const s1 = computeRetestOwed({ cards: smokeCards, records: smokeRecs, subjectTimes: smokeTimes, areas: AREAS }); + check(s1.rejudged[0]?.item === 'smoke:npm-18.2.0 · D3' && s1.rejudged[0]?.rejudgedBy?.number === 70, 'the next smoke:npm-* record re-judges a smoke cell'); + const s2 = computeRetestOwed({ cards: smokeCards, records: [smokeRecs[0]], subjectTimes: smokeTimes, areas: AREAS }); + check(s2.owed[0]?.kind === 'npm-smoke' && s2.owed[0]?.lastJudged?.number === 60, 'before the next smoke, the cell is owed to it'); + check(s2.owed[0]?.itemStatus === null, 'a smoke cell carries no checklist status'); + + // --- nothing read is dropped ----------------------------------------------- + battery('nothing read is dropped'); + const odd = computeRetestOwed({ + cards: [ + card(301, '2026-10-27T15:00:00Z', 'QA-source: #26639 · observation 3'), + card(302, '2026-10-27T15:00:00Z', 'QA-source: #27377 · ux-walkthrough · track 2'), + card(303, '2026-10-27T15:00:00Z', 'QA-source: other/repo#5 · i18n.surface-matrix · n1'), + card(304, '2026-10-27T15:00:00Z', 'QA-source: #10 · i18n.surface-matrix · n1', 'not_planned'), + card(305, '2026-10-27T15:00:00Z', 'QA-source: #10 · i18n.gone-item · n1'), + { number: 306, state: 'open', state_reason: null, closed_at: null, body: 'QA-source: #10 · i18n.surface-matrix · n1' }, + ], + records: RECORDS, + subjectTimes: TIMES, + areas: new Set(['i18n']), + itemStatus: new Map([['i18n.surface-matrix', 'active']]), + repo: 'objectstack-ai/objectstack', + }); + check(odd.notRead.some((n) => n.card === 301 && /expected 3/.test(n.reason)), 'a malformed line is listed with its reason'); + check(odd.notRead.some((n) => n.card === 302 && /neither a checklist item id/.test(n.reason)), 'an unrecognised locator is listed with its reason'); + check(odd.notRead.some((n) => n.card === 303 && /other\/repo/.test(n.reason)), 'a record in another repository is listed, not guessed at'); + check(odd.notLanded.some((n) => n.card === 304 && n.stateReason === 'not_planned'), 'a card closed as not planned is listed as no landing'); + check(row(odd.owed, 'i18n.gone-item')?.itemStatus === 'absent', 'an item the checklist no longer has is still listed, marked absent'); + check(![...odd.owed, ...odd.rejudged, ...odd.unknown].some((r) => r.fixCards.some((c) => c.number === 306)), 'an open card has landed nothing'); + + // --- as-of replays the past --------------------------------------------------- + battery('as-of replays the past'); + const past = computeRetestOwed({ cards: CARDS, records: RECORDS, subjectTimes: TIMES, areas: AREAS, asOf: '2026-10-28T00:00:00Z' }); + check(row(past.owed, 'i18n.surface-matrix')?.fixCards.map((c) => c.number).join() === '101', 'a record filed after as-of does not clear an older fix'); + check(!past.owed.concat(past.rejudged).some((r) => r.fixCards.some((c) => c.number === 102)), 'a card closed after as-of is not yet a landing'); + check(!past.unknown.length && !past.owed.some((r) => r.item === 'approvals.a-b'), 'cards citing a record filed after as-of are not yet read'); + + // --- the render names every section ------------------------------------------ + battery('the render names every section'); + const md = renderMarkdown({ + computedOn: { repo: 'objectstack-ai/objectstack', asOf: null, apiCalls: 3, generatedAt: '2026-10-29T00:00:00.000Z' }, + corpus: { records: 4, recordsParsed: 4, since: '2026-08-11T00:00:00Z', scanned: 10, cards: 5, shas: 4, shasResolved: 3 }, + ...r1, + notRead: odd.notRead, + notLanded: odd.notLanded, + }); + check(md.includes('### Owed — '), 'the owed section is present'); + check(md.includes('### Unknown — '), 'the unknown section is present when a row is unknown'); + check(md.includes('### Re-judged — '), 'the re-judged section is present'); + check(md.includes('#102'), 'an owed fix card is printed'); + check(md.includes('unconfirmed: #50'), 'an unconfirmed pointer is printed'); + check(md.includes('observation 3'), 'a not-read line is printed'); + check(md.includes('#304 (not_planned)'), 'a closed-without-fix card is printed'); + check(md.includes('Nothing is written anywhere'), 'the view states that it writes nothing'); + + // --- a listing stays on the repository path --------------------------------- + battery('a listing stays on the repository path'); + const hop = onPathOf( + 'https://api.github.com/repos/o/r/issues?state=closed&per_page=100', + 'https://api.github.com/repositories/42/issues?state=closed&per_page=100&after=Y3Vy%3D', + ); + check(new URL(hop).pathname === '/repos/o/r/issues', 'the next link is followed on the /repos path, never /repositories/ID'); + check(new URL(hop).searchParams.get('after') === 'Y3Vy=' && new URL(hop).searchParams.get('state') === 'closed', 'the next link\'s cursor and filters are carried over'); + + for (const message of batteryFloorFailures()) failures.push(message); + if (failures.length > 0) { + console.error(`✗ retest-owed --self-test — ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); + } + console.log(`✓ retest-owed --self-test: ${checked} assertions in ${Object.keys(SELF_TEST_BATTERIES).length} batteries.`); + selfTestReachedVerdict = true; +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ retest-owed self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + } else { + await main(process.argv.slice(2)); + } +}