diff --git a/changelog.d/maintenance/6878-list-uncovered-fragments.md b/changelog.d/maintenance/6878-list-uncovered-fragments.md new file mode 100644 index 0000000000..de5fb61642 --- /dev/null +++ b/changelog.d/maintenance/6878-list-uncovered-fragments.md @@ -0,0 +1 @@ +- **release:** `list-uncovered-commits.mjs` now unions the CHANGELOG scan window with `changelog.d/` fragment refs (filename `-` prefix + every `#N` in the body), so a commit covered only by a fragment is no longer reported as an uncovered reconciliation gap ([#6857](https://github.com/diegosouzapw/OmniRoute/issues/6857) via [#6878](https://github.com/diegosouzapw/OmniRoute/pull/6878)) diff --git a/scripts/release/list-uncovered-commits.mjs b/scripts/release/list-uncovered-commits.mjs index ba8127c124..b2a5ae819b 100644 --- a/scripts/release/list-uncovered-commits.mjs +++ b/scripts/release/list-uncovered-commits.mjs @@ -55,6 +55,68 @@ export function computeUncovered(commits, changelogRefs) { return { covered, uncovered }; } +// Subdirectories that hold changelog.d fragments (fragments-first, #6783). +const FRAGMENT_DIRS = ["features", "fixes", "maintenance"]; + +/** + * Read the leading `-` PR/issue number from a changelog.d fragment filename. + * Some fragments (e.g. 6708, 6709) carry NO `#N` in their body, so the filename is the only + * place the PR number appears — it must still count as covering that PR. + * @param {string} filename bare name or a path ending in the fragment file + * @returns {number|null} + */ +export function fragmentFilenameRef(filename) { + const base = String(filename).replace(/^.*[\\/]/, ""); + const m = base.match(/^(\d+)-/); + return m ? Number(m[1]) : null; +} + +/** + * Collect every ref "covered" by changelog.d fragments: the leading `-` of each fragment + * filename PLUS every `#N` inside its body. + * @param {{name:string, body:string}[]} fragments + * @returns {Set} + */ +export function fragmentRefs(fragments) { + const refs = new Set(); + for (const f of fragments || []) { + const fromName = fragmentFilenameRef(f.name); + if (fromName != null) refs.add(fromName); + for (const m of String(f.body || "").matchAll(/#(\d+)/g)) refs.add(Number(m[1])); + } + return refs; +} + +/** + * Union of the CHANGELOG scan window refs and the changelog.d fragment refs. Since fragments-first + * (#6783) a merged PR's changelog entry usually lives in a fragment and is only folded into + * CHANGELOG.md at release time, so scanning CHANGELOG.md alone reports fragment-covered commits as + * uncovered (#6857). + * @param {string} changelog + * @param {string} version + * @param {{name:string, body:string}[]} fragments + * @returns {Set} + */ +export function collectChangelogRefs(changelog, version, fragments) { + const refs = changelogRefWindow(changelog, version); + for (const r of fragmentRefs(fragments)) refs.add(r); + return refs; +} + +/** Read all changelog.d fragment files (name + body) from disk under `root`. */ +export function readChangelogFragments(root) { + const out = []; + for (const sub of FRAGMENT_DIRS) { + const dir = path.join(root, "changelog.d", sub); + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (!name.endsWith(".md") || name === "README.md") continue; + out.push({ name, body: fs.readFileSync(path.join(dir, name), "utf8") }); + } + } + return out; +} + /** Read every #N in the version's CHANGELOG section + the [Unreleased] section. */ export function changelogRefWindow(changelog, version) { const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -85,7 +147,8 @@ function main(argv) { }) : []; const changelog = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8"); - const refs = changelogRefWindow(changelog, version); + const fragments = readChangelogFragments(ROOT); + const refs = collectChangelogRefs(changelog, version, fragments); const { covered, uncovered } = computeUncovered(commits, refs); if (jsonOut) { diff --git a/tests/unit/list-uncovered-commits.test.ts b/tests/unit/list-uncovered-commits.test.ts index a49cbcecfa..5ed0d1fe4b 100644 --- a/tests/unit/list-uncovered-commits.test.ts +++ b/tests/unit/list-uncovered-commits.test.ts @@ -2,7 +2,15 @@ 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; +const { + refsOf, + typeOf, + computeUncovered, + changelogRefWindow, + fragmentFilenameRef, + fragmentRefs, + collectChangelogRefs, +} = mod; test("refsOf extracts every #N from a subject", () => { assert.deepEqual(refsOf("fix(x): thing (#5842) (#5901)"), [5842, 5901]); @@ -61,3 +69,62 @@ test("changelogRefWindow scans [Unreleased] + the version section but not older assert.ok(refs.has(20), "picks up the target version refs"); assert.ok(!refs.has(999), "does NOT bleed into the previous version"); }); + +// ── changelog.d fragment coverage (#6857) ──────────────────────────────────── +// Since fragments-first (#6783) a merged PR's changelog entry usually lives in +// changelog.d/{features,fixes,maintenance}/-.md, NOT in CHANGELOG.md yet. +// A commit whose #N only exists in a fragment must count as covered. + +test("fragmentFilenameRef reads the leading - PR number from a fragment filename", () => { + assert.equal(fragmentFilenameRef("6708-gemma4-thinkingconfig-guard.md"), 6708); + assert.equal(fragmentFilenameRef("features/6072-ws-server.md"), 6072); + assert.equal(fragmentFilenameRef("README.md"), null, "non-numeric prefix → null"); + assert.equal(fragmentFilenameRef(".gitkeep"), null); +}); + +test("fragmentRefs unions filename PR numbers with every #N in the body", () => { + const fragments = [ + // no #N in the body — the ref lives ONLY in the filename (real case: 6708) + { name: "6708-gemma4-thinkingconfig-guard.md", body: "- **fix(sse):** skip thinkingConfig\n" }, + // body cites additional refs beyond the filename + { name: "6709-xai-responses.md", body: "- **feat:** xAI (#6710 relates #6711)\n" }, + ]; + const refs = fragmentRefs(fragments); + assert.ok(refs.has(6708), "filename-only PR number is covered"); + assert.ok(refs.has(6709), "filename PR number of a body-ref fragment"); + assert.ok(refs.has(6710), "body #N is covered"); + assert.ok(refs.has(6711), "every body #N is covered"); +}); + +test("collectChangelogRefs unions the CHANGELOG window with changelog.d fragment refs", () => { + const cl = `# Changelog + +## [Unreleased] + +## [3.9.0] — x + +- **fix(a):** landed ([#20](u)) + +--- +`; + const fragments = [{ name: "6708-gemma4.md", body: "- **fix(sse):** guard\n" }]; + const refs = collectChangelogRefs(cl, "3.9.0", fragments); + assert.ok(refs.has(20), "still includes CHANGELOG.md refs"); + assert.ok(refs.has(6708), "ALSO includes fragment-only refs (the #6857 bug)"); +}); + +test("computeUncovered: a fragment-only ref counts as covered (end-to-end #6857)", () => { + // Repro: PR 6708 merged with a fragment (no #N in body, no CHANGELOG bullet yet). + const cl = "# Changelog\n\n## [Unreleased]\n\n## [3.9.0] — x\n\n---\n"; + const fragments = [{ name: "6708-gemma4.md", body: "- **fix(sse):** guard\n" }]; + const commits = [{ hash: "aa", subject: "fix(sse): gemma thinkingConfig guard (#6708)" }]; + + // BEFORE the fix, only the CHANGELOG window is scanned → the commit looks uncovered. + const windowOnly = computeUncovered(commits, changelogRefWindow(cl, "3.9.0")); + assert.equal(windowOnly.covered, 0, "sanity: CHANGELOG alone does not cover the fragment PR"); + + // AFTER the fix, the fragment ref set covers it. + const withFragments = computeUncovered(commits, collectChangelogRefs(cl, "3.9.0", fragments)); + assert.equal(withFragments.covered, 1, "fragment ref makes the commit covered"); + assert.equal(withFragments.uncovered.length, 0); +});