mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-27 01:22:10 +03:00
Retargetado para release/v3.8.51 (release/v3.8.50 está congelada — freeze issue #11439). Validado em lote combinado (batch-0824h2, junto de #11435/#11436/#11437) contra o tip de release/v3.8.51: typecheck:core limpo, gates estáticos OK, 127/127 testes focados passando. Investigação sólida com repro real via container isolado, três causas independentes identificadas e corrigidas com testes de regressão dedicados para cada uma. Obrigado pela contribuição!
176 lines
7.1 KiB
TypeScript
176 lines
7.1 KiB
TypeScript
/**
|
|
* Guard for the back-merge fragment sweep.
|
|
*
|
|
* The release reaches `main` as one squash commit, so `main` still holds every `changelog.d/`
|
|
* fragment the reconciliation folded in and deleted. When the release branch back-merges
|
|
* `main`, git restores all of them — 191 in the v3.8.49 run. Nothing looks broken at that
|
|
* moment, which is exactly why it bites: the NEXT aggregation folds them in a second time and
|
|
* the section grows duplicates that have to be hand-unpicked.
|
|
*
|
|
* The asymmetry these tests pin matters more than the happy path. Keeping a duplicate is a
|
|
* visible nuisance someone will notice. Deleting a fragment that never shipped silently drops
|
|
* a contributor's credit. So every ambiguous case must resolve to KEEP.
|
|
*/
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
// @ts-expect-error — plain .mjs release script, no type declarations by design
|
|
import {
|
|
classifyFragments,
|
|
normalizeBullet,
|
|
refsIn,
|
|
summarizeStale,
|
|
} from "../../scripts/release/sweep-stale-fragments.mjs";
|
|
|
|
const CHANGELOG = `# Changelog
|
|
|
|
## [Unreleased]
|
|
|
|
## [3.8.49] - 2026-07-30
|
|
|
|
### 🐛 Bug Fixes
|
|
|
|
- **fix(sse):** stop the retry loop double-counting attempts ([#8100](https://github.com/x/y/pull/8100)) — thanks @someone
|
|
- **fix(db):** migration 109 is idempotent again ([#8101](https://github.com/x/y/pull/8101))
|
|
|
|
### 📝 Maintenance
|
|
|
|
- **chore(ci):** pin the workflow action digests, no issue number on this one
|
|
`;
|
|
|
|
test("a fragment already folded into the changelog is stale", () => {
|
|
const { stale, keep } = classifyFragments({
|
|
changelog: CHANGELOG,
|
|
fragments: [{ name: "8100-retry.md", rel: "changelog.d/fixes/8100-retry.md", body: "- **fix(sse):** stop the retry loop double-counting attempts ([#8100](https://x/y/pull/8100))" }],
|
|
});
|
|
assert.equal(stale.length, 1);
|
|
assert.equal(keep.length, 0);
|
|
assert.equal(stale[0].matchedBy, "pr-number");
|
|
assert.match(stale[0].reason, /#8100/);
|
|
});
|
|
|
|
test("a fragment for THIS cycle, not yet in the changelog, is kept", () => {
|
|
// The whole point: the sweep must not eat the fragments the current cycle is accumulating.
|
|
const { stale, keep } = classifyFragments({
|
|
changelog: CHANGELOG,
|
|
fragments: [{ name: "8966-iflow.md", rel: "changelog.d/maintenance/8966-iflow.md", body: "- **chore(sse):** dropped the leftover iflow entry ([#8966](https://x/y/pull/8966))" }],
|
|
});
|
|
assert.equal(stale.length, 0);
|
|
assert.equal(keep.length, 1);
|
|
});
|
|
|
|
test("identity is the FILENAME's PR number, never a ref cited in the bullet", () => {
|
|
// The regression that caught this: the real fragment
|
|
// `changelog.d/features/8980-deprecate-gemini-cli-provider.md` cites issue #7034 for
|
|
// context, and #7034 shipped in an earlier cycle. Matching on any body ref flagged it stale
|
|
// and would have deleted an unreleased bullet along with its credit.
|
|
const { stale, keep } = classifyFragments({
|
|
changelog: CHANGELOG + "\n- an older bullet about ([#7034](url))\n",
|
|
fragments: [
|
|
{
|
|
name: "8980-deprecate-gemini-cli-provider.md",
|
|
rel: "changelog.d/features/8980-deprecate-gemini-cli-provider.md",
|
|
body: "- **feat(sse):** deprecated the provider. The client identity (issue #7034) is untouched ([#8980](url))",
|
|
},
|
|
],
|
|
});
|
|
assert.equal(stale.length, 0, "#7034 is context, not this fragment's identity");
|
|
assert.equal(keep.length, 1);
|
|
});
|
|
|
|
test("the filename's PR number being present IS enough to sweep", () => {
|
|
const { stale } = classifyFragments({
|
|
changelog: CHANGELOG,
|
|
fragments: [
|
|
{ name: "8101-migration.md", rel: "changelog.d/fixes/8101-migration.md", body: "- whatever the text says" },
|
|
],
|
|
});
|
|
assert.equal(stale.length, 1);
|
|
assert.equal(stale[0].matchedBy, "pr-number");
|
|
assert.match(stale[0].reason, /#8101/);
|
|
});
|
|
|
|
test("a ref-less fragment already present is caught by normalized text", () => {
|
|
const { stale, keep } = classifyFragments({
|
|
changelog: CHANGELOG,
|
|
fragments: [
|
|
{
|
|
name: "pin.md",
|
|
rel: "changelog.d/maintenance/pin.md",
|
|
// Different markdown, same sentence — must still collide.
|
|
body: "- chore(ci): pin the workflow action digests, no issue number on this one",
|
|
},
|
|
],
|
|
});
|
|
assert.equal(keep.length, 0);
|
|
assert.equal(stale.length, 1);
|
|
assert.equal(stale[0].matchedBy, "text");
|
|
});
|
|
|
|
test("a ref-less fragment that is NOT present is kept", () => {
|
|
const { stale, keep } = classifyFragments({
|
|
changelog: CHANGELOG,
|
|
fragments: [{ name: "new.md", rel: "changelog.d/features/new.md", body: "- feat(api): brand new endpoint nobody has shipped yet" }],
|
|
});
|
|
assert.equal(stale.length, 0);
|
|
assert.equal(keep.length, 1);
|
|
});
|
|
|
|
test("a very short ref-less fragment is kept — accidental text collisions are not evidence", () => {
|
|
const { stale, keep } = classifyFragments({
|
|
changelog: CHANGELOG,
|
|
fragments: [{ name: "tiny.md", rel: "changelog.d/fixes/tiny.md", body: "- typo" }],
|
|
});
|
|
assert.equal(stale.length, 0, "too short to identify — keeping is the safe direction");
|
|
assert.equal(keep.length, 1);
|
|
});
|
|
|
|
test("an empty changelog sweeps nothing", () => {
|
|
const frags = [
|
|
{ name: "a.md", rel: "changelog.d/fixes/a.md", body: "- fix: something ([#1](url))" },
|
|
{ name: "b.md", rel: "changelog.d/fixes/b.md", body: "- fix: другое" },
|
|
];
|
|
const { stale, keep } = classifyFragments({ changelog: "", fragments: frags });
|
|
assert.equal(stale.length, 0);
|
|
assert.equal(keep.length, 2);
|
|
});
|
|
|
|
test("no fragments and no changelog does not throw", () => {
|
|
const r = classifyFragments({});
|
|
assert.deepEqual(r.stale, []);
|
|
assert.deepEqual(r.keep, []);
|
|
});
|
|
|
|
test("normalizeBullet folds markdown, links and refs away", () => {
|
|
const a = normalizeBullet("- **fix(sse):** stop the *retry* loop ([#8100](https://x/y/pull/8100))");
|
|
const b = normalizeBullet("fix(sse): stop the retry loop #8100");
|
|
assert.equal(a, b, "the same sentence in two markdown dialects must compare equal");
|
|
});
|
|
|
|
test("refsIn finds every number and nothing else", () => {
|
|
assert.deepEqual(refsIn("closes #12, refs #345 and v3.8.49"), [12, 345]);
|
|
assert.deepEqual(refsIn(""), []);
|
|
assert.deepEqual(refsIn(undefined), []);
|
|
});
|
|
|
|
// The report line "matched by … : N · by text: M" must count every stale entry under the
|
|
// category it was actually matched by. The summary used to compare against a `matchedBy`
|
|
// value ("ref") that classifyFragments never emits — it emits "pr-number" — so the
|
|
// pr-number bucket was permanently 0 and every filename-matched fragment was mis-tallied
|
|
// as a text match. summarizeStale is the pure counter the report line uses.
|
|
test("summarizeStale tallies pr-number and text matches under their real categories", () => {
|
|
const stale = [
|
|
{ matchedBy: "pr-number" },
|
|
{ matchedBy: "pr-number" },
|
|
{ matchedBy: "text" },
|
|
];
|
|
assert.deepEqual(summarizeStale(stale), { byPrNumber: 2, byText: 1 });
|
|
});
|
|
|
|
test("summarizeStale never leaks a category into the wrong bucket", () => {
|
|
const stale = [{ matchedBy: "pr-number" }, { matchedBy: "pr-number" }];
|
|
const { byPrNumber, byText } = summarizeStale(stale);
|
|
assert.equal(byPrNumber, 2, "both filename matches count as pr-number");
|
|
assert.equal(byText, 0, "no filename match may be reported as a text match");
|
|
});
|