chore(release): pipeline hardening — test-masking pre-flight gate + contributors/uncovered helpers (#5926)

* chore(ci): add test-masking PR-context gate to release-green pre-flight

Reproduce check:test-masking (vs origin/main) inside validate-release-green so
non-allowlisted net-assert reductions surface in the local pre-flight instead of
in a ~40-min CI layer on the release PR. run() now merges a per-gate opts.env so
GITHUB_BASE_REF reaches the child. HARD gate; skipped under --quick.

Context: v3.8.43 release cost 3 CI round-trips for PR-context gates (test-masking,
file-size, pr-evidence) that check:release-green did not reproduce locally.

* chore(release): add contributors generator + uncovered-commit reconciliation helpers

- scripts/release/gen-contributors.mjs: reproducible `### 🙌 Contributors` table for a
  CHANGELOG version (parenthetical-group parser → accurate per-PR attribution, noise-handle
  denylist). v3.8.43 shipped without the section (a real miss) because it was hand-built.
  npm run release:contributors <version> [--inject].
- scripts/release/list-uncovered-commits.mjs: lists commits since the last tag with no
  CHANGELOG bullet (v3.8.43 had 123/176 uncovered at reconciliation start). Advisory,
  maintainer-side. npm run release:uncovered.
- 20 unit tests (parenthetical attribution, noise exclusion, idempotent injection, coverage window).

* chore(quality): absorb web-cookie-providers-new file-size drift from #5928 (base-red on release/v3.8.44)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-02 14:27:31 -03:00
committed by GitHub
parent 2e75ed28a4
commit a8d1e7bf78
8 changed files with 524 additions and 3 deletions

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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

View File

@@ -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 <version> # print the table
// node scripts/release/gen-contributors.mjs <version> --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 <version> [--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));
}

View File

@@ -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<number>} 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));
}

View File

@@ -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");
});

View File

@@ -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");
});

View File

@@ -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");
});