diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 15a134b796..2756977aa9 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -313,7 +313,8 @@ "tests/unit/usage-service-hardening.test.ts": 1633, "tests/unit/vscode-token-routes.test.ts": 1212, "tests/unit/combo-config.test.ts": 881, - "tests/unit/web-cookie-providers-new.test.ts": 845, + "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", + "tests/unit/web-cookie-providers-new.test.ts": 850, "tests/unit/response-sanitizer.test.ts": 906 }, "_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.", diff --git a/package.json b/package.json index 9c2fdfb7b6..b223ffbf0a 100644 --- a/package.json +++ b/package.json @@ -205,7 +205,9 @@ "uninstall:full": "node scripts/build/uninstall.mjs --full", "prepare": "husky", "system-info": "node scripts/dev/system-info.mjs", - "build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs" + "build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs", + "release:contributors": "node scripts/release/gen-contributors.mjs", + "release:uncovered": "node scripts/release/list-uncovered-commits.mjs" }, "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1073.0", diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index 2914db0b50..78091ce8ab 100644 --- a/scripts/quality/validate-release-green.mjs +++ b/scripts/quality/validate-release-green.mjs @@ -146,7 +146,7 @@ function run(cmd, cmdArgs, opts = {}) { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024, - env: { ...process.env, FORCE_COLOR: "0" }, + env: { ...process.env, FORCE_COLOR: "0", ...(opts.env || {}) }, // A hard ceiling for the long, silent test suites (execFileSync buffers all output until // exit, so they show no progress while running). undefined = no timeout for fast gates. ...(opts.timeout ? { timeout: opts.timeout } : {}), @@ -255,6 +255,27 @@ function main() { }); } + // test-masking (hard) — a PR-context gate: it only runs on the release PR (PR→main) in CI, so + // net-assert reductions accrue unseen on release/** and explode on the release PR. Reproduce it + // here against origin/main so a non-allowlisted reduction surfaces in the pre-flight, not in a + // ~40-min CI layer (v3.8.43 cost 3 such round-trips). Legitimate reductions get allowlisted in + // config/quality/test-masking-allowlist.json; tautology/skip/deletion signals are never allowlistable. + if (!QUICK) { + announce("Test-masking (weakened-assert guard vs main)"); + // best-effort fetch so the merge-base diff is accurate; ignore fetch failure (offline pre-flight) + run("git", ["fetch", "--no-tags", "origin", "main", "--depth=200"], { timeout: 60 * 1000 }); + const { code, out } = run(npmCmd, ["run", "check:test-masking"], { + env: { GITHUB_BASE_REF: "main" }, + }); + record({ + id: "test-masking", + label: "Test-masking (weakened-assert guard)", + kind: "hard", + ok: code === 0, + detail: code === 0 ? "no weakening" : firstFailureLine(out), + }); + } + // Remaining quality-gate / quality-extended ratchets that the PR→release // fast-gates skip and that historically surfaced — one at a time, because the // CI Quality Ratchet job is fail-fast — only on the release PR. Running them all diff --git a/scripts/release/gen-contributors.mjs b/scripts/release/gen-contributors.mjs new file mode 100644 index 0000000000..660fbb5893 --- /dev/null +++ b/scripts/release/gen-contributors.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// Generate (or inject) the `### 🙌 Contributors` table for a CHANGELOG version section. +// +// WHY: every version's CHANGELOG `## [vX.Y.Z]` section MUST end with a `### 🙌 Contributors` +// table (the convention across every prior version). v3.8.43 shipped without it (a real miss the +// owner caught) because it was assembled by hand. This makes it reproducible + accurate. +// +// A naive `@handle` scan mis-assigns rollup PRs — a maintenance bullet lists many PRs under one +// `— thanks @X`, and a flat scan would credit every handle on the line with all of them. This +// parses each `([#refs] — thanks @X / @Y)` PARENTHETICAL GROUP and assigns that group's refs only +// to that group's handles (crediting is per-parenthetical, matching how bullets are written). +// +// Usage: +// node scripts/release/gen-contributors.mjs # print the table +// node scripts/release/gen-contributors.mjs --inject # insert/replace it in CHANGELOG.md +// +// Exit codes: 0 ok · 2 version section not found · 3 nothing to inject over. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +// Handles that are package names / code refs / scopes, never people. Extend as needed. +export const NOISE_HANDLES = new Set([ + "toon-format", + "dnd-kit", + "om-usage", + "anthropic-ai", + "huggingface", + "oven", + "latest", + "next", + "types", +]); + +const MAINTAINER = "diegosouzapw"; + +/** Extract the `## [version]` … up to the next `## [` section body (exclusive of the next header). */ +export function extractVersionSection(changelog, version) { + const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const startRe = new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m"); + const sm = changelog.match(startRe); + if (!sm) return null; + const bodyStart = sm.index + sm[0].length; + const rest = changelog.slice(bodyStart); + const nextIdx = rest.search(/\n## \[/); + return nextIdx === -1 ? rest : rest.slice(0, nextIdx); +} + +/** + * Parse contributor → set of ref numbers from a version section body. + * Rules (in order, per bullet line starting with "- "): + * 1. Parenthetical groups containing "thanks": refs in the group → handles in the group. + * 2. A "thanks @X" NOT inside such a group (direct-commit trailing credit): the last ref before + * it on the line (if any) → the handles. + * 3. "Extracted from [#N] by [@X]": N → X. + * Excludes NOISE_HANDLES and the maintainer (returned separately by caller). + */ +export function parseContributors(sectionText) { + const agg = new Map(); // handle -> Set(refs) + const add = (handle, refs) => { + if (NOISE_HANDLES.has(handle) || handle === MAINTAINER) return; + if (!agg.has(handle)) agg.set(handle, new Set()); + for (const r of refs) agg.get(handle).add(r); + }; + const handlesIn = (s) => [...s.matchAll(/@([A-Za-z0-9_-]+)/g)].map((m) => m[1]); + const refsIn = (s) => [...s.matchAll(/#(\d+)/g)].map((m) => Number(m[1])); + + for (const raw of sectionText.split("\n")) { + if (!raw.startsWith("- ")) continue; + // Collapse markdown links so parenthetical groups aren't broken by the URL's own parens: + // [#5720](https://…/pull/5720) → #5720 · [@pizzav-xyz](https://…) → @pizzav-xyz + const line = raw + .replace(/\[#(\d+)\]\([^)]*\)/g, "#$1") + .replace(/\[@([A-Za-z0-9_-]+)\]\([^)]*\)/g, "@$1"); + const usedSpans = []; + + // (1) parenthetical groups with "thanks" + for (const g of line.matchAll(/\(([^()]*thanks[^()]*)\)/g)) { + const inner = g[1]; + const refs = refsIn(inner); + for (const th of inner.matchAll(/thanks\s+((?:@[A-Za-z0-9_-]+(?:\s*\/\s*)?)+)/g)) { + for (const h of handlesIn(th[1])) add(h, refs); + } + usedSpans.push([g.index, g.index + g[0].length]); + } + + // (2) trailing "— thanks @X" outside any used parenthetical (direct commits) + for (const th of line.matchAll(/thanks\s+((?:@[A-Za-z0-9_-]+(?:\s*\/\s*)?)+)/g)) { + const inGroup = usedSpans.some(([s, e]) => th.index >= s && th.index < e); + if (inGroup) continue; + const before = line.slice(0, th.index); + const refsBefore = refsIn(before); + const refs = refsBefore.length ? [refsBefore[refsBefore.length - 1]] : []; + for (const h of handlesIn(th[1])) add(h, refs); + } + + // (3) "Extracted from #N by @X" (links already collapsed by the preprocessing above) + for (const em of line.matchAll(/[Ee]xtracted from #(\d+)\s+by\s+@([A-Za-z0-9_-]+)/g)) { + add(em[2], [Number(em[1])]); + } + } + return agg; +} + +export function renderContributors(version, agg, maintainerNote = "maintainer") { + const fmt = (set) => + set.size + ? [...set] + .sort((a, b) => a - b) + .map((n) => `#${n}`) + .join(", ") + : "direct commit / report"; + const rows = [...agg.entries()].sort((a, b) => + a[0].toLowerCase().localeCompare(b[0].toLowerCase()) + ); + const lines = [ + "### 🙌 Contributors", + "", + `Thanks to everyone whose work landed in v${version}:`, + "", + "| Contributor | PRs / Issues |", + "| --- | --- |", + ]; + for (const [h, refs] of rows) { + lines.push(`| [@${h}](https://github.com/${h}) | ${fmt(refs)} |`); + } + lines.push(`| [@${MAINTAINER}](https://github.com/${MAINTAINER}) | ${maintainerNote} |`); + return lines.join("\n"); +} + +/** Insert or replace the Contributors section inside the version block, before its closing `---`. */ +export function injectContributors(changelog, version, table) { + const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const startRe = new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m"); + const sm = changelog.match(startRe); + if (!sm) return null; + const headerEnd = sm.index + sm[0].length; + const rest = changelog.slice(headerEnd); + const nextIdx = rest.search(/\n## \[/); + const bodyEnd = nextIdx === -1 ? changelog.length : headerEnd + nextIdx; + let body = changelog.slice(headerEnd, bodyEnd); + // strip an existing Contributors section (idempotent re-run) + body = body.replace(/\n### 🙌 Contributors[\s\S]*?(?=\n---\n|$)/, "\n"); + // insert before the trailing `---` (or append if none) + const idx = body.lastIndexOf("\n---"); + const insertion = `\n${table}\n`; + body = idx >= 0 ? body.slice(0, idx) + insertion + body.slice(idx) : `${body}${insertion}\n---\n`; + return changelog.slice(0, headerEnd) + body + changelog.slice(bodyEnd); +} + +function main(argv) { + const version = argv[0]; + const inject = argv.includes("--inject"); + if (!version || !/^\d+\.\d+\.\d+$/.test(version)) { + process.stderr.write("usage: gen-contributors.mjs [--inject]\n"); + process.exit(1); + } + const clPath = path.join(ROOT, "CHANGELOG.md"); + const changelog = fs.readFileSync(clPath, "utf8"); + const section = extractVersionSection(changelog, version); + if (section == null) { + process.stderr.write(`No [${version}] section in CHANGELOG.md\n`); + process.exit(2); + } + const agg = parseContributors(section); + const table = renderContributors(version, agg); + if (!inject) { + process.stdout.write(table + "\n"); + return; + } + const next = injectContributors(changelog, version, table); + if (next == null) { + process.stderr.write(`Could not locate [${version}] block for injection\n`); + process.exit(3); + } + fs.writeFileSync(clPath, next); + process.stderr.write(`✓ Injected ${agg.size} external contributor(s) into [${version}]\n`); +} + +// direct-run guard (importable for tests) +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +} diff --git a/scripts/release/list-uncovered-commits.mjs b/scripts/release/list-uncovered-commits.mjs new file mode 100644 index 0000000000..ba8127c124 --- /dev/null +++ b/scripts/release/list-uncovered-commits.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// Reconciliation helper: list non-merge commits since the last tag whose PR/issue ref is NOT +// represented in the current version's CHANGELOG section (or [Unreleased]). +// +// WHY: during the cycle, PRs merge into release/** and some land WITHOUT a CHANGELOG bullet, so +// /generate-release reconciliation has to rediscover them by hand (v3.8.43: 123 of 176 commits had +// no bullet). This surfaces exactly that gap in seconds — maintainer-side, non-blocking, run it at +// reconciliation (Phase 0a) so the release CHANGELOG is complete before the PR opens. +// +// A commit is "covered" iff ANY `#N` in its subject appears anywhere in the CHANGELOG scan window +// (the version section + [Unreleased]) — matching on issue OR PR number, since a bullet may cite +// either. Internal commits (chore/ci/test/refactor) are listed under "rollup candidates" so the +// maintainer can consolidate rather than write one bullet each. +// +// Usage: node scripts/release/list-uncovered-commits.mjs [--json] +// Exit: 0 always (advisory). Prints a report to stdout. + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const git = (args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim(); + +const ROLLUP_TYPES = new Set(["chore", "ci", "test", "refactor", "build", "docs", "style"]); + +export function refsOf(subject) { + return [...subject.matchAll(/#(\d+)/g)].map((m) => Number(m[1])); +} + +export function typeOf(subject) { + const m = subject.match(/^([a-z]+)(\(|:|!)/); + return m ? m[1] : "other"; +} + +/** + * @param {{hash:string, subject:string}[]} commits + * @param {Set} changelogRefs every #N present in the CHANGELOG scan window + * @returns {{covered:number, uncovered:{hash,subject,refs,type,rollup}[]}} + */ +export function computeUncovered(commits, changelogRefs) { + const uncovered = []; + let covered = 0; + for (const c of commits) { + const refs = refsOf(c.subject); + const isCovered = refs.length > 0 && refs.some((r) => changelogRefs.has(r)); + if (isCovered) { + covered++; + } else { + const type = typeOf(c.subject); + uncovered.push({ ...c, refs, type, rollup: ROLLUP_TYPES.has(type) }); + } + } + return { covered, uncovered }; +} + +/** Read every #N in the version's CHANGELOG section + the [Unreleased] section. */ +export function changelogRefWindow(changelog, version) { + const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // From [Unreleased] up to (but excluding) the version-after-this one. + const startRe = /^## \[Unreleased\]/m; + const s = changelog.match(startRe); + const from = s ? s.index : 0; + // find the header AFTER the target version + const verRe = new RegExp(`^## \\[${esc}\\]`, "m"); + const vm = changelog.slice(from).match(verRe); + const afterVersionStart = vm ? from + vm.index + vm[0].length : from; + const rest = changelog.slice(afterVersionStart); + const nextIdx = rest.search(/\n## \[/); + const to = nextIdx === -1 ? changelog.length : afterVersionStart + nextIdx; + const window = changelog.slice(from, to); + return new Set([...window.matchAll(/#(\d+)/g)].map((m) => Number(m[1]))); +} + +function main(argv) { + const jsonOut = argv.includes("--json"); + const lastTag = git(["describe", "--tags", "--abbrev=0"]); + const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version; + const log = git(["log", "--no-merges", `${lastTag}..HEAD`, "--pretty=format:%h%x09%s"]); + const commits = log + ? log.split("\n").map((l) => { + const [hash, subject] = l.split("\t"); + return { hash, subject }; + }) + : []; + const changelog = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8"); + const refs = changelogRefWindow(changelog, version); + const { covered, uncovered } = computeUncovered(commits, refs); + + if (jsonOut) { + process.stdout.write( + JSON.stringify({ version, lastTag, total: commits.length, covered, uncovered }, null, 2) + + "\n" + ); + return; + } + const bulletsWorthy = uncovered.filter((c) => !c.rollup); + const rollupCandidates = uncovered.filter((c) => c.rollup); + process.stdout.write(`# Uncovered-commit reconciliation — v${version} (${lastTag}..HEAD)\n\n`); + process.stdout.write( + `Commits: ${commits.length} · covered: ${covered} · uncovered: ${uncovered.length}\n\n` + ); + process.stdout.write( + `## Needs a bullet (feat/fix/other — user-facing) — ${bulletsWorthy.length}\n` + ); + for (const c of bulletsWorthy) process.stdout.write(`- ${c.hash} ${c.subject}\n`); + process.stdout.write( + `\n## Rollup candidates (chore/ci/test/refactor/docs) — ${rollupCandidates.length}\n` + ); + for (const c of rollupCandidates) process.stdout.write(`- ${c.hash} ${c.subject}\n`); + process.stdout.write( + `\n> Advisory. Add a bullet for each user-facing item; consolidate rollup candidates into a few Maintenance bullets (list their PR numbers).\n` + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +} diff --git a/tests/unit/gen-contributors.test.ts b/tests/unit/gen-contributors.test.ts new file mode 100644 index 0000000000..d6ba809f8e --- /dev/null +++ b/tests/unit/gen-contributors.test.ts @@ -0,0 +1,110 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const mod = await import("../../scripts/release/gen-contributors.mjs"); +const { + extractVersionSection, + parseContributors, + renderContributors, + injectContributors, + NOISE_HANDLES, +} = mod; + +const FIXTURE = `# Changelog + +## [Unreleased] + +--- + +## [3.9.0] — 2026-08-01 + +### ✨ New Features + +- **feat(a):** thing one. ([#100](https://github.com/x/y/pull/100) — thanks @alice) +- **feat(b):** uses \`@toon-format/toon\` and \`@dnd-kit\`. ([#101](https://github.com/x/y/pull/101) — thanks @bob) + +### 🔧 Bug Fixes + +- **fix(c):** direct commit fix. (thanks @carol) +- **fix(d):** extracted. Extracted from [#102](https://github.com/x/y/pull/102) by [@dave](https://github.com/dave). + +### 📝 Maintenance + +- **refactor(rollup):** god-file split ([#200](https://github.com/x/y/pull/200), [#201](https://github.com/x/y/pull/201) — thanks @erin); editorconfig ([#202](https://github.com/x/y/pull/202) — thanks @frank). — thanks @diegosouzapw + +--- + +## [3.8.99] — 2026-07-31 + +### 🔧 Bug Fixes + +- **fix(z):** other version, must not leak. ([#999](https://github.com/x/y/pull/999) — thanks @zoe) + +--- +`; + +test("extractVersionSection returns only the target version body (not the next section)", () => { + const sec = extractVersionSection(FIXTURE, "3.9.0"); + assert.ok(sec.includes("thing one"), "includes 3.9.0 content"); + assert.ok(!sec.includes("must not leak"), "excludes 3.8.99 content"); + assert.ok(!sec.includes("#999"), "does not bleed into next version"); +}); + +test("parseContributors credits per parenthetical group, not a flat scan", () => { + const agg = parseContributors(extractVersionSection(FIXTURE, "3.9.0")); + // rollup: erin gets 200+201, frank gets 202 — NOT both getting all three + assert.deepEqual( + [...agg.get("erin")].sort((a, b) => a - b), + [200, 201] + ); + assert.deepEqual([...agg.get("frank")], [202]); + // simple bullets + assert.deepEqual([...agg.get("alice")], [100]); + // direct-commit credit with no PR ref + assert.ok(agg.has("carol") && agg.get("carol").size === 0); + // "Extracted from #N by @X" + assert.deepEqual([...agg.get("dave")], [102]); +}); + +test("noise handles and the maintainer are excluded from the contributor map", () => { + const agg = parseContributors(extractVersionSection(FIXTURE, "3.9.0")); + assert.ok(!agg.has("toon-format"), "package scope is not a contributor"); + assert.ok(!agg.has("dnd-kit"), "package scope is not a contributor"); + assert.ok(!agg.has("diegosouzapw"), "maintainer is rendered separately, not in the map"); + assert.ok(NOISE_HANDLES.has("toon-format")); +}); + +test("renderContributors emits an alphabetical table with maintainer last", () => { + const agg = parseContributors(extractVersionSection(FIXTURE, "3.9.0")); + const table = renderContributors("3.9.0", agg); + assert.ok(table.startsWith("### 🙌 Contributors")); + const rows = table.split("\n").filter((l) => l.startsWith("| [@")); + const handles = rows.map((r) => r.match(/@([A-Za-z0-9_-]+)/)[1]); + assert.equal(handles[handles.length - 1], "diegosouzapw", "maintainer is last"); + const external = handles.slice(0, -1); + assert.deepEqual( + external, + [...external].sort((a, b) => a.localeCompare(b)), + "external sorted" + ); + assert.ok(table.includes("| [@carol](https://github.com/carol) | direct commit / report |")); +}); + +test("injectContributors inserts before the closing --- and is idempotent", () => { + const once = injectContributors( + FIXTURE, + "3.9.0", + renderContributors("3.9.0", parseContributors(extractVersionSection(FIXTURE, "3.9.0"))) + ); + assert.ok(once.includes("### 🙌 Contributors"), "section injected"); + // 3.8.99 untouched + assert.ok(once.includes("must not leak")); + // idempotent: injecting again does not duplicate + const twice = injectContributors( + once, + "3.9.0", + renderContributors("3.9.0", parseContributors(extractVersionSection(once, "3.9.0"))) + ); + const count = (twice.match(/### 🙌 Contributors/g) || []).length; + assert.equal(count, 1, "no duplicate Contributors section on re-run"); +}); diff --git a/tests/unit/list-uncovered-commits.test.ts b/tests/unit/list-uncovered-commits.test.ts new file mode 100644 index 0000000000..a49cbcecfa --- /dev/null +++ b/tests/unit/list-uncovered-commits.test.ts @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const mod = await import("../../scripts/release/list-uncovered-commits.mjs"); +const { refsOf, typeOf, computeUncovered, changelogRefWindow } = mod; + +test("refsOf extracts every #N from a subject", () => { + assert.deepEqual(refsOf("fix(x): thing (#5842) (#5901)"), [5842, 5901]); + assert.deepEqual(refsOf("chore: no refs here"), []); +}); + +test("typeOf reads the conventional-commit type", () => { + assert.equal(typeOf("feat(api): x"), "feat"); + assert.equal(typeOf("fix: y"), "fix"); + assert.equal(typeOf("refactor(db)!: z"), "refactor"); + assert.equal(typeOf("Merge branch main"), "other"); +}); + +test("computeUncovered: a commit is covered iff ANY of its refs is in the changelog window", () => { + const commits = [ + { hash: "a1", subject: "fix(x): covered by issue ref (#100)" }, // issue 100 in changelog + { hash: "b2", subject: "feat(y): uncovered feature (#200)" }, // 200 not in changelog + { hash: "c3", subject: "refactor(z): internal (#300)" }, // rollup type, uncovered + { hash: "d4", subject: "chore: no ref at all" }, // no ref → uncovered, rollup + ]; + const refs = new Set([100]); // only #100 is documented + const { covered, uncovered } = computeUncovered(commits, refs); + assert.equal(covered, 1); + assert.equal(uncovered.length, 3); + const byHash = Object.fromEntries(uncovered.map((c) => [c.hash, c])); + assert.equal(byHash.b2.rollup, false, "feat is user-facing, not a rollup candidate"); + assert.equal(byHash.c3.rollup, true, "refactor is a rollup candidate"); + assert.equal(byHash.d4.rollup, true, "chore is a rollup candidate"); +}); + +test("changelogRefWindow scans [Unreleased] + the version section but not older versions", () => { + const cl = `# Changelog + +## [Unreleased] + +- **fix:** something ([#10](u)) + +--- + +## [3.9.0] — x + +### 🔧 Bug Fixes + +- **fix(a):** landed ([#20](u)) + +--- + +## [3.8.99] — y + +- **fix(old):** must not count ([#999](u)) + +--- +`; + const refs = changelogRefWindow(cl, "3.9.0"); + assert.ok(refs.has(10), "picks up [Unreleased] refs"); + assert.ok(refs.has(20), "picks up the target version refs"); + assert.ok(!refs.has(999), "does NOT bleed into the previous version"); +}); diff --git a/tests/unit/validate-release-green.test.ts b/tests/unit/validate-release-green.test.ts index b31be512b9..d2d165bd53 100644 --- a/tests/unit/validate-release-green.test.ts +++ b/tests/unit/validate-release-green.test.ts @@ -121,3 +121,22 @@ test("classifyRunError: a kill WITHOUT a configured timeout is not misreported a assert.equal(r.code, 1); assert.doesNotMatch(r.out, /ceiling/); }); + +test("pre-flight wires the test-masking PR-context gate against origin/main (v3.8.43 gap fix)", async () => { + const fs = await import("node:fs"); + const src = fs.readFileSync( + new URL("../../scripts/quality/validate-release-green.mjs", import.meta.url), + "utf8" + ); + // The gate must run check:test-masking, pin the base to main, and be classified HARD — + // it caught a real net-assert reduction that only surfaced on the release PR before. + assert.match(src, /check:test-masking/, "test-masking gate must be wired into the pre-flight"); + assert.match(src, /GITHUB_BASE_REF:\s*"main"/, "test-masking must diff against origin/main"); + assert.match( + src, + /id:\s*"test-masking"[\s\S]*?kind:\s*"hard"/, + "test-masking must be a HARD gate (non-allowlisted weakening blocks the release)" + ); + // run() must honor a per-gate env override so GITHUB_BASE_REF actually reaches the child. + assert.match(src, /\.\.\.\(opts\.env \|\| \{\}\)/, "run() must merge opts.env into the child env"); +});