mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 19:52:50 +03:00
Merge remote-tracking branch 'origin/release/v3.8.47' into fix/6400-head-request-hang
This commit is contained in:
33
.github/workflows/quality.yml
vendored
33
.github/workflows/quality.yml
vendored
@@ -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)
|
||||
@@ -130,15 +149,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 +178,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
|
||||
|
||||
@@ -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}/<PR>-<slug>.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)
|
||||
|
||||
42
changelog.d/README.md
Normal file
42
changelog.d/README.md
Normal file
@@ -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**: `<PR-number>-<short-slug>.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).
|
||||
0
changelog.d/features/.gitkeep
Normal file
0
changelog.d/features/.gitkeep
Normal file
1
changelog.d/features/6783-changelog-fragments.md
Normal file
1
changelog.d/features/6783-changelog-fragments.md
Normal file
@@ -0,0 +1 @@
|
||||
- **Changelog fragments (`changelog.d/`)**: PRs now add their changelog entry as a new fragment file (`changelog.d/{features|fixes|maintenance}/<PR>-<slug>.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`.
|
||||
0
changelog.d/fixes/.gitkeep
Normal file
0
changelog.d/fixes/.gitkeep
Normal file
1
changelog.d/fixes/6788-tia-dashboard-loader.md
Normal file
1
changelog.d/fixes/6788-tia-dashboard-loader.md
Normal file
@@ -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).
|
||||
0
changelog.d/maintenance/.gitkeep
Normal file
0
changelog.d/maintenance/.gitkeep
Normal file
1
changelog.d/maintenance/6784-merge-train.md
Normal file
1
changelog.d/maintenance/6784-merge-train.md
Normal file
@@ -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`.
|
||||
@@ -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/<MODEL> 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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/<section>/*.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).");
|
||||
|
||||
167
scripts/release/aggregate-changelog.mjs
Normal file
167
scripts/release/aggregate-changelog.mjs
Normal file
@@ -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/<section>/ 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/<PR>-<slug>.md → appended to "### ✨ New Features"
|
||||
// changelog.d/fixes/<PR>-<slug>.md → appended to "### 🐛 Bug Fixes"
|
||||
// changelog.d/maintenance/<PR>-<slug>.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 <root>/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());
|
||||
}
|
||||
137
scripts/release/merge-train.sh
Executable file
137
scripts/release/merge-train.sh
Executable file
@@ -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] <base-branch> <PR#> [<PR#>...]
|
||||
# --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] <base-branch> <PR#> [<PR#>...]" >&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-<ts> --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
|
||||
184
tests/unit/changelog-fragments.test.ts
Normal file
184
tests/unit/changelog-fragments.test.ts
Normal file
@@ -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/<section>/ 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}`);
|
||||
}
|
||||
});
|
||||
59
tests/unit/merge-train-plan.test.ts
Normal file
59
tests/unit/merge-train-plan.test.ts
Normal file
@@ -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/);
|
||||
});
|
||||
32
tests/unit/rerank-openrouter-6574.test.ts
Normal file
32
tests/unit/rerank-openrouter-6574.test.ts
Normal file
@@ -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)}`
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user