From 1ddb102a9036ce0453a9c9a8f3e48a18c42f6df2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:22:49 -0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(release):=20changelog.d/=20fragments?= =?UTF-8?q?=20=E2=80=94=20eliminate=20the=20CHANGELOG=20merge-storm=20casc?= =?UTF-8?q?ade=20(#6783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(release): changelog.d/ fragments — kill the CHANGELOG-eat merge-storm cascade Every PR used to edit the same top lines of CHANGELOG.md (its bullet), so in a merge-storm each merge conflicted every sibling (CHANGELOG-eat / DIRTY cascade), forcing a re-sync push + full CI re-run per PR per merge — O(N^2) CI runs. A PR now adds ONE new file under changelog.d/{features|fixes|maintenance}/ with its bullet; two PRs never touch the same file. scripts/release/aggregate-changelog.mjs (npm run changelog:aggregate) folds fragments into the living section and deletes them at release reconciliation. check:changelog-integrity (already wired in the merge-integrity CI job — zero workflow change) now also validates fragment well-formedness. This PR dogfoods the convention: its own entry is a fragment. * chore(changelog): fragment filename matches PR number (#6783) --- CONTRIBUTING.md | 2 +- changelog.d/README.md | 42 ++++ changelog.d/features/.gitkeep | 0 .../features/6783-changelog-fragments.md | 1 + changelog.d/fixes/.gitkeep | 0 changelog.d/maintenance/.gitkeep | 0 package.json | 1 + scripts/check/check-changelog-integrity.mjs | 58 +++++- scripts/release/aggregate-changelog.mjs | 167 ++++++++++++++++ tests/unit/changelog-fragments.test.ts | 184 ++++++++++++++++++ 10 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 changelog.d/README.md create mode 100644 changelog.d/features/.gitkeep create mode 100644 changelog.d/features/6783-changelog-fragments.md create mode 100644 changelog.d/fixes/.gitkeep create mode 100644 changelog.d/maintenance/.gitkeep create mode 100644 scripts/release/aggregate-changelog.mjs create mode 100644 tests/unit/changelog-fragments.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c3b4acb02..389b79d534 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -341,7 +341,7 @@ Write unit tests in `tests/unit/` covering at minimum: - [ ] Error responses route through `buildErrorBody()` / `sanitizeErrorMessage()` — no raw stack traces in response bodies (see [`docs/security/ERROR_SANITIZATION.md`](./docs/security/ERROR_SANITIZATION.md)) - [ ] Shell commands (`exec` / `spawn`) pass runtime values via `env`, not via string interpolation - [ ] All inputs validated with Zod schemas -- [ ] CHANGELOG updated (if user-facing change) +- [ ] Changelog **fragment** added under `changelog.d/{features|fixes|maintenance}/-.md` for user-facing changes (see [`changelog.d/README.md`](./changelog.d/README.md)) — do **not** edit `CHANGELOG.md` directly; fragments are aggregated at release time and never conflict between PRs - [ ] Documentation updated (if applicable) - [ ] No new CodeQL / Secret-Scanning alerts opened, or each one dismissed with technical justification referencing the relevant `docs/security/` doc - [ ] Routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) classified as `isLocalOnlyPath()` in `src/server/authz/routeGuard.ts` — see [Hard Rule #15](docs/security/ROUTE_GUARD_TIERS.md) diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000000..1e383a0a1d --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,42 @@ +# changelog.d/ — changelog fragments + +**A PR never edits `CHANGELOG.md` directly during the cycle.** Instead it adds ONE new +file here — its changelog entry as a *fragment*. Two PRs never touch the same file, so +changelog merge conflicts (the "CHANGELOG-eat" cascade that forced a re-sync push + full +CI re-run after every sibling merge) are structurally impossible. + +## Convention + +| Directory | Aggregates under | +| -------------- | ----------------------- | +| `features/` | `### ✨ New Features` | +| `fixes/` | `### 🐛 Bug Fixes` | +| `maintenance/` | `### 📝 Maintenance` | + +- **Filename**: `-.md` (e.g. `fixes/6700-dockerfile-better-sqlite3.md`). + The PR number prefix keeps aggregation order deterministic. +- **Content**: the exact bullet line(s) that should land in `CHANGELOG.md`, starting with + `- `. Multi-line (continuation) bullets are fine. Keep the repo's credit format: + `(#PR — thanks @user)`. +- One fragment per PR (rarely more, e.g. a PR that both fixes and adds). + +## Example + +`changelog.d/fixes/6496-cloudflare-relay-worker-syntax.md`: + +```markdown +- **fix(providers):** Cloudflare relay Worker deploys use Service Worker syntax with `body_part` metadata ([#6496](https://github.com/diegosouzapw/OmniRoute/pull/6496)) — thanks @SeaXen +``` + +## Aggregation + +The release captain (or `/generate-release`) folds all fragments into `CHANGELOG.md` and +deletes them: + +```bash +node scripts/release/aggregate-changelog.mjs # write + delete fragments +node scripts/release/aggregate-changelog.mjs --dry-run # preview only +``` + +Fragment well-formedness is enforced by `npm run check:changelog-integrity` (the same +gate that guards against CHANGELOG-eat for legacy direct edits). diff --git a/changelog.d/features/.gitkeep b/changelog.d/features/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/changelog.d/features/6783-changelog-fragments.md b/changelog.d/features/6783-changelog-fragments.md new file mode 100644 index 0000000000..0980c2967a --- /dev/null +++ b/changelog.d/features/6783-changelog-fragments.md @@ -0,0 +1 @@ +- **Changelog fragments (`changelog.d/`)**: PRs now add their changelog entry as a new fragment file (`changelog.d/{features|fixes|maintenance}/-.md`) instead of editing `CHANGELOG.md` — two PRs never touch the same file, structurally eliminating the CHANGELOG-eat merge conflicts that forced a re-sync push + full CI re-run after every sibling merge (O(N²) CI runs in a merge-storm). `scripts/release/aggregate-changelog.mjs` (npm run changelog:aggregate) folds fragments into the living section at release reconciliation, and `check:changelog-integrity` now also validates fragment well-formedness. Regression guard: `tests/unit/changelog-fragments.test.ts`. diff --git a/changelog.d/fixes/.gitkeep b/changelog.d/fixes/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/changelog.d/maintenance/.gitkeep b/changelog.d/maintenance/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/package.json b/package.json index c898b34204..92d759d7eb 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "check:test-masking": "node scripts/check/check-test-masking.mjs", "check:test-runner-api": "node scripts/check/check-test-runner-api.mjs", "check:changelog-integrity": "node scripts/check/check-changelog-integrity.mjs", + "changelog:aggregate": "node scripts/release/aggregate-changelog.mjs", "check:agent-skills-sync": "node --import tsx/esm scripts/skills/generate-agent-skills.mjs", "check:build-scope": "node scripts/check/check-build-scope.mjs", "check:error-helper": "node scripts/check/check-error-helper.mjs", diff --git a/scripts/check/check-changelog-integrity.mjs b/scripts/check/check-changelog-integrity.mjs index b07c3f99f4..edf3ac4442 100644 --- a/scripts/check/check-changelog-integrity.mjs +++ b/scripts/check/check-changelog-integrity.mjs @@ -26,12 +26,15 @@ // env ALLOW_CHANGELOG_REMOVALS=1 report-only (never fails) import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const CHANGELOG = "CHANGELOG.md"; +const FRAGMENTS_DIR = "changelog.d"; +const FRAGMENT_SECTIONS = ["features", "fixes", "maintenance"]; +const FRAGMENT_SKIP = new Set(["README.md", ".gitkeep"]); /** Extract the set of bullet lines (trimmed) from a CHANGELOG text. */ export function extractBullets(text) { @@ -56,6 +59,49 @@ export function findLostBullets(baseText, headText) { return lost; } +/** + * Validate changelog FRAGMENTS (changelog.d/
/*.md — see changelog.d/README.md). + * A fragment must be a well-formed markdown bullet ("- ...") with no merge-conflict + * markers, and must live in a known section dir. Returns [{file, error}]. Pure over + * the filesystem — unit-tested via a tmp root. + */ +export function findInvalidFragments(root = ROOT) { + const invalid = []; + const base = join(root, FRAGMENTS_DIR); + if (!existsSync(base)) return invalid; + const entries = readdirSync(base, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + if (!FRAGMENT_SKIP.has(entry.name)) { + invalid.push({ + file: `${FRAGMENTS_DIR}/${entry.name}`, + error: `fragments live in a section dir (${FRAGMENT_SECTIONS.join("|")}), not at changelog.d root`, + }); + } + continue; + } + if (!FRAGMENT_SECTIONS.includes(entry.name)) { + invalid.push({ + file: `${FRAGMENTS_DIR}/${entry.name}/`, + error: `unknown section dir (expected ${FRAGMENT_SECTIONS.join("|")})`, + }); + continue; + } + for (const f of readdirSync(join(base, entry.name))) { + if (FRAGMENT_SKIP.has(f) || !f.endsWith(".md")) continue; + const file = `${FRAGMENTS_DIR}/${entry.name}/${f}`; + const text = readFileSync(join(base, entry.name, f), "utf8"); + const firstContent = text.split("\n").find((l) => l.trim().length > 0); + if (!firstContent) invalid.push({ file, error: "empty fragment" }); + else if (!firstContent.trimStart().startsWith("- ")) + invalid.push({ file, error: 'fragment must start with a markdown bullet ("- ")' }); + else if (/^(<{7}|={7}|>{7})/m.test(text)) + invalid.push({ file, error: "fragment contains merge-conflict markers" }); + } + } + return invalid; +} + function git(args) { return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); } @@ -77,6 +123,16 @@ function resolveBaseRef() { } function main() { + // Fragment well-formedness first (changelog.d/ — the fragments pattern makes the + // eat-guard below structurally unnecessary for PRs that stop editing CHANGELOG.md). + const invalidFragments = findInvalidFragments(); + if (invalidFragments.length > 0) { + console.error(`[changelog-integrity] ${invalidFragments.length} invalid changelog fragment(s):`); + for (const { file, error } of invalidFragments) console.error(` ✗ ${file}: ${error}`); + console.error("\nSee changelog.d/README.md for the fragment convention."); + return 1; + } + const baseRef = resolveBaseRef(); if (!baseRef) { console.log("[changelog-integrity] SKIP — could not resolve a base ref (offline/fresh clone)."); diff --git a/scripts/release/aggregate-changelog.mjs b/scripts/release/aggregate-changelog.mjs new file mode 100644 index 0000000000..7d490045f6 --- /dev/null +++ b/scripts/release/aggregate-changelog.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// scripts/release/aggregate-changelog.mjs +// +// Changelog FRAGMENTS aggregator (towncrier/changesets pattern, adopted 2026-07-09). +// +// Why: during a release cycle every PR used to edit the same few lines at the top of +// CHANGELOG.md (its bullet). In a merge-storm each merge conflicted every sibling +// (CHANGELOG-eat / DIRTY cascade), forcing a re-sync push + full CI re-run per PR per +// merge — O(N²) CI runs for N queued PRs. With fragments, a PR adds ONE NEW FILE under +// changelog.d/
/ instead, so two PRs never touch the same file: no conflicts, +// no eat, no re-sync. This script is the single place fragments become CHANGELOG.md +// bullets — run by the release captain (or /generate-release) at reconciliation, and +// safe to run mid-cycle whenever a consolidated view is wanted. +// +// Convention: +// changelog.d/features/-.md → appended to "### ✨ New Features" +// changelog.d/fixes/-.md → appended to "### 🐛 Bug Fixes" +// changelog.d/maintenance/-.md → appended to "### 📝 Maintenance" +// File content = the exact bullet line(s), starting with "- " (continuation lines +// allowed). Credit format stays the repo norm: "(#PR — thanks @user)". +// +// Usage: +// node scripts/release/aggregate-changelog.mjs [--dry-run] +// --dry-run print the would-be CHANGELOG.md to stdout and list fragments; +// touch nothing. +// +// On a real run, aggregated fragment files are DELETED (leaving README.md and the +// .gitkeep placeholders) — the caller commits both the CHANGELOG.md update and the +// deletions in one commit. + +import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const FRAGMENTS_DIR = "changelog.d"; + +/** Section subdir → the CHANGELOG heading its bullets are appended under. */ +export const SECTIONS = Object.freeze({ + features: "### ✨ New Features", + fixes: "### 🐛 Bug Fixes", + maintenance: "### 📝 Maintenance", +}); + +const SKIP_FILES = new Set(["README.md", ".gitkeep"]); + +/** + * Validate one fragment's text. Returns null when OK, or a human-readable error. + * Pure — unit-tested. + */ +export function validateFragmentText(text) { + const body = String(text || "").replace(/^/, ""); + const lines = body.split("\n"); + const firstContent = lines.find((l) => l.trim().length > 0); + if (!firstContent) return "empty fragment"; + if (!firstContent.trimStart().startsWith("- ")) { + return 'fragment must start with a markdown bullet ("- ")'; + } + if (/^(<{7}|={7}|>{7})/m.test(body)) return "fragment contains merge-conflict markers"; + return null; +} + +/** + * Collect fragments from /changelog.d, sorted by filename per section for a + * deterministic output order. Returns { features: [...], fixes: [...], + * maintenance: [...], invalid: [{file, error}] } where each valid entry is + * { file, text } (text trimmed of trailing whitespace). + */ +export function collectFragments(root) { + const out = { features: [], fixes: [], maintenance: [], invalid: [] }; + const base = join(root, FRAGMENTS_DIR); + if (!existsSync(base)) return out; + for (const section of Object.keys(SECTIONS)) { + const dir = join(base, section); + if (!existsSync(dir)) continue; + const files = readdirSync(dir) + .filter((f) => f.endsWith(".md") && !SKIP_FILES.has(f)) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + for (const f of files) { + const file = join(dir, f); + const text = readFileSync(file, "utf8").replace(/\s+$/, ""); + const error = validateFragmentText(text); + if (error) out.invalid.push({ file: relative(root, file), error }); + else out[section].push({ file: relative(root, file), text }); + } + } + return out; +} + +/** + * Append bullets at the END of a living-section heading's bullet block (before the + * next "##"/"###" heading). Operates on the FIRST occurrence of the heading — in this + * repo's CHANGELOG the living cycle section always appears first. Pure — unit-tested. + * Throws when a needed heading is missing (the release captain adds the heading; the + * script never invents structure). + */ +export function insertBullets(changelogText, bulletsBySection) { + let lines = changelogText.split("\n"); + for (const [section, heading] of Object.entries(SECTIONS)) { + const bullets = (bulletsBySection[section] || []).map((b) => b.text ?? b); + if (bullets.length === 0) continue; + const headIdx = lines.findIndex((l) => l.trim() === heading); + if (headIdx === -1) { + throw new Error( + `heading "${heading}" not found in CHANGELOG.md — add it to the living section before aggregating ${section} fragments` + ); + } + // End of this section's block: last non-empty line before the next heading. + let nextHead = lines.length; + for (let i = headIdx + 1; i < lines.length; i++) { + if (/^##/.test(lines[i])) { + nextHead = i; + break; + } + } + let insertAt = nextHead; + while (insertAt > headIdx + 1 && lines[insertAt - 1].trim() === "") insertAt--; + const block = bullets.flatMap((b) => b.split("\n")); + lines = [...lines.slice(0, insertAt), ...block, ...lines.slice(insertAt)]; + } + return lines.join("\n"); +} + +/** + * Aggregate fragments into CHANGELOG.md. Returns a summary object. When dryRun is + * true nothing is written or deleted. + */ +export function aggregate({ root = ROOT, dryRun = false } = {}) { + const collected = collectFragments(root); + if (collected.invalid.length > 0) { + const detail = collected.invalid.map((i) => ` ✗ ${i.file}: ${i.error}`).join("\n"); + throw new Error(`invalid changelog fragments:\n${detail}`); + } + const total = collected.features.length + collected.fixes.length + collected.maintenance.length; + const changelogPath = join(root, "CHANGELOG.md"); + const before = readFileSync(changelogPath, "utf8"); + const after = total === 0 ? before : insertBullets(before, collected); + if (!dryRun && total > 0) { + writeFileSync(changelogPath, after); + for (const section of Object.keys(SECTIONS)) { + for (const { file } of collected[section]) unlinkSync(join(root, file)); + } + } + return { total, collected, changed: total > 0, after }; +} + +function main() { + const dryRun = process.argv.includes("--dry-run"); + const result = aggregate({ dryRun }); + if (result.total === 0) { + console.log("[aggregate-changelog] no fragments to aggregate — nothing to do."); + return 0; + } + for (const section of Object.keys(SECTIONS)) { + for (const { file } of result.collected[section]) { + console.log(`[aggregate-changelog] ${dryRun ? "would aggregate" : "aggregated"} ${file}`); + } + } + console.log( + `[aggregate-changelog] ${result.total} fragment(s) → CHANGELOG.md${dryRun ? " (dry-run, nothing written)" : " (fragments deleted — commit CHANGELOG.md + deletions together)"}` + ); + return 0; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + process.exit(main()); +} diff --git a/tests/unit/changelog-fragments.test.ts b/tests/unit/changelog-fragments.test.ts new file mode 100644 index 0000000000..6be69c0a4d --- /dev/null +++ b/tests/unit/changelog-fragments.test.ts @@ -0,0 +1,184 @@ +// Guards the changelog FRAGMENTS pipeline (changelog.d/ → CHANGELOG.md), adopted +// 2026-07-09 to kill the CHANGELOG-eat / DIRTY merge-storm cascade: a PR adds ONE new +// file under changelog.d/
/ instead of editing CHANGELOG.md, so sibling PRs +// never conflict. Covers the aggregator (scripts/release/aggregate-changelog.mjs) and +// the fragment validation wired into the merge-integrity gate +// (scripts/check/check-changelog-integrity.mjs::findInvalidFragments). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const { SECTIONS, validateFragmentText, collectFragments, insertBullets, aggregate } = + await import("../../scripts/release/aggregate-changelog.mjs"); +const { findInvalidFragments } = await import("../../scripts/check/check-changelog-integrity.mjs"); + +const CHANGELOG_FIXTURE = `# Changelog + +## [Unreleased] + +## [3.8.47] — TBD + +_Living section — bullets land here as PRs merge._ + +### ✨ New Features + +- **existing feature**: already here (#1 — thanks @a) + +### 🐛 Bug Fixes + +- **fix(x):** existing fix (#2 — thanks @b) + +### 📝 Maintenance + +- chore: existing maintenance (#3) + +## [3.8.46] - 2026-07-04 + +### ✨ New Features + +- **old feature**: shipped (#0) +`; + +function makeRoot({ fragments = {} } = {}) { + const root = mkdtempSync(join(tmpdir(), "chfrag-")); + writeFileSync(join(root, "CHANGELOG.md"), CHANGELOG_FIXTURE); + mkdirSync(join(root, "changelog.d"), { recursive: true }); + for (const [rel, text] of Object.entries(fragments)) { + const abs = join(root, "changelog.d", rel); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync(abs, text); + } + return root; +} + +test("validateFragmentText accepts a bullet and rejects garbage", () => { + assert.equal(validateFragmentText("- **fix:** ok (#9 — thanks @x)"), null); + assert.equal(validateFragmentText("- multi\n continuation line"), null); + assert.match(validateFragmentText(""), /empty/); + assert.match(validateFragmentText("not a bullet"), /must start/); + assert.match(validateFragmentText("- ok\n<<<<<<< HEAD"), /conflict markers/); +}); + +test("collectFragments reads sections sorted and flags invalid files", () => { + const root = makeRoot({ + fragments: { + "fixes/6700-b.md": "- fix B (#6700)", + "fixes/6496-a.md": "- fix A (#6496)", + "features/6728-chaos.md": "- feat chaos (#6728)", + "features/bad.md": "no bullet here", + }, + }); + const c = collectFragments(root); + assert.deepEqual( + c.fixes.map((f) => f.text), + ["- fix A (#6496)", "- fix B (#6700)"] + ); + assert.equal(c.features.length, 1); + assert.equal(c.invalid.length, 1); + assert.match(c.invalid[0].file, /bad\.md/); + rmSync(root, { recursive: true, force: true }); +}); + +test("insertBullets appends at the END of each living section", () => { + const out = insertBullets(CHANGELOG_FIXTURE, { + features: [{ text: "- NEW feature bullet (#10)" }], + fixes: [{ text: "- NEW fix bullet (#11)" }], + maintenance: [{ text: "- NEW maintenance bullet (#12)" }], + }); + const lines = out.split("\n"); + const featIdx = lines.indexOf("- NEW feature bullet (#10)"); + const bugHeadIdx = lines.indexOf("### 🐛 Bug Fixes"); + const fixIdx = lines.indexOf("- NEW fix bullet (#11)"); + const maintHeadIdx = lines.indexOf("### 📝 Maintenance"); + const maintIdx = lines.indexOf("- NEW maintenance bullet (#12)"); + // Each new bullet lands after its own existing bullets, before the next heading. + assert.ok(featIdx > lines.indexOf("- **existing feature**: already here (#1 — thanks @a)")); + assert.ok(featIdx < bugHeadIdx, "feature bullet must stay inside the features section"); + assert.ok(fixIdx > bugHeadIdx && fixIdx < maintHeadIdx); + assert.ok(maintIdx > maintHeadIdx && maintIdx < lines.indexOf("## [3.8.46] - 2026-07-04")); + // Only the FIRST (living) occurrence of a heading is touched — the shipped 3.8.46 + // section is byte-identical. + assert.ok(out.includes("## [3.8.46] - 2026-07-04\n\n### ✨ New Features\n\n- **old feature**: shipped (#0)")); + // No existing bullet lost. + for (const existing of ["#1 — thanks @a", "existing fix (#2", "existing maintenance (#3"]) { + assert.ok(out.includes(existing)); + } +}); + +test("insertBullets throws when a needed heading is missing", () => { + const noMaint = CHANGELOG_FIXTURE.replace("### 📝 Maintenance\n\n- chore: existing maintenance (#3)\n", ""); + assert.throws( + () => insertBullets(noMaint, { maintenance: [{ text: "- x" }] }), + /📝 Maintenance.*not found/s + ); +}); + +test("aggregate dry-run touches nothing; real run writes and deletes fragments", () => { + const root = makeRoot({ + fragments: { "fixes/6800-real.md": "- real aggregated fix (#6800 — thanks @c)" }, + }); + const dry = aggregate({ root, dryRun: true }); + assert.equal(dry.total, 1); + assert.ok(!readFileSync(join(root, "CHANGELOG.md"), "utf8").includes("#6800")); + assert.ok(existsSync(join(root, "changelog.d/fixes/6800-real.md"))); + + const real = aggregate({ root }); + assert.equal(real.total, 1); + const after = readFileSync(join(root, "CHANGELOG.md"), "utf8"); + assert.ok(after.includes("- real aggregated fix (#6800 — thanks @c)")); + assert.ok(!existsSync(join(root, "changelog.d/fixes/6800-real.md")), "fragment must be deleted"); + + // Idempotence: nothing left → second run is a no-op. + const again = aggregate({ root }); + assert.equal(again.total, 0); + assert.equal(readFileSync(join(root, "CHANGELOG.md"), "utf8"), after); + rmSync(root, { recursive: true, force: true }); +}); + +test("aggregate refuses invalid fragments loudly", () => { + const root = makeRoot({ fragments: { "features/oops.md": "forgot the dash" } }); + assert.throws(() => aggregate({ root }), /invalid changelog fragments/); + rmSync(root, { recursive: true, force: true }); +}); + +test("gate findInvalidFragments: clean tree passes, bad placement/content fail", () => { + const clean = makeRoot({ fragments: { "maintenance/1-ok.md": "- ok (#1)" } }); + assert.deepEqual(findInvalidFragments(clean), []); + rmSync(clean, { recursive: true, force: true }); + + const dirty = makeRoot({ + fragments: { + "stray.md": "- misplaced at root", + "unknown-section/2-x.md": "- wrong dir", + "fixes/3-bad.md": "missing dash", + }, + }); + const invalid = findInvalidFragments(dirty); + const files = invalid.map((i) => i.file).sort(); + assert.equal(invalid.length, 3); + assert.ok(files.some((f) => f.includes("stray.md"))); + assert.ok(files.some((f) => f.includes("unknown-section"))); + assert.ok(files.some((f) => f.includes("3-bad.md"))); + rmSync(dirty, { recursive: true, force: true }); +}); + +test("gate skips README.md and .gitkeep; absent changelog.d is fine", () => { + const root = makeRoot(); + writeFileSync(join(root, "changelog.d/README.md"), "# docs, not a fragment"); + mkdirSync(join(root, "changelog.d/fixes"), { recursive: true }); + writeFileSync(join(root, "changelog.d/fixes/.gitkeep"), ""); + assert.deepEqual(findInvalidFragments(root), []); + rmSync(root, { recursive: true, force: true }); + + const bare = mkdtempSync(join(tmpdir(), "chfrag-bare-")); + assert.deepEqual(findInvalidFragments(bare), []); + rmSync(bare, { recursive: true, force: true }); +}); + +test("SECTIONS maps every dir to a real living-section heading in the fixture", () => { + for (const heading of Object.values(SECTIONS)) { + assert.ok(CHANGELOG_FIXTURE.includes(heading), `fixture must contain ${heading}`); + } +}); From 15d08a86c6d29423adda33bf74d60bbb8b095bcd Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:23:06 -0300 Subject: [PATCH 2/5] =?UTF-8?q?ci(quality):=20shard=20unit=20fast-path=202?= =?UTF-8?q?=E2=86=924=20=E2=80=94=20halves=20the=20heaviest=20job's=20wall?= =?UTF-8?q?=20time=20(#6781)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/quality.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index d1e765cb79..7b10170301 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -130,15 +130,17 @@ jobs: - run: npm run test:vitest fast-unit: - name: Unit Tests fast-path (${{ matrix.shard }}/2) + name: Unit Tests fast-path (${{ matrix.shard }}/4) # Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest). - # This is the heaviest fast-path job (~9min on ubuntu-latest); the 32-core VPS - # cuts it to ~2-3min when the flag is on. + # This is the heaviest fast-path job; 4-way sharding (was 2) halves the critical + # path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot runner box). + # Node's native --test-shard=N/total takes any denominator — only this matrix and + # the TEST_SHARD env below encode the shard count. runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} strategy: fail-fast: false matrix: - shard: [1, 2] + shard: [1, 2, 3, 4] env: JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-lint-api-key-secret-long @@ -157,7 +159,7 @@ jobs: # silenciosamente não rodavam no fast path) e o setupPolyfill não era importado. - run: npm run test:unit:ci:shard env: - TEST_SHARD: ${{ matrix.shard }}/2 + TEST_SHARD: ${{ matrix.shard }}/4 # ── Pacote 4 (plano mestre testes+CI, aprovado 2026-07-04) ───────────────────────── # No-new-warnings por PR via ESLint bulk suppressions nativo (>=9.24). O baseline From 1eb218f76ade2e32848d4fe586429c6b77c7c738 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:23:17 -0300 Subject: [PATCH 3/5] ci(quality): TIA impacted-run splits dashboard tests onto the tsx loader (closes #6787) (#6788) The impacted branch ran every selected file under --import tsx/esm; the canonical test:unit:ci:shard runs tests/unit/dashboard/** under --import tsx (CJS transform, required for @lobehub/icons/es/* deep imports). Any PR whose impact map reached a dashboard component false-redded with 'Unexpected token export' (reproduced on unrelated PRs #6317 and #6335 the same evening). The selection is now split by segment with loader parity. --- .github/workflows/quality.yml | 21 ++++++++++++++++++- .../fixes/6788-tia-dashboard-loader.md | 1 + 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/6788-tia-dashboard-loader.md diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7b10170301..04d69911ec 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -108,7 +108,26 @@ jobs: fi echo "Running impacted tests:"; echo "$SEL" mapfile -t FILES <<< "$SEL" - node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${FILES[@]}" + # Loader parity with test:unit:ci:shard (#6787): tests/unit/dashboard/** runs + # under `--import tsx` (CJS transform — required for ESM-only deep imports like + # @lobehub/icons/es/* reached via lobeProviderIcons.ts); everything else under + # `--import tsx/esm`. A single tsx/esm invocation false-reds every dashboard + # module-shape test the impact map selects ("Unexpected token 'export'"). + DASH=(); REST=() + for f in "${FILES[@]}"; do + case "$f" in + tests/unit/dashboard/*) DASH+=("$f") ;; + *) REST+=("$f") ;; + esac + done + RC=0 + if [ ${#REST[@]} -gt 0 ]; then + node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${REST[@]}" || RC=$? + fi + if [ ${#DASH[@]} -gt 0 ]; then + node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$? + fi + exit $RC fast-vitest: name: Vitest (fast-path) diff --git a/changelog.d/fixes/6788-tia-dashboard-loader.md b/changelog.d/fixes/6788-tia-dashboard-loader.md new file mode 100644 index 0000000000..50e6f4f06b --- /dev/null +++ b/changelog.d/fixes/6788-tia-dashboard-loader.md @@ -0,0 +1 @@ +- **fix(ci):** the blocking "Impacted unit tests (TIA)" step false-redded any PR whose impact graph reached a dashboard component — it ran every selected test under `--import tsx/esm`, but `tests/unit/dashboard/**` requires the `--import tsx` CJS transform (ESM-only deep imports like `@lobehub/icons/es/*`), exactly as the canonical `test:unit:ci:shard` already does per segment. The impacted selection is now split by segment with matching loaders (closes #6787). From 50881c31d00a06e25f304a15bcadddd5b48c8010 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:23:27 -0300 Subject: [PATCH 4/5] =?UTF-8?q?chore(release):=20merge-train=20=E2=80=94?= =?UTF-8?q?=20batch-validate=20queued=20PRs=20once,=20--admin=20with=20evi?= =?UTF-8?q?dence=20(#6784)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges every queued PR into a throwaway detached worktree cut from origin/, runs the fast-gates parity suite ONCE on the final train tip, and prints the evidence line that authorizes gh pr merge --squash --admin per member (merge-gates.md §7). Conflicting PRs are ejected and reported, the train continues. Never pushes, never merges PRs, never stashes. --- changelog.d/maintenance/6784-merge-train.md | 1 + scripts/release/merge-train.sh | 137 ++++++++++++++++++++ tests/unit/merge-train-plan.test.ts | 59 +++++++++ 3 files changed, 197 insertions(+) create mode 100644 changelog.d/maintenance/6784-merge-train.md create mode 100755 scripts/release/merge-train.sh create mode 100644 tests/unit/merge-train-plan.test.ts diff --git a/changelog.d/maintenance/6784-merge-train.md b/changelog.d/maintenance/6784-merge-train.md new file mode 100644 index 0000000000..e98586183f --- /dev/null +++ b/changelog.d/maintenance/6784-merge-train.md @@ -0,0 +1 @@ +- **Merge-train script** (`scripts/release/merge-train.sh`): batch-validates N queued PRs as ONE merged result on the runner box — merges every queued PR into a throwaway worktree cut from the release tip, runs the fast-gates parity suite once, and prints the `--admin` evidence block per PR (merge-gates §7). Replaces O(N²) per-PR CI re-runs in merge-storms. Regression guard: `tests/unit/merge-train-plan.test.ts`. diff --git a/scripts/release/merge-train.sh b/scripts/release/merge-train.sh new file mode 100755 index 0000000000..217a66212e --- /dev/null +++ b/scripts/release/merge-train.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# scripts/release/merge-train.sh — batch-validate N queued PRs as ONE merged result. +# +# Why: in a merge-storm, waiting for each PR's CI after each sibling merge costs +# O(N²) CI runs. The train merges every queued PR into a throwaway worktree cut from +# the release tip, runs the full fast-gates parity suite ONCE on the final result, and +# prints the evidence block that authorizes `gh pr merge --squash --admin` for each +# train member (merge-gates.md §7 — owner-approved policy extension of §4, 2026-07-09). +# +# Designed for the 32-core runner box (192.168.0.113) or any checkout with +# node_modules. It only READS from origin — it never pushes, never merges PRs, never +# touches other worktrees, and never uses `git stash` (Hard Rule #22a). +# +# Usage: +# scripts/release/merge-train.sh [--plan] [...] +# --plan print the planned steps and exit 0 (no worktree, no network) — used by +# the unit test and for a quick sanity read. +# +# Exit codes: 0 = suite green (evidence printed); 1 = usage error; 2 = suite red; +# PRs whose merge conflicts are EJECTED (reported, train continues). +set -euo pipefail + +PLAN=0 +if [ "${1:-}" = "--plan" ]; then + PLAN=1 + shift +fi + +if [ $# -lt 2 ]; then + echo "usage: $0 [--plan] [...]" >&2 + exit 1 +fi + +BASE="$1" +shift +PRS=("$@") +for N in "${PRS[@]}"; do + case "$N" in + ''|*[!0-9]*) echo "error: PR number '$N' is not numeric" >&2; exit 1 ;; + esac +done + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +SUITE=( + "npm run typecheck:core" + "node scripts/check/check-file-size.mjs" + "node scripts/check/check-complexity.mjs" + "node scripts/check/check-cognitive-complexity.mjs" + "node scripts/check/check-changelog-integrity.mjs" + "TEST_SHARD=1/2 npm run test:unit:ci:shard" + "TEST_SHARD=2/2 npm run test:unit:ci:shard" + "npm run test:vitest" +) + +if [ "$PLAN" = "1" ]; then + echo "[merge-train] PLAN — base=origin/${BASE} prs=${PRS[*]}" + echo "[merge-train] 1. worktree add .claude/worktrees/merge-train- --detach origin/${BASE}" + for N in "${PRS[@]}"; do + echo "[merge-train] 2. fetch origin pull/${N}/head && merge (conflict → EJECT #${N}, continue)" + done + i=3 + for c in "${SUITE[@]}"; do + echo "[merge-train] ${i}. ${c}" + i=$((i + 1)) + done + echo "[merge-train] ${i}. green → print --admin evidence per PR; red → exit 2 (bisect + eject)" + echo "[merge-train] ${i}. teardown: git worktree remove --force (trap EXIT)" + exit 0 +fi + +if [ -z "$ROOT" ]; then + echo "error: not inside a git checkout" >&2 + exit 1 +fi + +TS="$(date +%Y%m%d-%H%M%S)" +WT="$ROOT/.claude/worktrees/merge-train-$TS" +LOG="$WT-suite.log" + +cleanup() { + git -C "$ROOT" worktree remove --force "$WT" 2>/dev/null || true +} +trap cleanup EXIT + +echo "[merge-train] fetching origin/${BASE}…" +git -C "$ROOT" fetch origin "$BASE" --quiet +git -C "$ROOT" worktree add --detach "$WT" "origin/$BASE" --quiet +# reuse the main checkout's node_modules (same convention as dev worktrees) +[ -e "$WT/node_modules" ] || ln -s "$ROOT/node_modules" "$WT/node_modules" + +EJECTED=() +BOARDED=() +for N in "${PRS[@]}"; do + echo "[merge-train] boarding #${N}…" + if ! git -C "$WT" fetch origin "pull/${N}/head" --quiet; then + echo "[merge-train] ✗ #${N} EJECTED — could not fetch pull/${N}/head" + EJECTED+=("$N") + continue + fi + if git -C "$WT" merge FETCH_HEAD --no-edit --quiet >/dev/null 2>&1; then + BOARDED+=("$N") + else + git -C "$WT" merge --abort 2>/dev/null || true + echo "[merge-train] ✗ #${N} EJECTED — merge conflict vs the train (route it through the normal §5 path)" + EJECTED+=("$N") + fi +done + +if [ ${#BOARDED[@]} -eq 0 ]; then + echo "[merge-train] no PR boarded — nothing to validate." >&2 + exit 1 +fi + +TIP="$(git -C "$WT" rev-parse HEAD)" +EJ_MSG="" +[ ${#EJECTED[@]} -gt 0 ] && EJ_MSG=" — ejected: ${EJECTED[*]}" +echo "[merge-train] train tip ${TIP} — boarded: ${BOARDED[*]}${EJ_MSG}" +echo "[merge-train] running parity suite (log: ${LOG})…" + +for c in "${SUITE[@]}"; do + echo "[merge-train] ▶ ${c}" + if ! (cd "$WT" && eval "$c") >>"$LOG" 2>&1; then + echo "[merge-train] ✗ SUITE RED at: ${c}" >&2 + echo "[merge-train] tail of ${LOG}:" >&2 + tail -30 "$LOG" >&2 + echo "[merge-train] bisect: re-run the failing gate on intermediate train commits, eject the offender, re-run." >&2 + exit 2 + fi +done + +echo "[merge-train] ✅ SUITE GREEN on ${TIP}" +echo "[merge-train] evidence line for each PR (paste before gh pr merge --squash --admin):" +for N in "${BOARDED[@]}"; do + echo " #${N}: Validated in local merge-train ${LOG} on $(hostname) @ ${TIP} (suite green)" +done +[ ${#EJECTED[@]} -gt 0 ] && echo "[merge-train] ejected (need the normal path): ${EJECTED[*]}" +exit 0 diff --git a/tests/unit/merge-train-plan.test.ts b/tests/unit/merge-train-plan.test.ts new file mode 100644 index 0000000000..770434d22d --- /dev/null +++ b/tests/unit/merge-train-plan.test.ts @@ -0,0 +1,59 @@ +// Guards scripts/release/merge-train.sh (merge-gates.md §7 — batch validation of N +// queued PRs as one merged result, replacing O(N²) per-PR CI re-runs). Only the +// side-effect-free surface is testable in unit scope: --plan mode (no worktree, no +// network) and argument validation. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pExecFile = promisify(execFile); +const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "../../scripts/release/merge-train.sh"); + +async function run(args: string[]) { + try { + const { stdout, stderr } = await pExecFile("bash", [SCRIPT, ...args]); + return { code: 0, stdout, stderr }; + } catch (err) { + const e = err as { code?: number; stdout?: string; stderr?: string }; + return { code: e.code ?? -1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" }; + } +} + +test("--plan prints the full step plan without touching anything and exits 0", async () => { + const { code, stdout } = await run(["--plan", "release/v9.9.9", "111", "222"]); + assert.equal(code, 0); + assert.match(stdout, /PLAN — base=origin\/release\/v9\.9\.9 prs=111 222/); + assert.match(stdout, /worktree add \.claude\/worktrees\/merge-train-/); + assert.match(stdout, /pull\/111\/head/); + assert.match(stdout, /pull\/222\/head/); + // the parity suite is fully enumerated in the plan + for (const gate of [ + "typecheck:core", + "check-file-size.mjs", + "check-complexity.mjs", + "check-cognitive-complexity.mjs", + "check-changelog-integrity.mjs", + "TEST_SHARD=1/2", + "TEST_SHARD=2/2", + "test:vitest", + ]) { + assert.ok(stdout.includes(gate), `plan must include ${gate}`); + } + assert.match(stdout, /--admin evidence/); + assert.match(stdout, /teardown: git worktree remove/); +}); + +test("usage error without enough args", async () => { + const { code, stderr } = await run(["--plan", "release/v9.9.9"]); + assert.equal(code, 1); + assert.match(stderr, /usage:/); +}); + +test("rejects a non-numeric PR ref", async () => { + const { code, stderr } = await run(["--plan", "release/v9.9.9", "12a"]); + assert.equal(code, 1); + assert.match(stderr, /not numeric/); +}); From 16e5b4d4440d6ddda02202e2dc0c1e129b738aba Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:24:16 -0300 Subject: [PATCH 5/5] fix(providers): register openrouter rerank provider (#6574) (#6681) * fix(providers): register openrouter rerank provider (#6574) * fix(changelog): restore CHANGELOG bullets eaten by release sync * fix(changelog): re-restore CHANGELOG bullet after further release sync * fix(changelog): re-restore CHANGELOG bullet after further release sync * fix(changelog): re-restore CHANGELOG bullet after further release sync * fix(changelog): correct CHANGELOG restoration (previous attempt had a script-path bug) * fix(changelog): re-restore CHANGELOG bullet after further release sync * fix(changelog): re-restore #6681 bullet after #6700 release sync * chore(sync): merge release tip + restore own CHANGELOG bullet Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(sync): merge release tip + restore own CHANGELOG bullet --- CHANGELOG.md | 1 + open-sse/config/rerankRegistry.ts | 18 +++++++++++++ tests/unit/rerank-openrouter-6574.test.ts | 32 +++++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 tests/unit/rerank-openrouter-6574.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bba532ec2..1fe49dcb30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **Kiro long-lived API key auth**: new `/api/oauth/kiro/api-key` route + `KiroService.validateApiKey` let a Kiro account be linked with a long-lived AWS CodeWhisperer/Kiro API key instead of the interactive OAuth device flow, with live per-account model discovery (`ListAvailableModels`, 5-minute cache) layered over the existing static registry fallback (#6587 — thanks @strangersp) - **Chaos Mode**: multi-model parallel/collaborative task execution — dispatches a task to every active provider connection at once (parallel) or chains outputs sequentially so each model builds on the previous one's answer (collaborative), configurable via Dashboard → Chaos Mode (`GET`/`PUT`/`DELETE /api/chaos/config`) and gated per-API-key via a new `chaosModeEnabled` permission (opt-in — disabled by default globally and per key). `POST /api/chaos/run` (dashboard session) and `POST /api/skills/collect/chaos` (external Bearer-token) delegate to a shared `executeChaosRun()` engine (`src/lib/chaos/chaosExecutor.ts`) that dispatches in-process via the established synthetic-Request/route-handler pattern (no network hop, no hardcoded port), with a concurrency cap (max 10 parallel), configurable `max_tokens` (256–128k), a clear error when `stream` is requested, and collaborative-chain info (provider order + input size). Fixes external Bearer-auth bypass and stale config-cache leakage. Regression guard: `tests/unit/chaos-config.test.ts`, `tests/unit/chaos-executor.test.ts`, `tests/unit/chaos-api-routes.test.ts`. ([#6728](https://github.com/diegosouzapw/OmniRoute/pull/6728) — thanks @Moseyuh333) - **feat(cli):** 2 new CLI tool integrations on Dashboard → CLI Tools — **omp** (Oh My Pi) and **letta** — each with binary detection, config apply/reset, and a settings card following the existing tool-card pattern. Both settings routes shell out to `which omp`/`which letta` to detect the local install, so they're loopback-gated (`LOCAL_ONLY_API_PREFIXES`, Hard Rules #15/#17) in addition to the shared `requireCliToolsAuth()` management-auth guard every cli-tools route requires, and route errors through `sanitizeErrorMessage()`; `src/lib/db/omp.ts` isolates the `omp` CLI's own local SQLite reads behind parameterized queries. (Note: the original PR also proposed **pi**, **codewhale**, and **jcode** integrations — those three had already shipped via a separate PR by the time this one was reconciled, so only omp+letta landed here.) Regression guard: `tests/unit/db/omp.test.ts`, `tests/unit/cli-tools-auth-hardening.test.ts`, `tests/integration/cli-settings-omp.test.ts`, `tests/integration/cli-settings-letta.test.ts`. ([#6318](https://github.com/diegosouzapw/OmniRoute/pull/6318) — thanks @hamsa0x7) +- **fix(providers):** register OpenRouter as a rerank provider so `openrouter/cohere/rerank-*` models resolve instead of erroring `Invalid rerank model` (#6574 — thanks @rafpigna) ### 🐛 Bug Fixes diff --git a/open-sse/config/rerankRegistry.ts b/open-sse/config/rerankRegistry.ts index e44efb16e1..7b8a79801c 100644 --- a/open-sse/config/rerankRegistry.ts +++ b/open-sse/config/rerankRegistry.ts @@ -89,6 +89,24 @@ export const RERANK_PROVIDERS = { ], }, + // OpenRouter exposes a separate, Cohere-compatible POST /api/v1/rerank endpoint + // (not surfaced by its live /v1/models feed, which contains 0 rerank ids — confirmed + // by direct curl). Model IDs keep their vendor slash (e.g. "cohere/rerank-4-pro"); + // parseRerankModel splits on the FIRST slash, so 3-segment ids resolve safely, same + // as siliconflow above. Seeded by hand and must be maintained here as OpenRouter adds + // more rerank models (#6574). + openrouter: { + id: "openrouter", + baseUrl: "https://openrouter.ai/api/v1/rerank", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "cohere/rerank-4-pro", name: "Cohere Rerank 4 Pro (via OpenRouter)" }, + { id: "cohere/rerank-4-fast", name: "Cohere Rerank 4 Fast (via OpenRouter)" }, + { id: "cohere/rerank-v3.5", name: "Cohere Rerank v3.5 (via OpenRouter)" }, + ], + }, + // DeepInfra rerank is NOT Cohere-shaped: POST /v1/inference/ with {queries:[q],documents} // returning {scores:[…]} (one score per document, positional). The `deepinfra` format adapter in // open-sse/handlers/rerank.ts builds the per-model URL and maps scores → Cohere results (#5332). diff --git a/tests/unit/rerank-openrouter-6574.test.ts b/tests/unit/rerank-openrouter-6574.test.ts new file mode 100644 index 0000000000..55128b1ad6 --- /dev/null +++ b/tests/unit/rerank-openrouter-6574.test.ts @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { parseRerankModel, getRerankProvider, getAllRerankModels } = await import( + "../../open-sse/config/rerankRegistry.ts" +); + +// #6574 — OpenRouter now exposes a Cohere-compatible /api/v1/rerank endpoint +// (confirmed live: openrouter.ai/cohere/rerank-4-pro, model ids stay +// fully-qualified "cohere/rerank-4-pro"), but RERANK_PROVIDERS has no +// "openrouter" entry at all. Same failure class as #5332 (siliconflow/deepinfra): +// parseRerankModel() can't resolve a provider for a 3-segment id when the +// provider itself isn't registered, so /v1/rerank falls through straight to +// the generic "Invalid rerank model" 400 without ever calling upstream. +test("#6574 parseRerankModel resolves openrouter multi-slash rerank model id", () => { + assert.deepEqual(parseRerankModel("openrouter/cohere/rerank-4-pro"), { + provider: "openrouter", + model: "cohere/rerank-4-pro", + }); +}); + +test("#6574 getRerankProvider('openrouter') returns a provider config", () => { + assert.ok(getRerankProvider("openrouter"), "openrouter should be a registered rerank provider"); +}); + +test("#6574 getAllRerankModels lists openrouter reranker models", () => { + const ids = getAllRerankModels().map((m) => m.id); + assert.ok( + ids.includes("openrouter/cohere/rerank-4-pro"), + `expected openrouter/cohere/rerank-4-pro in ${JSON.stringify(ids)}` + ); +});