mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
Merge remote-tracking branch 'origin/release/v3.8.51' into feat/i18n-batch-eu
This commit is contained in:
@@ -586,8 +586,8 @@ function stampServiceWorkerBuildId(resolvedOutDir) {
|
||||
process.env.OMNIROUTE_SW_BUILD_ID || process.env.SOURCE_VERSION || String(Date.now());
|
||||
let sw = fsSync.readFileSync(swDest, "utf8");
|
||||
sw = sw.replace(
|
||||
/^const CACHE_NAME = "omniroute-pwa-v2";$/m,
|
||||
`const CACHE_NAME = "omniroute-pwa-v2-${buildId}"; // build ${buildId}`
|
||||
/^const CACHE_NAME = "omniroute-pwa-v3";$/m,
|
||||
`const CACHE_NAME = "omniroute-pwa-v3-${buildId}"; // build ${buildId}`
|
||||
);
|
||||
fsSync.writeFileSync(swDest, sw);
|
||||
}
|
||||
|
||||
@@ -218,12 +218,16 @@ function readCodeFacts() {
|
||||
"const t=computeFreeModelTotals();const cli=Object.values(CLI_TOOLS);",
|
||||
"const by=(c)=>cli.filter(x=>x.category===c).length;",
|
||||
// "Free forever" = every provider whose free access renews or needs no key at all.
|
||||
// one-time-initial (signup credits) and discontinued pools are excluded on purpose.
|
||||
// one-time-initial (signup credits) and discontinued pools are excluded on purpose,
|
||||
// and so is every eligibility-gated row: a provider nobody can sign up for without
|
||||
// clearing a gate is not "free forever" for the reader of the headline.
|
||||
"const FOREVER=new Set(['recurring-monthly','recurring-daily','recurring-uncapped',",
|
||||
"'recurring-credit','keyless']);",
|
||||
"const ff=new Set();for(const m of t.perModel)if(FOREVER.has(m.freeType))ff.add(m.provider);",
|
||||
"const ff=new Set();for(const m of t.perModel)",
|
||||
"if(FOREVER.has(m.freeType)&&!m.eligibilityGate)ff.add(m.provider);",
|
||||
'console.log("@@"+JSON.stringify({freeSteady:t.steadyRecurringTokens,entries:t.perModel.length,',
|
||||
"freeFirst:t.firstMonthRealisticTokens,freePools:t.poolCount,engines:ENGINE_IDS.length,",
|
||||
"freeFirst:t.firstMonthRealisticTokens,freeGated:t.gatedRecurringTokens,",
|
||||
"freePools:t.poolCount,engines:ENGINE_IDS.length,",
|
||||
"cliTotal:cli.length,cliCode:by('code'),cliAgent:by('agent'),",
|
||||
"mcpTools:countUniqueMcpTools(cols),mcpScopes:sc.size,providers:pids.size,freeForever:ff.size,",
|
||||
"modePacks:Object.keys(MODE_PACKS),",
|
||||
@@ -271,6 +275,20 @@ export function extractHeadlineClaims(content) {
|
||||
return claims;
|
||||
}
|
||||
|
||||
// The eligibility-gated figure ("+~6M behind regional identity verification") is validated
|
||||
// with its own anchor so it can neither drift nor be silently dropped once it exists.
|
||||
const GATED_ANCHOR = /^\s*behind regional identity verification/i;
|
||||
|
||||
export function extractGatedClaims(content) {
|
||||
const claims = [];
|
||||
for (const m of content.matchAll(/\+?~?(\d+(?:\.\d+)?)([BM])\b/g)) {
|
||||
const after = content.slice(m.index + m[0].length, m.index + m[0].length + 60);
|
||||
if (!GATED_ANCHOR.test(after)) continue;
|
||||
claims.push({ tokens: Number(m[1]) * (m[2] === "B" ? 1e9 : 1e6), unit: m[2], text: m[0] });
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
export function checkFreeTierHeadline(content, totals) {
|
||||
const claims = extractHeadlineClaims(content);
|
||||
if (!claims.length) return { ok: true, detail: "no aggregate free-tier headline in this file" };
|
||||
@@ -279,14 +297,31 @@ export function checkFreeTierHeadline(content, totals) {
|
||||
const stale = claims.filter(
|
||||
(c) => Math.abs(c.value - steady) >= 0.05 && Math.abs(c.value - first) >= 0.05
|
||||
);
|
||||
if (!stale.length)
|
||||
return { ok: true, detail: `${claims.length} headline claim(s) match the live catalog` };
|
||||
return {
|
||||
ok: false,
|
||||
detail:
|
||||
const problems = [];
|
||||
if (stale.length) {
|
||||
problems.push(
|
||||
`stale headline ${[...new Set(stale.map((c) => c.text))].join(", ")} — live catalog ` +
|
||||
`computes ~${steady.toFixed(2)}B steady / ~${first.toFixed(2)}B first month`,
|
||||
};
|
||||
`computes ~${steady.toFixed(2)}B steady / ~${first.toFixed(2)}B first month`
|
||||
);
|
||||
}
|
||||
if (totals.g != null && totals.g > 0) {
|
||||
const gated = extractGatedClaims(content);
|
||||
const tol = (c) => (c.unit === "B" ? 0.05e9 : 0.5e6);
|
||||
const gatedStale = gated.filter((c) => Math.abs(c.tokens - totals.g) >= tol(c));
|
||||
if (!gated.length) {
|
||||
problems.push(
|
||||
`missing gated figure — live catalog computes ${Math.round(totals.g / 1e6)}M behind regional identity verification`
|
||||
);
|
||||
} else if (gatedStale.length) {
|
||||
problems.push(
|
||||
`stale gated figure ${[...new Set(gatedStale.map((c) => c.text))].join(", ")} — live catalog ` +
|
||||
`computes ${Math.round(totals.g / 1e6)}M behind regional identity verification`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!problems.length)
|
||||
return { ok: true, detail: `${claims.length} headline claim(s) match the live catalog` };
|
||||
return { ok: false, detail: problems.join("; ") };
|
||||
}
|
||||
|
||||
// PURE: docs prose that names the product version ("OmniRoute v3.8.50 ·",
|
||||
@@ -599,12 +634,12 @@ export function buildChecks() {
|
||||
},
|
||||
{
|
||||
label: "Free-tier headline (live catalog)",
|
||||
actual: `~${(f.freeSteady / 1e9).toFixed(2)}B steady / ${f.freePools} pools`,
|
||||
actual: `~${(f.freeSteady / 1e9).toFixed(2)}B steady / ${f.freePools} pools / ${Math.round(f.freeGated / 1e6)}M gated`,
|
||||
docKey: "free-tier headline",
|
||||
strict: true,
|
||||
files: ["README.md", "docs/reference/FREE_TIERS.md"],
|
||||
validate: (content) =>
|
||||
checkFreeTierHeadline(content, { s: f.freeSteady, m: f.freeFirst }),
|
||||
checkFreeTierHeadline(content, { s: f.freeSteady, m: f.freeFirst, g: f.freeGated }),
|
||||
},
|
||||
claim(
|
||||
f.engines,
|
||||
|
||||
103
scripts/check/check-docs-frontmatter.mjs
Normal file
103
scripts/check/check-docs-frontmatter.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validates the frontmatter of every Markdown file that fumadocs-mdx compiles.
|
||||
*
|
||||
* Why this gate exists: `source.config.ts` feeds `docs/**` globs to
|
||||
* `defineDocs()`, and fumadocs' default frontmatter schema REQUIRES a `title`
|
||||
* string. A doc added without frontmatter does not fail any docs gate — it
|
||||
* fails the **production build** with a generic Turbopack error
|
||||
* (`[MDX] invalid frontmatter … title: Invalid input: expected string,
|
||||
* received undefined`), which then cascades into `check:pack-artifact` and the
|
||||
* tarball boot-smoke. That is exactly how #12478 turned the release branch red
|
||||
* (base-red #12581): one new reference doc, no frontmatter, three failing
|
||||
* gates and an unbuildable branch.
|
||||
*
|
||||
* Catching it here costs milliseconds instead of a full Next build.
|
||||
*
|
||||
* The globs are read from `source.config.ts` rather than duplicated, so adding
|
||||
* a new docs directory there cannot silently escape this check.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const CONFIG_PATH = path.join(ROOT, "source.config.ts");
|
||||
|
||||
/** Extract the `files: [...]` globs declared in source.config.ts. */
|
||||
function readConfiguredGlobs() {
|
||||
const src = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||
const block = src.match(/files\s*:\s*\[([\s\S]*?)\]/);
|
||||
if (!block) {
|
||||
console.error(
|
||||
"[docs-frontmatter] FAIL — could not locate the `files:` globs in source.config.ts"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const globs = [...block[1].matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]);
|
||||
if (globs.length === 0) {
|
||||
console.error("[docs-frontmatter] FAIL — source.config.ts declares no doc globs");
|
||||
process.exit(1);
|
||||
}
|
||||
return globs;
|
||||
}
|
||||
|
||||
/** "./reference/**\/*.md" -> the directory under docs/ it covers. */
|
||||
function globToDir(glob) {
|
||||
const cleaned = glob.replace(/^\.\//, "");
|
||||
const dir = cleaned.split("/**")[0];
|
||||
return path.join(ROOT, "docs", dir);
|
||||
}
|
||||
|
||||
function walkMarkdown(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...walkMarkdown(full));
|
||||
else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
const files = [...new Set(readConfiguredGlobs().flatMap((g) => walkMarkdown(globToDir(g))))];
|
||||
|
||||
for (const file of files) {
|
||||
const rel = path.relative(ROOT, file);
|
||||
const text = fs.readFileSync(file, "utf-8");
|
||||
|
||||
if (!text.startsWith("---")) {
|
||||
violations.push(`${rel}: no frontmatter block (fumadocs requires a \`title\`)`);
|
||||
continue;
|
||||
}
|
||||
const end = text.indexOf("\n---", 3);
|
||||
if (end === -1) {
|
||||
violations.push(`${rel}: frontmatter block is never closed`);
|
||||
continue;
|
||||
}
|
||||
const frontmatter = text.slice(3, end);
|
||||
const title = frontmatter.match(/^\s*title\s*:\s*(.+)$/m);
|
||||
if (!title) {
|
||||
violations.push(`${rel}: frontmatter has no \`title\``);
|
||||
} else if (title[1].trim().replace(/^["']|["']$/g, "") === "") {
|
||||
violations.push(`${rel}: \`title\` is empty`);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(
|
||||
`[docs-frontmatter] FAIL — ${violations.length} doc(s) would break the Next build:`
|
||||
);
|
||||
for (const v of violations) console.error(` - ${v}`);
|
||||
console.error(
|
||||
"\nEvery Markdown file matched by source.config.ts is compiled by fumadocs-mdx and needs a\n" +
|
||||
'frontmatter block with a title, e.g.:\n\n---\ntitle: "Removed Providers"\nversion: 3.8.51\nlastUpdated: 2026-09-03\n---\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[docs-frontmatter] OK — ${files.length} compiled doc(s) carry a valid frontmatter title.`
|
||||
);
|
||||
75
scripts/check/check-git-identity.sh
Executable file
75
scripts/check/check-git-identity.sh
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env sh
|
||||
# Guard de identidade de commit — previne misattribution de autoria.
|
||||
#
|
||||
# Contexto (ver .mailmap na raiz): este checkout já produziu DUAS janelas de
|
||||
# commits com autoria trocada, ambas por um override de identidade deixado para
|
||||
# trás por uma sessão automatizada:
|
||||
# 1. 2026-08-13..26 — nome "Xiangzhe" + e-mail de @backryun (237 commits)
|
||||
# 2. 2026-08-29..09-02 — nome "Markus Hartung" + e-mail do mantenedor (59 commits)
|
||||
#
|
||||
# Este gate NÃO impõe uma identidade única: contribuidores commitam normalmente
|
||||
# com a sua, e creditar um contribuidor via `--author` continua funcionando.
|
||||
# Ele bloqueia apenas as duas assinaturas do defeito:
|
||||
# (a) um COMMITTER que não é a identidade desta máquina (pega ambas as janelas);
|
||||
# (b) um AUTHOR com o e-mail do mantenedor sob o nome de outra pessoa;
|
||||
# (c) um e-mail explicitamente aposentado (`omniroute.legacyEmail`).
|
||||
#
|
||||
# Ativação — opcional e por máquina; sem ela o gate é inerte:
|
||||
# git config --global omniroute.expectedName "diegosouzapw"
|
||||
# git config --global omniroute.expectedEmail "8016841+diegosouzapw@users.noreply.github.com"
|
||||
# git config --global --add omniroute.legacyEmail "diegosouzapw@users.noreply.github.com"
|
||||
|
||||
expected_name=$(git config --get omniroute.expectedName 2>/dev/null)
|
||||
expected_email=$(git config --get omniroute.expectedEmail 2>/dev/null)
|
||||
legacy_emails=$(git config --get-all omniroute.legacyEmail 2>/dev/null)
|
||||
|
||||
# Sem configuração nesta máquina o gate não opina — contribuidores não são afetados.
|
||||
[ -z "$expected_email" ] && exit 0
|
||||
|
||||
an=$(git var GIT_AUTHOR_IDENT 2>/dev/null | sed 's/ <.*//')
|
||||
ae=$(git var GIT_AUTHOR_IDENT 2>/dev/null | sed 's/.*<//; s/>.*//')
|
||||
cn=$(git var GIT_COMMITTER_IDENT 2>/dev/null | sed 's/ <.*//')
|
||||
ce=$(git var GIT_COMMITTER_IDENT 2>/dev/null | sed 's/.*<//; s/>.*//')
|
||||
|
||||
fail=0
|
||||
|
||||
# (a) O COMMITTER é quem executa o commit — nesta máquina, sempre o dono dela.
|
||||
# Um override de identidade esquecido por uma sessão aparece exatamente aqui,
|
||||
# e foi o que passou despercebido nas duas janelas: em agosto NEM o nome NEM
|
||||
# o e-mail eram do mantenedor, então checar só o e-mail dele não bastaria.
|
||||
if [ "$ce" != "$expected_email" ] || { [ -n "$expected_name" ] && [ "$cn" != "$expected_name" ]; }; then
|
||||
echo "🛑 COMMITTER não é a identidade desta máquina: $cn <$ce>" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (b) O AUTHOR pode ser um contribuidor (crédito via --author), mas nunca pode
|
||||
# carregar o e-mail do mantenedor sob o nome de outra pessoa.
|
||||
if [ -n "$expected_name" ] && [ "$ae" = "$expected_email" ] && [ "$an" != "$expected_name" ]; then
|
||||
echo "🛑 AUTHOR combina o e-mail do mantenedor com outro nome: $an <$ae>" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (c) e-mails aposentados que já causaram misattribution.
|
||||
for legacy in $legacy_emails; do
|
||||
if [ "$ae" = "$legacy" ]; then
|
||||
echo "🛑 AUTHOR usa e-mail aposentado: $an <$ae>" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [ "$ce" = "$legacy" ]; then
|
||||
echo "🛑 COMMITTER usa e-mail aposentado: $cn <$ce>" >&2
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$fail" = "0" ] && exit 0
|
||||
|
||||
cat >&2 <<MSG
|
||||
|
||||
Identidade esperada nesta máquina: $expected_name <$expected_email>
|
||||
Corrija com:
|
||||
git config --global user.name "$expected_name"
|
||||
git config --global user.email "$expected_email"
|
||||
Para creditar um contribuidor, use o E-MAIL DELE (nunca o seu):
|
||||
git commit --author="Nome <email-do-contribuidor>"
|
||||
MSG
|
||||
exit 1
|
||||
@@ -211,6 +211,8 @@ export const KNOWN_TRANSLATOR_PAIRS: readonly string[] = [
|
||||
"antigravity:openai",
|
||||
"claude:gemini",
|
||||
"claude:openai",
|
||||
// Naver CLOVA Studio Chat Completions v3 (native envelope, model in URL path).
|
||||
"clova:openai",
|
||||
"cursor:openai",
|
||||
"gemini:claude",
|
||||
"gemini:openai",
|
||||
@@ -218,6 +220,7 @@ export const KNOWN_TRANSLATOR_PAIRS: readonly string[] = [
|
||||
"openai-responses:openai",
|
||||
"openai:antigravity",
|
||||
"openai:claude",
|
||||
"openai:clova",
|
||||
"openai:cursor",
|
||||
"openai:gemini",
|
||||
"openai:kiro",
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cross-references openapi.yaml x-loopback-only / x-always-protected annotations
|
||||
* against the compile-time constants in src/server/authz/routeGuard.ts.
|
||||
* against the compile-time route-classification constants in
|
||||
* src/server/authz/routeGuard.ts.
|
||||
*
|
||||
* Fails if any YAML annotation disagrees with the routeGuard.ts constants.
|
||||
* routeGuard classifies a loopback-only route through TWO mechanisms, and this
|
||||
* checker must honor BOTH or it reports false positives (regression #12335):
|
||||
*
|
||||
* 1. LOCAL_ONLY_API_PREFIXES — flat string prefixes. One entry
|
||||
* (VNC_ROUTE_PREFIX) is an imported const rather than a string literal, so
|
||||
* it is resolved from its source module.
|
||||
* 2. LOCAL_ONLY_API_PATTERNS — RegExp entries for spawn-capable routes whose
|
||||
* dynamic path parameter sits BEFORE the gated segment (e.g.
|
||||
* /api/providers/{id}/login), which a flat prefix cannot target without
|
||||
* over-broadening the whole /api/providers/ subtree.
|
||||
*
|
||||
* A route is "covered" iff it matches a resolved prefix OR a pattern — exactly
|
||||
* the `isLocalOnlyPath()` runtime contract. Fails if any YAML annotation
|
||||
* disagrees with the routeGuard.ts constants.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
@@ -13,34 +27,132 @@ import * as yaml from "js-yaml";
|
||||
const ROOT = process.cwd();
|
||||
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
|
||||
const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.ts");
|
||||
const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8");
|
||||
|
||||
function parseStringArray(match) {
|
||||
if (!match) return [];
|
||||
// Strip line comments before splitting — array entries in routeGuard.ts often
|
||||
// carry inline `// T-XX:` annotations that would otherwise pollute the parsed tokens.
|
||||
return match[1]
|
||||
.replace(/\/\/[^\n]*/g, "")
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
||||
.filter(Boolean);
|
||||
// Capture an exported array's body up to its closing `\n];`. Unlike a `[^\]]+`
|
||||
// capture, this is immune to `]` characters inside comments or regex character
|
||||
// classes (e.g. `[^/]`) — the exact footgun documented at routeGuard.ts's
|
||||
// /api/oauth/cursor/auto-import entry, and the reason regex patterns could not
|
||||
// be parsed at all before.
|
||||
function extractArrayBody(name) {
|
||||
const m = guardSrc.match(
|
||||
new RegExp(`export const ${name}\\b[\\s\\S]*?=\\s*\\[([\\s\\S]*?)\\n\\];`)
|
||||
);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8");
|
||||
const LOCAL_ONLY_PREFIXES = parseStringArray(
|
||||
guardSrc.match(/export const LOCAL_ONLY_API_PREFIXES.*?=\s*\[([^\]]+)\]/s)
|
||||
);
|
||||
const ALWAYS_PROTECTED_PATHS = parseStringArray(
|
||||
guardSrc.match(/export const ALWAYS_PROTECTED_API_PATHS.*?=\s*\[([^\]]+)\]/s)
|
||||
);
|
||||
const stripLineComments = (s) => s.replace(/\/\/[^\n]*/g, "");
|
||||
|
||||
if (LOCAL_ONLY_PREFIXES.length === 0 || ALWAYS_PROTECTED_PATHS.length === 0) {
|
||||
console.error("[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants");
|
||||
function resolveModule(spec) {
|
||||
let base;
|
||||
if (spec.startsWith("@/")) base = path.join(ROOT, "src", spec.slice(2));
|
||||
else if (spec.startsWith(".")) base = path.resolve(path.dirname(ROUTE_GUARD_PATH), spec);
|
||||
else throw new Error(`openapi-security-tiers: unsupported import specifier '${spec}'`);
|
||||
for (const cand of [base, `${base}.ts`, `${base}.mts`, path.join(base, "index.ts")]) {
|
||||
if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand;
|
||||
}
|
||||
throw new Error(`openapi-security-tiers: cannot resolve module '${spec}' (from ${base})`);
|
||||
}
|
||||
|
||||
// Resolve a bare identifier used inside a prefix array (e.g. VNC_ROUTE_PREFIX)
|
||||
// to its string-literal value by following its import in routeGuard.ts.
|
||||
function resolveIdentifier(ident) {
|
||||
const imp = guardSrc.match(
|
||||
new RegExp(`import\\s*(?:type\\s*)?\\{[^}]*\\b${ident}\\b[^}]*\\}\\s*from\\s*["']([^"']+)["']`)
|
||||
);
|
||||
if (!imp)
|
||||
throw new Error(
|
||||
`openapi-security-tiers: '${ident}' used in a prefix array has no import in routeGuard.ts`
|
||||
);
|
||||
const modSrc = fs.readFileSync(resolveModule(imp[1]), "utf-8");
|
||||
const lit = modSrc.match(new RegExp(`export const ${ident}\\s*=\\s*["']([^"']+)["']`));
|
||||
if (!lit)
|
||||
throw new Error(`openapi-security-tiers: cannot resolve '${ident}' to a string literal`);
|
||||
return lit[1];
|
||||
}
|
||||
|
||||
// String prefixes: quoted entries pass through; bare identifiers are resolved.
|
||||
function parsePrefixes(name) {
|
||||
const body = extractArrayBody(name);
|
||||
if (body == null)
|
||||
throw new Error(`openapi-security-tiers: could not locate ${name} in routeGuard.ts`);
|
||||
return stripLineComments(body)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((tok) => {
|
||||
const unquoted = tok.replace(/^["']|["']$/g, "");
|
||||
return unquoted !== tok ? unquoted : resolveIdentifier(tok);
|
||||
});
|
||||
}
|
||||
|
||||
// RegExp patterns: one `/.../ ` literal per line.
|
||||
function parsePatterns(name) {
|
||||
const body = extractArrayBody(name);
|
||||
if (body == null)
|
||||
throw new Error(`openapi-security-tiers: could not locate ${name} in routeGuard.ts`);
|
||||
const out = [];
|
||||
for (const raw of body.split("\n")) {
|
||||
const t = raw
|
||||
.replace(/\/\/.*$/, "")
|
||||
.trim()
|
||||
.replace(/,\s*$/, "")
|
||||
.trim();
|
||||
if (t.length > 2 && t.startsWith("/") && t.endsWith("/")) out.push(new RegExp(t.slice(1, -1)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const LOCAL_ONLY_PREFIXES = parsePrefixes("LOCAL_ONLY_API_PREFIXES");
|
||||
const LOCAL_ONLY_PATTERNS = parsePatterns("LOCAL_ONLY_API_PATTERNS");
|
||||
const ALWAYS_PROTECTED_PATHS = parsePrefixes("ALWAYS_PROTECTED_API_PATHS");
|
||||
// isAlwaysProtectedPath() is ALSO two-armed (paths || patterns) — reading only the
|
||||
// path array repeated, on this half, the very bug #12350 fixed on the LOCAL_ONLY
|
||||
// half: the pattern-gated credential routes (…/{claude,codex}-auth/{export,
|
||||
// apply-local}, #12600) read as unannotated even though they are protected.
|
||||
const ALWAYS_PROTECTED_PATTERNS = parsePatterns("ALWAYS_PROTECTED_API_PATTERNS");
|
||||
|
||||
if (
|
||||
LOCAL_ONLY_PREFIXES.length === 0 ||
|
||||
LOCAL_ONLY_PATTERNS.length === 0 ||
|
||||
ALWAYS_PROTECTED_PATHS.length === 0 ||
|
||||
ALWAYS_PROTECTED_PATTERNS.length === 0
|
||||
) {
|
||||
console.error(
|
||||
`[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants ` +
|
||||
`(prefixes=${LOCAL_ONLY_PREFIXES.length}, patterns=${LOCAL_ONLY_PATTERNS.length}, ` +
|
||||
`alwaysProtected=${ALWAYS_PROTECTED_PATHS.length}, ` +
|
||||
`alwaysProtectedPatterns=${ALWAYS_PROTECTED_PATTERNS.length})`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// OpenAPI template params ({id}, {sessionId}, …) → a concrete single non-slash
|
||||
// segment, so pattern regexes written against resolved paths (`[^/]+`) match.
|
||||
const concretize = (p) => p.replace(/\{[^}]+\}/g, "x");
|
||||
|
||||
const matchesPrefix = (concrete) =>
|
||||
LOCAL_ONLY_PREFIXES.some((prefix) => {
|
||||
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
||||
return concrete === norm || concrete.startsWith(`${norm}/`);
|
||||
});
|
||||
|
||||
function coveredByLocalOnly(pathStr) {
|
||||
const concrete = concretize(pathStr);
|
||||
return matchesPrefix(concrete) || LOCAL_ONLY_PATTERNS.some((re) => re.test(concrete));
|
||||
}
|
||||
|
||||
/** Mirror of routeGuard.isAlwaysProtectedPath() — both arms, same order. */
|
||||
function coveredByAlwaysProtected(pathStr) {
|
||||
const concrete = concretize(pathStr);
|
||||
return (
|
||||
ALWAYS_PROTECTED_PATHS.some((p) => concrete === p || concrete.startsWith(`${p}/`)) ||
|
||||
ALWAYS_PROTECTED_PATTERNS.some((re) => re.test(concrete))
|
||||
);
|
||||
}
|
||||
|
||||
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
|
||||
const paths = raw.paths || {};
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const [pathStr, methods] of Object.entries(paths)) {
|
||||
@@ -48,50 +160,29 @@ for (const [pathStr, methods] of Object.entries(paths)) {
|
||||
for (const [method, spec] of Object.entries(methods)) {
|
||||
if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
|
||||
|
||||
if (spec["x-loopback-only"] === true) {
|
||||
const matchesPrefix = LOCAL_ONLY_PREFIXES.some((prefix) => {
|
||||
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
||||
return pathStr === norm || pathStr.startsWith(norm + "/");
|
||||
});
|
||||
if (!matchesPrefix) {
|
||||
errors.push(
|
||||
`${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` +
|
||||
`LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}]`
|
||||
);
|
||||
}
|
||||
if (spec["x-loopback-only"] === true && !coveredByLocalOnly(pathStr)) {
|
||||
errors.push(
|
||||
`${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` +
|
||||
`LOCAL_ONLY_API_PREFIXES or LOCAL_ONLY_API_PATTERNS`
|
||||
);
|
||||
}
|
||||
|
||||
if (spec["x-always-protected"] === true) {
|
||||
const matchesPath = ALWAYS_PROTECTED_PATHS.some(
|
||||
(p) => pathStr === p || pathStr.startsWith(`${p}/`)
|
||||
if (spec["x-always-protected"] === true && !coveredByAlwaysProtected(pathStr)) {
|
||||
errors.push(
|
||||
`${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT covered by ` +
|
||||
`ALWAYS_PROTECTED_API_PATHS or ALWAYS_PROTECTED_API_PATTERNS`
|
||||
);
|
||||
if (!matchesPath) {
|
||||
errors.push(
|
||||
`${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT in ` +
|
||||
`ALWAYS_PROTECTED_API_PATHS [${ALWAYS_PROTECTED_PATHS.join(", ")}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse pass: every YAML path that falls under a LOCAL_ONLY prefix should
|
||||
// carry `x-loopback-only: true` on every method, otherwise external API
|
||||
// consumers have no signal that the route is loopback-restricted. Closes the
|
||||
// "new spawn-capable route added without annotation" regression class.
|
||||
//
|
||||
// Currently reported as warnings (non-fatal) because the v3.8.4 release ships
|
||||
// with a known annotation gap on /api/services/* and /api/cli-tools/runtime/*
|
||||
// that will be patched in a follow-up doc-only PR. Promote to errors once the
|
||||
// backlog is cleared.
|
||||
// Reverse pass (non-fatal): every YAML path that falls under a LOCAL_ONLY prefix
|
||||
// should carry `x-loopback-only`. Pattern-only routes are intentionally excluded
|
||||
// — they are not "under" a broad prefix. Known annotation gaps stay warnings.
|
||||
const reverseWarnings = [];
|
||||
for (const [pathStr, methods] of Object.entries(paths)) {
|
||||
if (!methods || typeof methods !== "object") continue;
|
||||
const fallsUnderLocalOnly = LOCAL_ONLY_PREFIXES.some((prefix) => {
|
||||
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
||||
return pathStr === norm || pathStr.startsWith(norm + "/");
|
||||
});
|
||||
if (!fallsUnderLocalOnly) continue;
|
||||
if (!matchesPrefix(concretize(pathStr))) continue;
|
||||
for (const [method, spec] of Object.entries(methods)) {
|
||||
if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
|
||||
if (spec["x-loopback-only"] !== true) {
|
||||
@@ -105,7 +196,7 @@ for (const [pathStr, methods] of Object.entries(paths)) {
|
||||
|
||||
if (reverseWarnings.length > 0) {
|
||||
console.warn(
|
||||
`[openapi-security-tiers] WARN — ${reverseWarnings.length} LOCAL_ONLY paths missing x-loopback-only annotation (non-fatal, follow-up doc PR):`
|
||||
`[openapi-security-tiers] WARN — ${reverseWarnings.length} LOCAL_ONLY paths missing x-loopback-only annotation (non-fatal):`
|
||||
);
|
||||
reverseWarnings.forEach((w) => console.warn(` - ${w}`));
|
||||
}
|
||||
|
||||
@@ -90,14 +90,18 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/;
|
||||
// The MiniMax family was extracted from services/usage.ts into services/usage/minimax.ts
|
||||
// (god-file decomposition), so the FP moved with the getMiniMaxUsage signature.
|
||||
//
|
||||
// open-sse/executors/zcodeProtocol.ts L302: `clientId: \`omniroute-${process.pid}\``
|
||||
// open-sse/executors/zcodeProtocol.ts L313: `clientId: \`omniroute-${process.pid}\``
|
||||
// is the per-process identifier in the local ZCode app-server handshake. It is
|
||||
// generated from the process PID, is not an upstream OAuth/client credential, and
|
||||
// must remain visible in the wire contract. Frozen by file:line:value key.
|
||||
// NOTE: the key includes the LINE, so any edit that shifts this statement breaks
|
||||
// the gate twice over — a stale-entry error plus a "new violation" for the same
|
||||
// literal. That is what happened here (L302 -> L313). Re-point the line; do not
|
||||
// remove the entry.
|
||||
export const KNOWN_LITERAL_CREDS = new Set([
|
||||
"open-sse/services/usage/minimax.ts:213:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature)
|
||||
"open-sse/services/usage/minimax.ts:213:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature)
|
||||
"open-sse/executors/zcodeProtocol.ts:302:omniroute-${process.pid}", // local per-process ZCode handshake ID, not an upstream credential
|
||||
"open-sse/executors/zcodeProtocol.ts:313:omniroute-${process.pid}", // local per-process ZCode handshake ID, not an upstream credential
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,15 @@ export const SECTIONS = Object.freeze({
|
||||
|
||||
const SKIP_FILES = new Set(["README.md", ".gitkeep"]);
|
||||
|
||||
/** The living cycle version = package.json `version` (null when unreadable → legacy first-heading mode). */
|
||||
export function readVersion(root = ROOT) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one fragment's text. Returns null when OK, or a human-readable error.
|
||||
* Pure — unit-tested.
|
||||
@@ -89,17 +98,32 @@ export function collectFragments(root) {
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* next "##"/"###" heading). When `version` is given the heading is searched INSIDE the
|
||||
* `## [version]` block only — `[Unreleased]` still carries a `### ✨ New Features`
|
||||
* heading, so the first occurrence in the file is the wrong one (v3.8.51: every feature
|
||||
* fragment was landing under `[Unreleased]`, #12971). Without `version` the FIRST
|
||||
* occurrence is used (legacy behaviour). 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) {
|
||||
export function insertBullets(changelogText, bulletsBySection, version = null) {
|
||||
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);
|
||||
let from = 0;
|
||||
let to = lines.length;
|
||||
if (version) {
|
||||
from = lines.findIndex((l) => l.startsWith(`## [${version}]`));
|
||||
if (from === -1) {
|
||||
throw new Error(
|
||||
`section "## [${version}]" not found in CHANGELOG.md — fragments must land in the living version section`
|
||||
);
|
||||
}
|
||||
to = lines.findIndex((l, i) => i > from && l.startsWith("## ["));
|
||||
if (to === -1) to = lines.length;
|
||||
}
|
||||
const rel = lines.slice(from, to).findIndex((l) => l.trim() === heading);
|
||||
const headIdx = rel === -1 ? -1 : from + rel;
|
||||
if (headIdx === -1) {
|
||||
throw new Error(
|
||||
`heading "${heading}" not found in CHANGELOG.md — add it to the living section before aggregating ${section} fragments`
|
||||
@@ -125,7 +149,7 @@ export function insertBullets(changelogText, bulletsBySection) {
|
||||
* 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 } = {}) {
|
||||
export function aggregate({ root = ROOT, dryRun = false, version = readVersion(root) } = {}) {
|
||||
const collected = collectFragments(root);
|
||||
if (collected.invalid.length > 0) {
|
||||
const detail = collected.invalid.map((i) => ` ✗ ${i.file}: ${i.error}`).join("\n");
|
||||
@@ -134,7 +158,7 @@ export function aggregate({ root = ROOT, dryRun = false } = {}) {
|
||||
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);
|
||||
const after = total === 0 ? before : insertBullets(before, collected, version);
|
||||
if (!dryRun && total > 0) {
|
||||
writeFileSync(changelogPath, after);
|
||||
for (const section of Object.keys(SECTIONS)) {
|
||||
|
||||
831
scripts/release/reconcile-changelog.mjs
Normal file
831
scripts/release/reconcile-changelog.mjs
Normal file
@@ -0,0 +1,831 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/release/reconcile-changelog.mjs
|
||||
//
|
||||
// Reconcile the living `## [<version>]` CHANGELOG section against the FULL development cycle
|
||||
// (Phase 0a.1–0a.3a of /generate-release), so that at the moment a release is cut:
|
||||
// • every cycle commit is represented by a bullet whose PRIMARY reference is that commit's PR,
|
||||
// • every bullet carries the merged PR link and `— thanks @author` (Hard Rule #16),
|
||||
// • fragments are folded in under the RIGHT version section (not the first heading that
|
||||
// matches — `[Unreleased]` still carries a `### ✨ New Features` heading),
|
||||
// • fragments that duplicate bullets already shipped in a previous version are dropped,
|
||||
// • the section opens with "📊 Release by the numbers" + "🏆 Top 25 contributors" (v3.8.50 format).
|
||||
//
|
||||
// It never touches bullets that already exist in the section (the changelog-integrity gate
|
||||
// compares bullet lines against the base), never touches `[Unreleased]`, and never edits any
|
||||
// other version section. Run `npm run release:contributors -- <version> --inject` afterwards to
|
||||
// (re)build the `### 🙌 Contributors` table, then `release:sync-changelog-i18n`.
|
||||
//
|
||||
// Lessons baked in (v3.8.51 reconciliation, 2026-09-07 — PR #12971):
|
||||
// • prefix of a fragment filename is NOT a reliable PR number (issue numbers, closed/recreated
|
||||
// PRs, literal `#PR_NUMBER`); the commit that ADDED the fragment (`git log --diff-filter=A`)
|
||||
// is the definitive origin — unless that commit is a "carrier" PR that only back-filled
|
||||
// fragments for other people's PRs (`--carrier N`), then the prefix wins;
|
||||
// • a commit is covered only when its OWN PR is a primary ref (a `/pull/N` link or the last
|
||||
// `#N` on the bullet line) — an incidental mention ("Opper #11629 + 1min.ai #11631") must not
|
||||
// hide a PR's own bullet;
|
||||
// • gen-contributors only reads lines that start with "- ", so fragment bullets are collapsed
|
||||
// to a single line (pre-existing section bullets are left verbatim).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/release/reconcile-changelog.mjs [--version 3.8.51] [--base <ref>] [--head <ref>]
|
||||
// [--release-branch release/v3.8.51] [--credit 12255=backryun] [--carrier 11938]
|
||||
// [--drop-fragment changelog.d/fixes/x.md] [--fragment-pr changelog.d/fixes/y.md=11845]
|
||||
// [--prs <cached gh json>] [--dry-run] [--report <path.json>]
|
||||
//
|
||||
// Defaults: version = package.json; release branch = release/v<version>; head = HEAD;
|
||||
// base = parent of the commit that opened the cycle (first commit carrying the version string,
|
||||
// see resolveCycleBase in list-uncovered-commits.mjs) — pass `--base origin/release/v<prev>` to
|
||||
// use the previous release tip explicitly. Exit 0 always (advisory: the captain reviews the diff).
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveCycleBase } from "./list-uncovered-commits.mjs";
|
||||
|
||||
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
export const MAINTAINER = "diegosouzapw";
|
||||
export const SECTION_HEADINGS = Object.freeze({
|
||||
features: "### ✨ New Features",
|
||||
fixes: "### 🐛 Bug Fixes",
|
||||
maintenance: "### 📝 Maintenance",
|
||||
});
|
||||
const TYPE_LABEL = {
|
||||
fix: "🐛 Fixes",
|
||||
feat: "✨ Features",
|
||||
docs: "📚 Docs",
|
||||
chore: "🧹 Chore",
|
||||
test: "🧪 Tests",
|
||||
refactor: "♻️ Refactor",
|
||||
perf: "⚡ Performance",
|
||||
security: "🔒 Security",
|
||||
ci: "⚙️ CI",
|
||||
deps: "📦 Dependencies",
|
||||
build: "🏗️ Build",
|
||||
revert: "⏪ Reverts",
|
||||
other: "🔀 Other",
|
||||
};
|
||||
const BOT_RE = /dependabot|\[bot\]|^app\//i;
|
||||
|
||||
// ───────────────────────────── pure helpers (unit-tested) ─────────────────────────────
|
||||
|
||||
/** `[#N](url)` → `#N`, `[@h](url)` → `@h` so refs/handles can be scanned uniformly. */
|
||||
export const normalizeLinks = (s) =>
|
||||
s.replace(/\[#(\d+)\]\([^)]*\)/g, "#$1").replace(/\[@([A-Za-z0-9_-]+)\]\([^)]*\)/g, "@$1");
|
||||
export const refsIn = (s) => [...s.matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
|
||||
export const prLink = (repo, n) => `[#${n}](https://github.com/${repo}/pull/${n})`;
|
||||
|
||||
/**
|
||||
* Primary refs of a bullet: every `/pull/N` link, the LAST `#N` on its first line (the
|
||||
* conventional trailing `(#N)` back-reference) and every explicit `(#N …)` group — a group
|
||||
* that OPENS with the ref, e.g. `(#11436)` or `(#11436 — thanks @x)`. An incidental mention
|
||||
* inside prose (`(Opper #11629 + 1min.ai #11631)`) does not count.
|
||||
*/
|
||||
export function primaryRefs(bullet) {
|
||||
const flat = normalizeLinks(bullet.replace(/\n\s+/g, " "));
|
||||
const out = new Set([...bullet.matchAll(/\/pull\/(\d+)\)/g)].map((m) => Number(m[1])));
|
||||
const refs = refsIn(flat);
|
||||
if (refs.length) out.add(refs[refs.length - 1]);
|
||||
for (const m of flat.matchAll(/\(#(\d+)(?=[\s,)—-])/g)) out.add(Number(m[1]));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Commit hashes a bullet documents explicitly as `(direct commit \`<hash>\`)`. */
|
||||
export const directHashes = (bullet) =>
|
||||
[...bullet.matchAll(/direct commit `([0-9a-f]{7,40})`/g)].map((m) => m[1]);
|
||||
|
||||
/**
|
||||
* Split a markdown text into bullet blocks per section heading. A block is a `- ` line plus its
|
||||
* indented continuation lines. `fixedSection` forces every bullet into one section (fragments).
|
||||
* `collapse` joins continuation lines into the first line (fragments only — never pre-existing
|
||||
* section bullets, which the changelog-integrity gate compares line by line).
|
||||
*/
|
||||
export function parseBlocks(text, { fixedSection = null, collapse = false } = {}) {
|
||||
const out = { features: [], fixes: [], maintenance: [] };
|
||||
let cur = fixedSection;
|
||||
let block = null;
|
||||
const flush = () => {
|
||||
if (block && cur) {
|
||||
out[cur].push(
|
||||
collapse
|
||||
? block.map((l, i) => (i ? l.trim() : l.replace(/\s+$/, ""))).join(" ")
|
||||
: block.join("\n")
|
||||
);
|
||||
}
|
||||
block = null;
|
||||
};
|
||||
for (const line of text.split("\n")) {
|
||||
if (!fixedSection && line.startsWith("### ")) {
|
||||
flush();
|
||||
cur = Object.keys(SECTION_HEADINGS).find((k) => SECTION_HEADINGS[k] === line.trim()) || null;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("- ")) {
|
||||
flush();
|
||||
if (cur) block = [line];
|
||||
continue;
|
||||
}
|
||||
if (block && /^\s+\S/.test(line)) {
|
||||
block.push(line);
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Escape every regex metacharacter (CodeQL js/incomplete-sanitization: never escape just one). */
|
||||
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const mentions = (b, h) => new RegExp(`@${escapeRegExp(h)}(?![A-Za-z0-9_-])`, "i").test(b);
|
||||
|
||||
/** Append `— thanks @a / @b` (or extend an existing trailing thanks group) for handles not yet mentioned. */
|
||||
export function addCredit(bullet, handles, maintainer = MAINTAINER) {
|
||||
const hs = [...new Set(handles.filter((h) => h && h !== maintainer && !mentions(bullet, h)))];
|
||||
if (!hs.length) return bullet;
|
||||
const lines = bullet.split("\n");
|
||||
let last = lines[lines.length - 1];
|
||||
const add = hs.map((h) => `@${h}`).join(" / ");
|
||||
if (/thanks\s+@[A-Za-z0-9_-]+(\s*\/\s*@[A-Za-z0-9_-]+)*\s*$/.test(last)) {
|
||||
last = `${last.replace(/\s*$/, "")} / ${add}`;
|
||||
} else if (/thanks\s+@[A-Za-z0-9_-]+(\s*\/\s*@[A-Za-z0-9_-]+)*\)\s*$/.test(last)) {
|
||||
last = `${last.replace(/\)\s*$/, "")} / ${add})`;
|
||||
} else {
|
||||
last = `${last.replace(/\s*$/, "")} — thanks ${add}`;
|
||||
}
|
||||
lines[lines.length - 1] = last;
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Append a `([#N](…))` link to the last line of a bullet, before any trailing thanks group. */
|
||||
export function appendLink(bullet, repo, n) {
|
||||
if (refsIn(normalizeLinks(bullet)).includes(n)) return bullet;
|
||||
const lines = bullet.split("\n");
|
||||
let last = lines[lines.length - 1];
|
||||
const th = last.match(/\s*—\s*thanks\s+@[^\n]*$/);
|
||||
last = th
|
||||
? `${last.slice(0, th.index).replace(/\s*$/, "")} (${prLink(repo, n)})${th[0]}`
|
||||
: `${last.replace(/\s*$/, "")} (${prLink(repo, n)})`;
|
||||
lines[lines.length - 1] = last;
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Conventional-commit subject → { type, text } with the type prefix bolded (repo format). */
|
||||
export function bulletFromSubject(subject, special = {}) {
|
||||
let s = subject
|
||||
.replace(/\s*\(#\d+\)\s*$/, "")
|
||||
.replace(/^\[?URGENT\]?\s*/i, "")
|
||||
.trim();
|
||||
if (special[s]) s = special[s];
|
||||
const m = s.match(/^([a-z]+)(\([^)]*\))?(!)?:\s*(.+)$/i);
|
||||
if (!m) return { type: "other", text: s };
|
||||
return { type: m[1].toLowerCase(), text: `**${m[1].toLowerCase()}${m[2] || ""}:** ${m[4]}` };
|
||||
}
|
||||
|
||||
export const sectionForType = (type) =>
|
||||
type === "feat"
|
||||
? "features"
|
||||
: ["fix", "perf", "security", "revert"].includes(type)
|
||||
? "fixes"
|
||||
: "maintenance";
|
||||
|
||||
/** Type of an existing bullet (`- **fix(x):** …` or `- fix(x): …`), else "other". */
|
||||
export function typeOfBullet(bullet) {
|
||||
const m = normalizeLinks(bullet).match(/^- \*{0,2}([a-z]+)(?:\([^)]*\))?!?:\*{0,2}/i);
|
||||
return m ? m[1].toLowerCase() : "other";
|
||||
}
|
||||
|
||||
/** Text key used to spot twins (two fragments for one PR, a direct-commit twin of a synced PR). */
|
||||
export const dedupKey = (b) =>
|
||||
normalizeLinks(b)
|
||||
.split("\n")[0]
|
||||
.replace(/\s*\((?:#\d+[^)]*|direct commit[^)]*)\)\s*/g, " ")
|
||||
.replace(/\s*—\s*thanks.*$/, "")
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
|
||||
/**
|
||||
* Keep one bullet per dedupKey (the best-linked one) and MERGE every `([#N](…))` /
|
||||
* `(direct commit …)` group the dropped twins carried into the survivor.
|
||||
*/
|
||||
export function dedupeBullets(list) {
|
||||
const best = new Map();
|
||||
const score = (b) => (b.match(/\/pull\//g) || []).length * 10 + b.length / 1000;
|
||||
for (const b of list) {
|
||||
const k = dedupKey(b);
|
||||
if (!best.has(k) || score(b) > best.get(k).score) best.set(k, { b, score: score(b) });
|
||||
}
|
||||
const extras = new Map();
|
||||
const dropped = [];
|
||||
for (const b of list) {
|
||||
const k = dedupKey(b);
|
||||
if (best.get(k).b === b) continue;
|
||||
dropped.push(b);
|
||||
if (!extras.has(k)) extras.set(k, []);
|
||||
for (const m of b.matchAll(/\(\[#\d+\]\([^)]*\)\)|\(direct commit `[0-9a-f]+`\)/g)) {
|
||||
extras.get(k).push(m[0]);
|
||||
}
|
||||
}
|
||||
const seen = new Set();
|
||||
const kept = [];
|
||||
for (const b of list) {
|
||||
const k = dedupKey(b);
|
||||
if (best.get(k).b !== b || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
let out = b;
|
||||
for (const g of extras.get(k) || []) {
|
||||
if (out.includes(g)) continue;
|
||||
const th = out.match(/\s*—\s*thanks[^\n]*$/);
|
||||
out = th ? `${out.slice(0, th.index)} ${g}${th[0]}` : `${out} ${g}`;
|
||||
}
|
||||
kept.push(out);
|
||||
}
|
||||
return { kept, dropped };
|
||||
}
|
||||
|
||||
/** Extract `## [version]` … up to the next `## [` (exclusive). */
|
||||
export function versionSectionRange(changelog, version) {
|
||||
const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const m = changelog.match(new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m"));
|
||||
if (!m) return null;
|
||||
const bodyStart = m.index + m[0].length;
|
||||
const rest = changelog.slice(bodyStart);
|
||||
const next = rest.search(/\n## \[/);
|
||||
return { start: m.index, bodyStart, end: next === -1 ? changelog.length : bodyStart + next };
|
||||
}
|
||||
|
||||
/** Everything under every `## [` heading OTHER than `version` (used to spot already-shipped text). */
|
||||
export function otherSectionsText(changelog, version) {
|
||||
const r = versionSectionRange(changelog, version);
|
||||
if (!r) return changelog;
|
||||
return changelog.slice(0, r.start) + changelog.slice(r.end);
|
||||
}
|
||||
|
||||
/** Commits covered by the bullets: own PR is a primary ref (or closes a cited issue); no-PR commits by any ref. */
|
||||
export function computeCoverage(
|
||||
rows,
|
||||
bullets,
|
||||
{ closingPrs = new Set(), originHashes = new Set(), skipHashes = new Set() } = {}
|
||||
) {
|
||||
const cited = new Set();
|
||||
const hashes = [];
|
||||
for (const b of bullets) {
|
||||
for (const n of primaryRefs(b)) cited.add(n);
|
||||
hashes.push(...directHashes(b));
|
||||
}
|
||||
const documentedHash = (full) =>
|
||||
hashes.some((h) => full.startsWith(h) || h.startsWith(full.slice(0, 9)));
|
||||
const uncovered = rows.filter((r) => {
|
||||
const h = r.hash.slice(0, 9);
|
||||
if (skipHashes.has(h) || originHashes.has(h)) return false;
|
||||
if (r.pr) return !(cited.has(r.pr) || closingPrs.has(r.pr));
|
||||
return !(documentedHash(r.hash) || r.refs.some((x) => cited.has(x)));
|
||||
});
|
||||
return { cited, uncovered };
|
||||
}
|
||||
|
||||
/** Rank authors by commits; key = GitHub login of the merged PR when known, else the mailmap name. */
|
||||
export function rankAuthors(rows, limit = 25) {
|
||||
const counts = {};
|
||||
const names = {};
|
||||
for (const r of rows) {
|
||||
if (BOT_RE.test(r.authorName)) continue;
|
||||
const k = r.prAuthor || r.authorName;
|
||||
counts[k] = (counts[k] || 0) + 1;
|
||||
names[k] ??= {};
|
||||
names[k][r.authorName] = (names[k][r.authorName] || 0) + 1;
|
||||
}
|
||||
const display = (k) => Object.entries(names[k]).sort((a, b) => b[1] - a[1])[0][0];
|
||||
return {
|
||||
counts,
|
||||
top: Object.entries(counts)
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, limit)
|
||||
.map(([k, c]) => [display(k) === k ? k : `${display(k)} (@${k})`, c]),
|
||||
};
|
||||
}
|
||||
|
||||
const fmt = (n) => n.toLocaleString("en-US");
|
||||
const medal = (i) => (i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : String(i + 1));
|
||||
|
||||
/** Render the whole `## [version]` section (header → stats → ranking → three sections). */
|
||||
export function renderSection({
|
||||
version,
|
||||
today,
|
||||
baseTip,
|
||||
headTip,
|
||||
rows,
|
||||
all,
|
||||
ranking,
|
||||
prNumbers,
|
||||
dateSuffix = "TBD",
|
||||
}) {
|
||||
const bullets = Object.values(all)
|
||||
.flat()
|
||||
.filter((b) => b.startsWith("- "));
|
||||
const byType = {};
|
||||
const SEC_DEFAULT = { features: "feat", fixes: "fix", maintenance: "chore" };
|
||||
for (const k of Object.keys(all)) {
|
||||
for (const b of all[k].filter((x) => x.startsWith("- "))) {
|
||||
let t = typeOfBullet(b);
|
||||
if (!TYPE_LABEL[t] || t === "other") t = SEC_DEFAULT[k];
|
||||
byType[TYPE_LABEL[t]] = (byType[TYPE_LABEL[t]] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const prRefs = new Set();
|
||||
const handles = new Set();
|
||||
for (const b of bullets) {
|
||||
for (const n of refsIn(normalizeLinks(b))) if (prNumbers.has(n)) prRefs.add(n);
|
||||
for (const m of normalizeLinks(b).matchAll(/@([A-Za-z0-9_-]+)/g)) handles.add(m[1]);
|
||||
}
|
||||
const humans = rows.filter((r) => !BOT_RE.test(r.authorName));
|
||||
const people = new Set([...Object.keys(ranking.counts), ...handles].map((x) => x.toLowerCase()));
|
||||
const lines = [
|
||||
`## [${version}] — ${dateSuffix}`,
|
||||
``,
|
||||
`_Living section — reconciled ${today} from all cycle commits (\`${baseTip}\` → \`${headTip}\`, ${fmt(rows.length)} non-merge commits). Bullets carry the merged PR and its author; direct pushes are listed with their commit hash. Regenerated at each \`/generate-release\` phase._`,
|
||||
``,
|
||||
`### 📊 Release by the numbers`,
|
||||
``,
|
||||
`| | |`,
|
||||
`| --- | ---: |`,
|
||||
`| 👥 People who contributed | **${fmt(people.size)}** |`,
|
||||
`| 📝 Commits in the cycle | **${fmt(rows.length)}** |`,
|
||||
`| 🔀 Pull requests referenced | **${fmt(prRefs.size)}** |`,
|
||||
`| 📋 Changelog entries | **${fmt(bullets.length)}** |`,
|
||||
`| 🙌 Contributors credited in entries | **${fmt(handles.size)}** |`,
|
||||
`| 🤖 Automated dependency commits | ${rows.length - humans.length} |`,
|
||||
``,
|
||||
`**Entries by type**`,
|
||||
``,
|
||||
`| Type | Count |`,
|
||||
`| --- | ---: |`,
|
||||
...Object.entries(byType)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([t, c]) => `| ${t} | ${c} |`),
|
||||
``,
|
||||
`### 🏆 Top 25 contributors this cycle`,
|
||||
``,
|
||||
`_By commits in \`${baseTip}..${headTip}\`, author identities consolidated via \`.mailmap\` and the merged PR's GitHub login. Bots excluded._`,
|
||||
``,
|
||||
`| # | Contributor | Commits |`,
|
||||
`| ---: | --- | ---: |`,
|
||||
...ranking.top.map(([n, c], i) => `| ${medal(i)} | ${n} | ${c} |`),
|
||||
``,
|
||||
];
|
||||
for (const k of ["features", "fixes", "maintenance"]) {
|
||||
lines.push(SECTION_HEADINGS[k], "", ...all[k], "");
|
||||
}
|
||||
return `${lines.join("\n")}\n---\n`;
|
||||
}
|
||||
|
||||
// ───────────────────────────── data acquisition (git + gh) ─────────────────────────────
|
||||
|
||||
const git = (args, cwd = ROOT) =>
|
||||
execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 }).trim();
|
||||
const gh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 1 << 28 });
|
||||
|
||||
export function repoSlug(cwd = ROOT) {
|
||||
const url = git(["remote", "get-url", "origin"], cwd);
|
||||
const m = url.match(/github\.com[:/]([^/]+\/[^/.]+)/);
|
||||
return m ? m[1] : "diegosouzapw/OmniRoute";
|
||||
}
|
||||
|
||||
/** Non-merge commits in `base..head` with mailmap identities, PR number and co-author trailers. */
|
||||
export function readCommits(base, head, cwd = ROOT) {
|
||||
const raw = git(
|
||||
[
|
||||
"log",
|
||||
"--no-merges",
|
||||
"--use-mailmap",
|
||||
"--date=short",
|
||||
"--format=%H%x1f%ad%x1f%aN%x1f%aE%x1f%s%x1f%(trailers:key=Co-authored-by,valueonly,separator=%x1e)%x1e%x1e",
|
||||
`${base}..${head}`,
|
||||
],
|
||||
cwd
|
||||
);
|
||||
return raw
|
||||
.split("\x1e\x1e")
|
||||
.map((s) => s.replace(/^\n/, ""))
|
||||
.filter((s) => s.trim())
|
||||
.map((r) => {
|
||||
const [hash, date, authorName, authorEmail, subject, coa] = r.split("\x1f");
|
||||
const refs = refsIn(subject);
|
||||
const prMatch = subject.match(/\(#(\d+)\)\s*$/);
|
||||
return {
|
||||
hash,
|
||||
date,
|
||||
authorName,
|
||||
authorEmail,
|
||||
subject,
|
||||
refs,
|
||||
pr: prMatch ? Number(prMatch[1]) : null,
|
||||
prAuthor: null,
|
||||
coauthors: (coa || "")
|
||||
.split("\x1e")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchMergedPrs(repo, releaseBranch) {
|
||||
return JSON.parse(
|
||||
gh([
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
repo,
|
||||
"--state",
|
||||
"merged",
|
||||
"--base",
|
||||
releaseBranch,
|
||||
"--limit",
|
||||
"1000",
|
||||
"--json",
|
||||
"number,title,author,body,closingIssuesReferences,mergedAt",
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchPr(repo, n) {
|
||||
try {
|
||||
return JSON.parse(
|
||||
execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"view",
|
||||
String(n),
|
||||
"--repo",
|
||||
repo,
|
||||
"--json",
|
||||
"number,title,author,body,closingIssuesReferences",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fragment files at `ref` + the commit that ADDED each one (definitive origin of the credit). */
|
||||
export function readFragments(ref = "HEAD", cwd = ROOT) {
|
||||
const out = [];
|
||||
for (const dir of Object.keys(SECTION_HEADINGS)) {
|
||||
let files = "";
|
||||
try {
|
||||
files = git(["ls-tree", "--name-only", ref, `changelog.d/${dir}/`], cwd);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const f of files
|
||||
.split("\n")
|
||||
.filter((x) => x && !/README\.md$|\.gitkeep$/.test(x))
|
||||
.sort()) {
|
||||
const text = git(["show", `${ref}:${f}`], cwd);
|
||||
const origin =
|
||||
git(["log", "--diff-filter=A", "--format=%h%x09%s", "--", f], cwd)
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.pop() || "";
|
||||
const [ohash, osubject = ""] = origin.split("\t");
|
||||
const om = osubject.match(/\(#(\d+)\)\s*$/);
|
||||
const pm = f.match(/\/(\d{4,6})-/);
|
||||
out.push({
|
||||
path: f,
|
||||
section: dir,
|
||||
text,
|
||||
originHash: ohash ? ohash.slice(0, 9) : null,
|
||||
originPr: om ? Number(om[1]) : null,
|
||||
prefixPr: pm ? Number(pm[1]) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ───────────────────────────── the reconciliation itself ─────────────────────────────
|
||||
|
||||
export function reconcile({
|
||||
changelog,
|
||||
version,
|
||||
repo,
|
||||
rows,
|
||||
prs,
|
||||
fragments,
|
||||
extraPrs = new Map(),
|
||||
credits = {},
|
||||
carriers = new Set(),
|
||||
dropFragments = new Set(),
|
||||
fragmentPr = {},
|
||||
fragmentCredit = {},
|
||||
today,
|
||||
baseTip,
|
||||
headTip,
|
||||
skipHashes = new Set(),
|
||||
special = {},
|
||||
}) {
|
||||
const prBy = new Map(prs.map((p) => [p.number, p]));
|
||||
for (const [n, p] of extraPrs) if (!prBy.has(n)) prBy.set(n, p);
|
||||
const prAuthor = (n) => prBy.get(n)?.author?.login || null;
|
||||
for (const r of rows) r.prAuthor = r.pr ? prAuthor(r.pr) : null;
|
||||
const issueToPrs = new Map();
|
||||
for (const p of prs)
|
||||
for (const ci of p.closingIssuesReferences || []) {
|
||||
if (!issueToPrs.has(ci.number)) issueToPrs.set(ci.number, []);
|
||||
issueToPrs.get(ci.number).push(p.number);
|
||||
}
|
||||
|
||||
const range = versionSectionRange(changelog, version);
|
||||
if (!range) throw new Error(`CHANGELOG.md has no "## [${version}]" section`);
|
||||
const existing = parseBlocks(changelog.slice(range.bodyStart, range.end));
|
||||
const shippedElsewhere = normalizeLinks(otherSectionsText(changelog, version));
|
||||
|
||||
const blocks = {
|
||||
features: [...existing.features],
|
||||
fixes: [...existing.fixes],
|
||||
maintenance: [...existing.maintenance],
|
||||
};
|
||||
const srcOf = new Map();
|
||||
for (const f of fragments) {
|
||||
const parsed = parseBlocks(f.text, { fixedSection: f.section, collapse: true });
|
||||
for (const b of parsed[f.section]) {
|
||||
blocks[f.section].push(b);
|
||||
srcOf.set(b, f);
|
||||
}
|
||||
}
|
||||
|
||||
const dropped = [];
|
||||
const patched = [];
|
||||
const mismatches = [];
|
||||
const originHashes = new Set();
|
||||
for (const k of Object.keys(blocks)) {
|
||||
const out = [];
|
||||
for (let b of blocks[k]) {
|
||||
const frag = srcOf.get(b);
|
||||
const first = b.split("\n")[0].trim();
|
||||
if (frag && dropFragments.has(frag.path)) {
|
||||
dropped.push({ why: "dropped by --drop-fragment", first, src: frag.path });
|
||||
continue;
|
||||
}
|
||||
const probe = normalizeLinks(first)
|
||||
.replace(/\s*\(#\d+.*$/, "")
|
||||
.slice(0, 90);
|
||||
if (frag && probe.length > 40 && shippedElsewhere.includes(probe)) {
|
||||
dropped.push({
|
||||
why: "text already shipped in another version section",
|
||||
first,
|
||||
src: frag.path,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (frag) {
|
||||
const refs0 = refsIn(normalizeLinks(b));
|
||||
const override = fragmentPr[frag.path];
|
||||
const oPr = frag.originPr;
|
||||
const fPr = frag.prefixPr;
|
||||
let defPrs = override
|
||||
? [override]
|
||||
: oPr && prBy.has(oPr) && !carriers.has(oPr)
|
||||
? [oPr]
|
||||
: fPr && prBy.has(fPr)
|
||||
? [fPr]
|
||||
: oPr && prBy.has(oPr)
|
||||
? [oPr]
|
||||
: [];
|
||||
if (!defPrs.length)
|
||||
for (const n of refs0) for (const pr of issueToPrs.get(n) || []) defPrs.push(pr);
|
||||
defPrs = [...new Set(defPrs)];
|
||||
if (oPr && fPr && prBy.has(fPr) && oPr !== fPr) {
|
||||
mismatches.push(
|
||||
`${frag.path}: prefix #${fPr} (${prAuthor(fPr)}) vs origin #${oPr} (${prAuthor(oPr)}) → used #${defPrs.join("/")}`
|
||||
);
|
||||
}
|
||||
if (b.includes("#PR_NUMBER") && defPrs.length) {
|
||||
b = b
|
||||
.replace(/\[#PR_NUMBER\]\([^)]*\)/g, prLink(repo, defPrs[0]))
|
||||
.replace(/#PR_NUMBER/g, `#${defPrs[0]}`);
|
||||
}
|
||||
for (const h of fragmentCredit[frag.path] || []) b = addCredit(b, [h]);
|
||||
for (const n of defPrs) {
|
||||
const before = b;
|
||||
b = appendLink(b, repo, n);
|
||||
b = addCredit(b, [prAuthor(n), ...(credits[n] || [])]);
|
||||
if (b !== before) patched.push(`${frag.path} → #${n} @${prAuthor(n)}`);
|
||||
}
|
||||
if (!override && !(oPr && carriers.has(oPr)) && frag.originHash)
|
||||
originHashes.add(frag.originHash);
|
||||
}
|
||||
out.push(b);
|
||||
}
|
||||
blocks[k] = out;
|
||||
}
|
||||
|
||||
const allExisting = Object.values(blocks)
|
||||
.flat()
|
||||
.filter((b) => b.startsWith("- "));
|
||||
const closingPrs = new Set();
|
||||
const citedIssues = new Set(allExisting.flatMap((b) => refsIn(normalizeLinks(b))));
|
||||
for (const p of prs)
|
||||
for (const ci of p.closingIssuesReferences || [])
|
||||
if (citedIssues.has(ci.number)) closingPrs.add(p.number);
|
||||
const { uncovered } = computeCoverage(rows, allExisting, {
|
||||
closingPrs,
|
||||
originHashes,
|
||||
skipHashes,
|
||||
});
|
||||
|
||||
const gen = { features: [], fixes: [], maintenance: [] };
|
||||
const deps = [];
|
||||
for (const r of [...uncovered].sort(
|
||||
(a, b) => (a.pr || 0) - (b.pr || 0) || a.date.localeCompare(b.date)
|
||||
)) {
|
||||
if (BOT_RE.test(r.authorName)) {
|
||||
deps.push(r);
|
||||
continue;
|
||||
}
|
||||
const c = bulletFromSubject(r.subject, special);
|
||||
let bullet = `- ${c.text}`;
|
||||
bullet += r.pr ? ` (${prLink(repo, r.pr)})` : ` (direct commit \`${r.hash.slice(0, 10)}\`)`;
|
||||
bullet = addCredit(bullet, [
|
||||
r.pr ? prAuthor(r.pr) : null,
|
||||
...(r.pr ? credits[r.pr] || [] : []),
|
||||
]);
|
||||
gen[sectionForType(c.type)].push(bullet);
|
||||
}
|
||||
if (deps.length) {
|
||||
gen.maintenance.push(
|
||||
`- **deps:** ${deps.length} Dependabot bumps — ${deps
|
||||
.map(
|
||||
(r) =>
|
||||
`${r.subject.replace(/^deps(\([^)]*\))?:\s*/, "").replace(/\s*\(#\d+\)\s*$/, "")} (${prLink(repo, r.pr)})`
|
||||
)
|
||||
.join("; ")}`
|
||||
);
|
||||
}
|
||||
|
||||
const all = {};
|
||||
const dedupDropped = [];
|
||||
for (const k of Object.keys(blocks)) {
|
||||
const existingSet = new Set(existing[k]);
|
||||
const merged = [...blocks[k], ...gen[k]];
|
||||
const kept = merged.filter((b) => existingSet.has(b));
|
||||
const { kept: rest, dropped: dd } = dedupeBullets(merged.filter((b) => !existingSet.has(b)));
|
||||
dedupDropped.push(...dd);
|
||||
all[k] = [...kept, ...rest];
|
||||
}
|
||||
|
||||
const ranking = rankAuthors(rows);
|
||||
const section = renderSection({
|
||||
version,
|
||||
today,
|
||||
baseTip,
|
||||
headTip,
|
||||
rows,
|
||||
all,
|
||||
ranking,
|
||||
prNumbers: new Set(prBy.keys()),
|
||||
});
|
||||
const next = `${changelog.slice(0, range.start)}${section}${changelog.slice(range.end + 1)}`;
|
||||
const report = {
|
||||
version,
|
||||
baseTip,
|
||||
headTip,
|
||||
commits: rows.length,
|
||||
existing: Object.fromEntries(Object.entries(existing).map(([k, v]) => [k, v.length])),
|
||||
fragments: fragments.length,
|
||||
generated: Object.fromEntries(Object.entries(gen).map(([k, v]) => [k, v.length])),
|
||||
bullets: Object.values(all)
|
||||
.flat()
|
||||
.filter((b) => b.startsWith("- ")).length,
|
||||
dropped,
|
||||
patched: patched.length,
|
||||
mismatches,
|
||||
dedupDropped,
|
||||
uncovered: uncovered.length,
|
||||
depsRolled: deps.length,
|
||||
ranking: ranking.top,
|
||||
};
|
||||
return { changelog: next, report };
|
||||
}
|
||||
|
||||
// ───────────────────────────── CLI ─────────────────────────────
|
||||
|
||||
function parseArgs(argv) {
|
||||
const o = {
|
||||
credit: {},
|
||||
carriers: new Set(),
|
||||
dropFragments: new Set(),
|
||||
fragmentPr: {},
|
||||
dryRun: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const v = () => argv[++i];
|
||||
if (a === "--version") o.version = v();
|
||||
else if (a === "--base") o.base = v();
|
||||
else if (a === "--head") o.head = v();
|
||||
else if (a === "--release-branch") o.releaseBranch = v();
|
||||
else if (a === "--prs") o.prs = v();
|
||||
else if (a === "--report") o.report = v();
|
||||
else if (a === "--dry-run") o.dryRun = true;
|
||||
else if (a === "--carrier") o.carriers.add(Number(v()));
|
||||
else if (a === "--drop-fragment") o.dropFragments.add(v());
|
||||
else if (a === "--credit") {
|
||||
const [n, hs] = v().split("=");
|
||||
o.credit[Number(n)] = hs.split(",").map((h) => h.replace(/^@/, ""));
|
||||
} else if (a === "--fragment-pr") {
|
||||
const [p, n] = v().split("=");
|
||||
o.fragmentPr[p] = Number(n);
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
const o = parseArgs(argv);
|
||||
const version =
|
||||
o.version || JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
|
||||
const releaseBranch = o.releaseBranch || `release/v${version}`;
|
||||
const head = o.head || "HEAD";
|
||||
let base = o.base;
|
||||
if (!base) {
|
||||
const { base: open, source } = resolveCycleBase(version);
|
||||
base = `${open}^`;
|
||||
console.log(`[reconcile-changelog] base = parent of the ${source} commit ${open.slice(0, 10)}`);
|
||||
}
|
||||
const repo = repoSlug();
|
||||
const rows = readCommits(base, head);
|
||||
const prs = o.prs
|
||||
? JSON.parse(fs.readFileSync(o.prs, "utf8"))
|
||||
: fetchMergedPrs(repo, releaseBranch);
|
||||
const known = new Set(prs.map((p) => p.number));
|
||||
const extraPrs = new Map();
|
||||
for (const n of new Set(rows.filter((r) => r.pr && !known.has(r.pr)).map((r) => r.pr))) {
|
||||
const p = fetchPr(repo, n);
|
||||
if (p) extraPrs.set(n, p);
|
||||
}
|
||||
const fragments = readFragments(head);
|
||||
const changelog = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8");
|
||||
const baseTip = git(["rev-parse", "--short=10", base]);
|
||||
const headTip = git(["rev-parse", "--short=10", head]);
|
||||
// the cycle-open bump and the living-section restore are the only legitimate non-bullet commits
|
||||
const skipHashes = new Set(
|
||||
rows
|
||||
.filter(
|
||||
(r) =>
|
||||
/^chore\(release\): (open v[\d.]+ development cycle|restore the living)/.test(
|
||||
r.subject
|
||||
) || /^Release v[\d.]+$/.test(r.subject)
|
||||
)
|
||||
.map((r) => r.hash.slice(0, 9))
|
||||
);
|
||||
const { changelog: next, report } = reconcile({
|
||||
changelog,
|
||||
version,
|
||||
repo,
|
||||
rows,
|
||||
prs,
|
||||
fragments,
|
||||
extraPrs,
|
||||
credits: o.credit,
|
||||
carriers: o.carriers,
|
||||
dropFragments: o.dropFragments,
|
||||
fragmentPr: o.fragmentPr,
|
||||
today: new Date().toISOString().slice(0, 10),
|
||||
baseTip,
|
||||
headTip,
|
||||
skipHashes,
|
||||
});
|
||||
if (!o.dryRun) {
|
||||
fs.writeFileSync(path.join(ROOT, "CHANGELOG.md"), next);
|
||||
for (const f of fragments) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(ROOT, f.path));
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (o.report) fs.writeFileSync(o.report, JSON.stringify(report, null, 1));
|
||||
const { dropped, mismatches, dedupDropped, ranking, ...summary } = report;
|
||||
console.log(`[reconcile-changelog] ${o.dryRun ? "(dry-run) " : ""}${JSON.stringify(summary)}`);
|
||||
for (const d of dropped) console.log(` dropped: ${d.why} — ${d.first.slice(0, 90)}`);
|
||||
for (const m of mismatches) console.log(` review: ${m}`);
|
||||
for (const d of dedupDropped) console.log(` deduped: ${d.slice(0, 90)}`);
|
||||
console.log(
|
||||
`[reconcile-changelog] next: npm run release:contributors -- ${version} --inject && npx prettier --write CHANGELOG.md && npm run release:sync-changelog-i18n -- ${version} <prev> && npm run check:changelog-integrity`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
process.exit(main());
|
||||
}
|
||||
@@ -1,56 +1,81 @@
|
||||
// Generates docs/screenshots/free-tier-budget-card.svg from the per-model catalog.
|
||||
// Run: node scripts/research/gen-budget-card-svg.mjs
|
||||
#!/usr/bin/env node
|
||||
// Generates the free-tier budget card from the per-model catalog, through the
|
||||
// same function the docs gate and the dashboard use — never by parsing the data
|
||||
// file with a regex (that silently skipped every row carrying an extra field).
|
||||
// Run from the repo root:
|
||||
// node --import tsx/esm scripts/research/gen-budget-card-svg.mjs [--out path.svg]
|
||||
import fs from "node:fs";
|
||||
import { computeFreeModelTotals } from "../../open-sse/config/freeModelCatalog.ts";
|
||||
|
||||
const txt = fs.readFileSync("open-sse/config/freeModelCatalog.data.ts", "utf8");
|
||||
const recs = [
|
||||
...txt.matchAll(
|
||||
/\{ provider: "([^"]+)", modelId: "([^"]+)", displayName: "([^"]+)", monthlyTokens: (\d+), creditTokens: (\d+), freeType: "([^"]+)", poolKey: (null|"[^"]+"), tos: "([^"]+)" \}/g
|
||||
),
|
||||
].map((m) => ({
|
||||
provider: m[1],
|
||||
modelId: m[2],
|
||||
displayName: m[3],
|
||||
monthlyTokens: +m[4],
|
||||
creditTokens: +m[5],
|
||||
freeType: m[6],
|
||||
poolKey: m[7] === "null" ? null : m[7].slice(1, -1),
|
||||
tos: m[8],
|
||||
}));
|
||||
const outIdx = process.argv.indexOf("--out");
|
||||
if (outIdx >= 0 && !process.argv[outIdx + 1]) throw new Error("--out requires a path");
|
||||
const OUT = outIdx >= 0 ? process.argv[outIdx + 1] : "docs/screenshots/free-tier-budget-card.svg";
|
||||
|
||||
const t = computeFreeModelTotals();
|
||||
const STEADY_TYPES = new Set(["recurring-daily", "recurring-monthly", "keyless"]);
|
||||
const fmt = (n) =>
|
||||
n >= 1e9 ? (n / 1e9).toFixed(2) + "B" : n >= 1e6 ? Math.round(n / 1e6) + "M" : Math.round(n / 1e3) + "K";
|
||||
n >= 1e9
|
||||
? (n / 1e9).toFixed(2) + "B"
|
||||
: n >= 1e6
|
||||
? Math.round(n / 1e6) + "M"
|
||||
: Math.round(n / 1e3) + "K";
|
||||
|
||||
// One bar segment per steady pool (largest member), gated rows excluded like the headline.
|
||||
const poolMap = new Map();
|
||||
for (const r of recs) {
|
||||
if (!["recurring-daily", "recurring-monthly", "keyless"].includes(r.freeType)) continue;
|
||||
for (const r of t.perModel) {
|
||||
if (!STEADY_TYPES.has(r.freeType) || r.eligibilityGate) continue;
|
||||
const k = r.poolKey || `${r.provider}:${r.modelId}`;
|
||||
const cur = poolMap.get(k);
|
||||
if (!cur || r.monthlyTokens > cur.monthlyTokens) poolMap.set(k, r);
|
||||
}
|
||||
const pools = [...poolMap.values()].filter((r) => r.monthlyTokens > 0).sort((a, b) => b.monthlyTokens - a.monthlyTokens);
|
||||
const steady = pools.reduce((s, r) => s + r.monthlyTokens, 0);
|
||||
const pools = [...poolMap.values()]
|
||||
.filter((r) => r.monthlyTokens > 0)
|
||||
.sort((a, b) => b.monthlyTokens - a.monthlyTokens);
|
||||
const steady = t.steadyRecurringTokens;
|
||||
const firstMonth = t.firstMonthRealisticTokens;
|
||||
const gated = t.gatedRecurringTokens;
|
||||
|
||||
const otMap = new Map();
|
||||
for (const r of recs) {
|
||||
if (r.freeType !== "one-time-initial" || r.creditTokens <= 0) continue;
|
||||
for (const r of t.perModel) {
|
||||
// Gated rows are excluded here too — they are absent from firstMonthRealisticTokens.
|
||||
if (r.freeType !== "one-time-initial" || r.creditTokens <= 0 || r.eligibilityGate) continue;
|
||||
const k = r.poolKey || r.provider;
|
||||
otMap.set(k, { provider: r.provider, v: Math.max(otMap.get(k)?.v || 0, r.creditTokens) });
|
||||
}
|
||||
const oneTime = [...otMap.values()].sort((a, b) => b.v - a.v);
|
||||
const oneTimeSum = oneTime.reduce((s, r) => s + r.v, 0);
|
||||
const firstMonth = steady + oneTimeSum;
|
||||
const avoidProviders = [...new Set(recs.filter((r) => r.tos === "avoid").map((r) => r.provider))].length;
|
||||
const uncappedProviders = [...new Set(recs.filter((r) => r.freeType === "recurring-uncapped").map((r) => r.provider))];
|
||||
const avoidProviders = new Set(t.perModel.filter((r) => r.tos === "avoid").map((r) => r.provider))
|
||||
.size;
|
||||
const uncappedProviders = t.uncappedProviders;
|
||||
|
||||
const GRID = pools.slice(0, 28);
|
||||
const STRIP = oneTime.slice(0, 9);
|
||||
const PAL = ["#6c5ce7","#00b894","#0984e3","#e17055","#fdcb6e","#e84393","#00cec9","#d63031","#a29bfe","#55efc4","#74b9ff","#ffeaa7","#fab1a0","#81ecec"];
|
||||
const PAL = [
|
||||
"#6c5ce7",
|
||||
"#00b894",
|
||||
"#0984e3",
|
||||
"#e17055",
|
||||
"#fdcb6e",
|
||||
"#e84393",
|
||||
"#00cec9",
|
||||
"#d63031",
|
||||
"#a29bfe",
|
||||
"#55efc4",
|
||||
"#74b9ff",
|
||||
"#ffeaa7",
|
||||
"#fab1a0",
|
||||
"#81ecec",
|
||||
];
|
||||
const color = (i) => PAL[i % PAL.length];
|
||||
const cleanName = (r) => (r.displayName || r.provider).replace(/\s*\(.*$/, "").replace(/ —.*$/, "").slice(0, 24);
|
||||
const cleanName = (r) =>
|
||||
(r.displayName || r.provider)
|
||||
.replace(/\s*\(.*$/, "")
|
||||
.replace(/ —.*$/, "")
|
||||
.slice(0, 24);
|
||||
|
||||
// bar segments (min width so every pool shows)
|
||||
const BAR_X = 32, BAR_W = 836, MIN = 7;
|
||||
const BAR_X = 32,
|
||||
BAR_W = 836,
|
||||
MIN = 7;
|
||||
const extra = BAR_W - MIN * GRID.length;
|
||||
let bx = BAR_X;
|
||||
const segs = GRID.map((r, i) => {
|
||||
@@ -60,11 +85,13 @@ const segs = GRID.map((r, i) => {
|
||||
return s;
|
||||
});
|
||||
|
||||
const B = []; // body elements
|
||||
// title
|
||||
B.push(`<text x="32" y="50" fill="#e6edf3" font-size="18" font-weight="700">Monthly free-token budget</text>`);
|
||||
B.push(`<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">${pools.length} free pools · ${recs.length} models · one endpoint</text>`);
|
||||
// stats
|
||||
const B = [];
|
||||
B.push(
|
||||
`<text x="32" y="50" fill="#e6edf3" font-size="18" font-weight="700">Monthly free-token budget</text>`
|
||||
);
|
||||
B.push(
|
||||
`<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">${pools.length} free pools · ${t.modelCount} models · one endpoint</text>`
|
||||
);
|
||||
const stat = (sx, label, val, vc) => {
|
||||
B.push(`<text x="${sx}" y="84" fill="#7d8590" font-size="11.5">${label}</text>`);
|
||||
B.push(`<text x="${sx}" y="114" fill="${vc}" font-size="27" font-weight="800">${val}</text>`);
|
||||
@@ -72,50 +99,90 @@ const stat = (sx, label, val, vc) => {
|
||||
stat(32, "Steady / month", `~${fmt(steady)}`, "#e6edf3");
|
||||
stat(330, "First month (+ signup credits)", `~${fmt(firstMonth)}`, "#3fb950");
|
||||
stat(700, "ToS-flagged (you decide)", `${avoidProviders} providers`, "#d29922");
|
||||
// bar
|
||||
B.push(`<clipPath id="bar"><rect x="${BAR_X}" y="132" width="${BAR_W}" height="16" rx="8"/></clipPath>`);
|
||||
B.push(`<g clip-path="url(#bar)"><rect x="${BAR_X}" y="132" width="${BAR_W}" height="16" fill="#21262d"/>`);
|
||||
for (const s of segs) B.push(`<rect x="${s.x.toFixed(1)}" y="132" width="${(s.w + 0.6).toFixed(1)}" height="16" fill="${s.c}"/>`);
|
||||
B.push(
|
||||
`<clipPath id="bar"><rect x="${BAR_X}" y="132" width="${BAR_W}" height="16" rx="8"/></clipPath>`
|
||||
);
|
||||
B.push(
|
||||
`<g clip-path="url(#bar)"><rect x="${BAR_X}" y="132" width="${BAR_W}" height="16" fill="#21262d"/>`
|
||||
);
|
||||
for (const s of segs)
|
||||
B.push(
|
||||
`<rect x="${s.x.toFixed(1)}" y="132" width="${(s.w + 0.6).toFixed(1)}" height="16" fill="${s.c}"/>`
|
||||
);
|
||||
B.push(`</g>`);
|
||||
B.push(`<text x="32" y="172" fill="#7d8590" font-size="12">Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid.</text>`);
|
||||
// model grid 4 cols
|
||||
const COLS = 4, COLW = 213, GX = 32, GY = 200, RH = 30;
|
||||
B.push(
|
||||
`<text x="32" y="172" fill="#7d8590" font-size="12">Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid.</text>`
|
||||
);
|
||||
const COLS = 4,
|
||||
COLW = 213,
|
||||
GX = 32,
|
||||
GY = 200,
|
||||
RH = 30;
|
||||
GRID.forEach((r, i) => {
|
||||
const col = i % COLS, row = (i / COLS) | 0;
|
||||
const cx = GX + col * COLW, cy = GY + row * RH;
|
||||
const col = i % COLS,
|
||||
row = (i / COLS) | 0;
|
||||
const cx = GX + col * COLW,
|
||||
cy = GY + row * RH;
|
||||
B.push(`<circle cx="${cx + 5}" cy="${cy - 4}" r="5" fill="${color(i)}"/>`);
|
||||
B.push(`<text x="${cx + 16}" y="${cy}" fill="#c9d1d9" font-size="12.5">${cleanName(r)} <tspan fill="#7d8590">${fmt(r.monthlyTokens)}</tspan></text>`);
|
||||
B.push(
|
||||
`<text x="${cx + 16}" y="${cy}" fill="#c9d1d9" font-size="12.5">${cleanName(r)} <tspan fill="#7d8590">${fmt(r.monthlyTokens)}</tspan></text>`
|
||||
);
|
||||
});
|
||||
let y = GY + Math.ceil(GRID.length / COLS) * RH + 6;
|
||||
// first-month strip (wrapping)
|
||||
B.push(`<line x1="32" y1="${y}" x2="868" y2="${y}" stroke="#30363d"/>`);
|
||||
y += 26;
|
||||
B.push(`<text x="32" y="${y}" fill="#3fb950" font-size="13" font-weight="700">+ First month: one-time signup credits (~${fmt(oneTimeSum)})</text>`);
|
||||
B.push(
|
||||
`<text x="32" y="${y}" fill="#3fb950" font-size="13" font-weight="700">+ First month: one-time signup credits (~${fmt(oneTimeSum)})</text>`
|
||||
);
|
||||
y += 24;
|
||||
let sxp = 32;
|
||||
for (const r of STRIP) {
|
||||
const label = `${r.provider} ${fmt(r.v)}`;
|
||||
const w = 16 + label.length * 6.7;
|
||||
if (sxp + w > 862) { sxp = 32; y += 30; }
|
||||
B.push(`<rect x="${sxp.toFixed(0)}" y="${(y - 15).toFixed(0)}" width="${w.toFixed(0)}" height="22" rx="11" fill="#13311f" stroke="#238636"/>`);
|
||||
B.push(`<text x="${(sxp + w / 2).toFixed(0)}" y="${y.toFixed(0)}" fill="#7ee787" font-size="11.5" text-anchor="middle">${label}</text>`);
|
||||
if (sxp + w > 862) {
|
||||
sxp = 32;
|
||||
y += 30;
|
||||
}
|
||||
B.push(
|
||||
`<rect x="${sxp.toFixed(0)}" y="${(y - 15).toFixed(0)}" width="${w.toFixed(0)}" height="22" rx="11" fill="#13311f" stroke="#238636"/>`
|
||||
);
|
||||
B.push(
|
||||
`<text x="${(sxp + w / 2).toFixed(0)}" y="${y.toFixed(0)}" fill="#7ee787" font-size="11.5" text-anchor="middle">${label}</text>`
|
||||
);
|
||||
sxp += w + 8;
|
||||
}
|
||||
y += 26;
|
||||
// ToS note (softened)
|
||||
B.push(`<rect x="32" y="${y}" width="836" height="34" rx="8" fill="#1c2230" stroke="#30363d"/>`);
|
||||
B.push(`<text x="46" y="${(y + 14).toFixed(0)}" fill="#7d8590" font-size="12">Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.</text>`);
|
||||
B.push(`<text x="46" y="${(y + 28).toFixed(0)}" fill="#7d8590" font-size="11.5">+ ${uncappedProviders.length} permanently-free, no-cap providers (e.g. ${uncappedProviders.slice(0, 3).join(", ")}) · OpenRouter $10 → +24M/mo.</text>`);
|
||||
y += 34;
|
||||
const H = y + 24; // card content bottom
|
||||
const noteH = gated > 0 ? 48 : 34;
|
||||
B.push(
|
||||
`<rect x="32" y="${y}" width="836" height="${noteH}" rx="8" fill="#1c2230" stroke="#30363d"/>`
|
||||
);
|
||||
B.push(
|
||||
`<text x="46" y="${(y + 14).toFixed(0)}" fill="#7d8590" font-size="12">Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.</text>`
|
||||
);
|
||||
B.push(
|
||||
`<text x="46" y="${(y + 28).toFixed(0)}" fill="#7d8590" font-size="11.5">+ ${uncappedProviders.length} permanently-free, no-cap providers (e.g. ${uncappedProviders.slice(0, 3).join(", ")}) · OpenRouter $10 → +${fmt(t.boostMonthlyTokens)}/mo.</text>`
|
||||
);
|
||||
if (gated > 0) {
|
||||
B.push(
|
||||
`<text x="46" y="${(y + 42).toFixed(0)}" fill="#d29922" font-size="11.5">+ ~${fmt(gated)} behind regional identity verification (${t.gatedProviders.join(", ")}) — real quota, never in the headline.</text>`
|
||||
);
|
||||
}
|
||||
y += noteH;
|
||||
const H = y + 24;
|
||||
const CANVAS = H + 16;
|
||||
|
||||
const out = [];
|
||||
out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="900" height="${CANVAS}" viewBox="0 0 900 ${CANVAS}" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">`);
|
||||
out.push(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="900" height="${CANVAS}" viewBox="0 0 900 ${CANVAS}" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">`
|
||||
);
|
||||
out.push(`<rect width="900" height="${CANVAS}" rx="16" fill="#0d1117"/>`);
|
||||
out.push(`<rect x="16" y="16" width="868" height="${H}" rx="13" fill="#161b22" stroke="#30363d"/>`);
|
||||
out.push(`<text x="868" y="${(H + 8).toFixed(0)}" fill="#484f58" font-size="10.5" text-anchor="end">OmniRoute · /dashboard/free-tiers · preview mockup</text>`);
|
||||
out.push(
|
||||
`<text x="868" y="${(H + 8).toFixed(0)}" fill="#484f58" font-size="10.5" text-anchor="end">OmniRoute · /dashboard/free-tiers · preview mockup</text>`
|
||||
);
|
||||
out.push(...B);
|
||||
out.push(`</svg>`);
|
||||
fs.writeFileSync("docs/screenshots/free-tier-budget-card.svg", out.join("\n") + "\n");
|
||||
console.log(`SVG: ${GRID.length} models, ${STRIP.length} first-month chips, canvas ${CANVAS}px. steady=${fmt(steady)} firstMonth=${fmt(firstMonth)} oneTime=${fmt(oneTimeSum)}`);
|
||||
fs.writeFileSync(OUT, out.join("\n") + "\n");
|
||||
console.log(
|
||||
`SVG → ${OUT}: ${GRID.length} pools, ${STRIP.length} first-month chips, canvas ${CANVAS}px. steady=${fmt(steady)} firstMonth=${fmt(firstMonth)} gated=${fmt(gated)} oneTime=${fmt(oneTimeSum)}`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user