mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 20:32:20 +03:00
* docs: move superpowers/research artifacts to isolated _tasks repo + docs tree cleanup
- Move docs/superpowers/{plans,specs} and docs/research/* into the gitignored,
separately-versioned _tasks/ repo; untrack the two tracked research design docs.
- Add CLAUDE.md "Planning & Research Artifacts" section overriding the superpowers
default save paths (docs/... -> _tasks/...); align REPOSITORY_MAP and
DOCUMENTATION_OVERHAUL_PLAN with the new convention.
- Drop 4 now-obsolete /api/discovery/* entries from check-docs-symbols allowlist
(stale-enforcement) and refresh code/spec path comments to _tasks/...
- Sweeps in concurrent docs-tree restructuring (root-level provider/guide docs,
compression spec cleanup, .mcp.json.example removal).
* docs: reorganize docs/ tree + fix stale facts across ~26 docs
Phase A — reorganization:
- Move 7 orphan root docs into subfolders (providers/ created; TIERS+USAGE_QUOTA→guides/;
plugins+PLUGIN_SDK→frameworks/); delete 8 obsolete/redundant docs (SUBMIT_PR superseded
by CONTRIBUTING; DOCUMENTATION_OVERHAUL_PLAN; INCIDENT_RESPONSE/PERF_BUDGETS/THREAT_MODEL;
3 ops snapshots). Rebuild README index (was missing ~40 files) + per-folder meta.json nav.
- Clean 14 dangling doc-path references in bin/ ops scripts, scripts/, workflow, tests;
fix the dockerignore-docs-coverage required-docs path (PROVIDERS→providers/CLAUDE_WEB).
Phase B — content accuracy (verified against code, not the audit summary):
- Functional: ENVIRONMENT flag defaults (INPUT_SANITIZER/MCP_ENFORCE_SCOPES=true,
COMPRESS_DESCRIPTIONS=false, dynamic heap); MCP-SERVER notion tool names (omniroute_*→
notion_*) + counts 87→94; coverage gate 75/70→60/60/60/60 (RELEASE_CHECKLIST, COVERAGE_PLAN,
ERROR_SANITIZATION, CONTRIBUTING); pre-push hook description; regenerate PROVIDER_REFERENCE (237).
- Count drift: providers 237, executors 70, migrations 106, db modules 94, oauth 19,
strategies 17, MCP 94, flags 38, TS 6.0, open-sse ~900/services 294 across architecture/
frameworks/ops docs; AUTO-COMBO 9→12 factors w/ correct DEFAULT_WEIGHTS; REASONING +2
patterns; STEALTH UA defaults; AGENT_PROTOCOLS +cursor-cloud/list-capabilities;
LANGUAGE_PACKS +id pack.
- Kept Node 20 (runtime guard accepts 20.20.2+; only engines is stricter) and MCP scopes=13
(mcpScopes.ts) — both were correct in the docs; corrected only the attribution.
* docs: finish content refresh — compression engines, CLI_TOKEN merge, metadata sweep
- Compression: document the additional built-in engines (CCR, headroom, ionizer,
session-dedup) in COMPRESSION_ENGINES; clarify LLMLingua-2 is the ultra-mode SLM
backend + cross-ref the extra engines in EXTENDING_COMPRESSION; add the id
(Indonesian) language pack to LANGUAGE_PACKS.
- AUTO-COMBO: replace the orphan 'How tiers fit' weight table (stale weights) with a
pointer to the canonical 12-factor DEFAULT_WEIGHTS table.
- Security: merge CLI_TOKEN_AUTH.md (legacy 32-char SHA-256 format) into CLI_TOKEN.md
as a 'Legacy format — still accepted' section (server accepts both HMAC + legacy),
delete CLI_TOKEN_AUTH.md, drop it from the index + security nav.
- Metadata: bump stale frontmatter (version/lastUpdated) to 3.8.40/2026-06-28 across the
doc set audited this pass, and normalize the in-body 'Last updated' header lines to match.
* fix(runtime): drop Node 20 from supported range + align all docs/diagrams/counts
- Node minimum is now 22 (aligned with package.json engines). SUPPORTED_NODE_RANGE in
src/shared/utils/nodeRuntimeSupport.ts (and the bin/ mirror) drops the 20.x line →
'>=22.22.2 <23 || >=24.0.0 <27'; getNodeRuntimeSupport now rejects Node 20 as
unsupported-major. Test updated (TDD): node-runtime-support.test.ts asserts Node 20
rejected. Docs aligned (TROUBLESHOOTING ×2, TERMUX, RELEASE_CHECKLIST, CODEBASE,
CLI-TOOLS, README, llm.txt + 42 i18n llm.txt mirrors, skills/cli-serve).
- Diagrams regenerated: mcp-tools-87 -> mcp-tools-94 (34 base + pool 6 = 94) and
auto-combo-9factor -> auto-combo-12factor (correct DEFAULT_WEIGHTS); SVGs re-rendered
via mermaid-cli; doc refs + diagrams/README updated; fixed a pre-existing broken
resilience-3layers image path.
- CLAUDE.md + AGENTS.md aligned to real counts (237 providers, 94 MCP tools / 34 base,
106 migrations, 94 db modules, 12-factor auto-combo, 17 strategies); README provider
count 231 -> 237; executor count corrected to 68 (provider executors, excl base/index)
and OAuth to 18 across architecture docs. check:docs-all now passes (0 strict drift,
0 broken links); removed dead .mcp.json.example doc link.
* fix(services): update installer Node hint to >=22.22.2 (aligned with dropped Node 20)
* docs: realign counts to current release tip after rebase
The release tip advanced while this work was in flight (Gemini CLI provider/executor
removed by #5246, plus other PRs). Re-counted against the current code and updated:
providers 237->236, executors 68->67, OAuth modules 18->17, open-sse services 294->298;
regenerated PROVIDER_REFERENCE.md (236). check:docs-all passes (0 strict drift).
* docs(changelog) + i18n: record Node 20 drop + fix nodeIncompatibleHint
- CHANGELOG: add [3.8.40] entries for the Node 20.x removal (runtime) and the docs
reorganization/accuracy audit.
- i18n: nodeIncompatibleHint across all 42 locales no longer lists Node 20.x as
supported (ASCII + CJK full-width variants), aligned with the dropped Node 20.
* fix(docs): repair CI breakages from the doc moves
- test: cli-plugin-system asserted docs/dev/plugins.md exists; the file moved to
docs/frameworks/PLUGINS.md — point the test at the new path (Unit fast-path 2/2 fix).
- frontmatter: PLUGINS.md and PLUGIN_SDK.md moved into the fumadocs-indexed
docs/frameworks/ which requires a 'title' frontmatter; the missing frontmatter
failed the Next.js MDX build (dast-smoke 'invalid frontmatter'). Added frontmatter
to both, plus the providers/ docs (consistency; that folder is not indexed).
331 lines
12 KiB
JavaScript
331 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
// scripts/docs/sync-wiki.mjs
|
||
// Full GitHub wiki content + cover-count sync.
|
||
//
|
||
// WHY: the wiki has no generator and historically drifts (it sat at "212+ providers /
|
||
// 14 strategies / 37 MCP tools" while code was at 226 / 15 / 87, and new docs like
|
||
// SUPPLY_CHAIN never appeared). This closes the loop: content + counts, automated per
|
||
// release by .github/workflows/wiki-sync.yml.
|
||
//
|
||
// DESIGN — update-in-place, never duplicates:
|
||
// 1. The wiki page names are hand-curated and NOT deterministically reproducible
|
||
// (e.g. "API-Reference" vs "Fly-io-Deployment-Guide"). So we iterate the EXISTING
|
||
// wiki pages and fuzzy-match each to a docs/ source by normalized key
|
||
// (lowercase, strip non-alphanumeric). When a source exists we rewrite that exact
|
||
// page → zero risk of creating a parallel/duplicate page.
|
||
// 2. A curated allowlist (NEW_PAGE_EXCLUDE) keeps internal docs (audit reports, plans,
|
||
// the docs index) off the public wiki; every other unmatched docs page is ADDED
|
||
// with a deterministic acronym-aware name.
|
||
// 3. Hand-curated pages with no docs source (Home, _Sidebar, Header, _Footer,
|
||
// Languages) are left untouched — except the four cover counts on Home.md.
|
||
// 4. EN by default. Localized mirrors (<locale>‐Page) are pure update-in-place and
|
||
// only touched with --include-i18n (the i18n source lags and is validated
|
||
// separately).
|
||
//
|
||
// Content transform: strip the YAML frontmatter, prepend the wiki language banner.
|
||
//
|
||
// Usage:
|
||
// node scripts/docs/sync-wiki.mjs --wiki-dir <path> # write
|
||
// node scripts/docs/sync-wiki.mjs --wiki-dir <path> --dry-run # report only
|
||
// node scripts/docs/sync-wiki.mjs --wiki-dir <path> --check # exit 1 on drift
|
||
// node scripts/docs/sync-wiki.mjs --wiki-dir <path> --include-i18n # also localized
|
||
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = path.resolve(__dirname, "..", "..");
|
||
|
||
// U+2010 HYPHEN separates the locale prefix in localized wiki page names.
|
||
const LOCALE_SEP = "‐";
|
||
export const WIKI_BANNER = "> 🌍 [View in other languages](Languages)\n\n\n";
|
||
|
||
// Docs that must never become public wiki pages (internal reports/plans/index).
|
||
export const NEW_PAGE_EXCLUDE = new Set([
|
||
"README", // docs index, not a page
|
||
"DOCUMENTATION_AUDIT_REPORT",
|
||
"DOCUMENTATION_OVERHAUL_PLAN",
|
||
"E2E_DASHBOARD_SHAKEDOWN_v3.8.0",
|
||
"SUBMIT_PR",
|
||
"fix-opencode-context",
|
||
"plugins", // docs/dev/plugins.md — internal dev note
|
||
"SOCKET_DEV_FINDINGS",
|
||
]);
|
||
|
||
// Acronyms kept upper-case when minting a NEW page name (existing pages keep their
|
||
// curated name via fuzzy match, so this only affects brand-new pages).
|
||
const ACRONYMS = new Set([
|
||
"api",
|
||
"mcp",
|
||
"a2a",
|
||
"acp",
|
||
"cli",
|
||
"sse",
|
||
"i18n",
|
||
"pii",
|
||
"oauth",
|
||
"vm",
|
||
"ai",
|
||
"llm",
|
||
"sdk",
|
||
"ide",
|
||
"ui",
|
||
"ux",
|
||
"tls",
|
||
"mitm",
|
||
"ws",
|
||
"cors",
|
||
"jwt",
|
||
"db",
|
||
"vps",
|
||
]);
|
||
|
||
/** Normalized matching key: lowercase, drop extension + every non-alphanumeric char. */
|
||
export function normKey(s) {
|
||
return s
|
||
.toLowerCase()
|
||
.replace(/\.md$/, "")
|
||
.replace(/[^a-z0-9]/g, "");
|
||
}
|
||
|
||
/** Deterministic wiki page name for a brand-new page (acronym-aware Title-Case-dashed). */
|
||
export function toWikiName(basename) {
|
||
return basename
|
||
.replace(/\.md$/, "")
|
||
.split(/[_\-\s]+/)
|
||
.filter(Boolean)
|
||
.map((t) =>
|
||
ACRONYMS.has(t.toLowerCase())
|
||
? t.toUpperCase()
|
||
: t[0].toUpperCase() + t.slice(1).toLowerCase()
|
||
)
|
||
.join("-");
|
||
}
|
||
|
||
/** Strip YAML frontmatter and prepend the wiki language banner. Pure; exported for tests. */
|
||
export function toWikiContent(docMarkdown) {
|
||
const body = docMarkdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").replace(/^\s+/, "");
|
||
return WIKI_BANNER + body.replace(/\s*$/, "") + "\n";
|
||
}
|
||
|
||
function read(rel) {
|
||
const p = path.join(ROOT, rel);
|
||
return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
|
||
}
|
||
|
||
// ---- cover-page counts (source of truth) ----
|
||
function providerCount() {
|
||
const m = read("docs/reference/PROVIDER_REFERENCE.md").match(/Total providers:\s*\*\*(\d+)\*\*/);
|
||
return m ? Number(m[1]) : null;
|
||
}
|
||
function strategyCount() {
|
||
const m = read("src/shared/constants/routingStrategies.ts").match(
|
||
/ROUTING_STRATEGY_VALUES\s*=\s*\[([^\]]*)\]/
|
||
);
|
||
return m ? (m[1].match(/"[^"]+"/g) || []).length : null;
|
||
}
|
||
function localeCount() {
|
||
try {
|
||
const c = JSON.parse(read("config/i18n.json"));
|
||
return Array.isArray(c.locales) ? c.locales.length : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
function mcpToolCount() {
|
||
// Prefer a literal; the constant is computed at runtime so best-effort only.
|
||
const m = read("open-sse/mcp-server/server.ts").match(/TOTAL_MCP_TOOL_COUNT\s*=\s*(\d+)\b/);
|
||
return m ? Number(m[1]) : null;
|
||
}
|
||
export function readCounts() {
|
||
return {
|
||
providers: providerCount(),
|
||
strategies: strategyCount(),
|
||
mcpTools: mcpToolCount(),
|
||
locales: localeCount(),
|
||
};
|
||
}
|
||
|
||
/** Apply cover-page count substitutions to Home.md text. Pure; exported for tests. */
|
||
export function syncHomeCounts(home, counts) {
|
||
let out = home;
|
||
if (counts.providers) {
|
||
out = out
|
||
.replace(
|
||
/Connect every AI tool to \d+ providers/g,
|
||
`Connect every AI tool to ${counts.providers} providers`
|
||
)
|
||
.replace(/\*\*\d+ AI Providers\*\*/g, `**${counts.providers} AI Providers**`)
|
||
.replace(/All \d+ supported providers/g, `All ${counts.providers} supported providers`)
|
||
.replace(/\b\d+ providers\b/g, `${counts.providers} providers`);
|
||
}
|
||
if (counts.strategies) {
|
||
out = out.replace(
|
||
/\*\*\d+ Routing Strategies\*\*/g,
|
||
`**${counts.strategies} Routing Strategies**`
|
||
);
|
||
}
|
||
if (counts.mcpTools) {
|
||
out = out.replace(/(\|\s*\*\*MCP Server\*\*\s*\|\s*)\d+( tools)/g, `$1${counts.mcpTools}$2`);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---- docs discovery ----
|
||
function walkMarkdown(dir, acc = []) {
|
||
if (!fs.existsSync(dir)) return acc;
|
||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const p = path.join(dir, e.name);
|
||
if (e.isDirectory()) walkMarkdown(p, acc);
|
||
else if (e.name.endsWith(".md")) acc.push(p);
|
||
}
|
||
return acc;
|
||
}
|
||
|
||
/** Build normKey → docs absolute path for English docs (docs/ minus docs/i18n). */
|
||
function indexEnglishDocs() {
|
||
const docsRoot = path.join(ROOT, "docs");
|
||
const files = walkMarkdown(docsRoot).filter((f) => !f.includes(`${path.sep}i18n${path.sep}`));
|
||
const byKey = new Map();
|
||
for (const f of files) {
|
||
const base = path.basename(f, ".md");
|
||
const k = normKey(base);
|
||
// First-writer-wins keeps a deterministic pick for basename collisions.
|
||
if (!byKey.has(k)) byKey.set(k, { file: f, base });
|
||
}
|
||
return byKey;
|
||
}
|
||
|
||
/** Build normKey → docs path for a given locale's i18n tree. */
|
||
function indexLocaleDocs(locale) {
|
||
const root = path.join(ROOT, "docs", "i18n", locale);
|
||
const byKey = new Map();
|
||
for (const f of walkMarkdown(root)) {
|
||
const k = normKey(path.basename(f, ".md"));
|
||
if (!byKey.has(k)) byKey.set(k, f);
|
||
}
|
||
return byKey;
|
||
}
|
||
|
||
function listWikiPages(wikiDir) {
|
||
return fs
|
||
.readdirSync(wikiDir)
|
||
.filter((n) => n.endsWith(".md"))
|
||
.map((n) => n.slice(0, -3));
|
||
}
|
||
|
||
/** Split a wiki page name into { locale, name }. EN pages have locale = null. */
|
||
export function parseWikiPage(page) {
|
||
const idx = page.indexOf(LOCALE_SEP);
|
||
if (idx === -1) return { locale: null, name: page };
|
||
return { locale: page.slice(0, idx), name: page.slice(idx + 1) };
|
||
}
|
||
|
||
function main() {
|
||
const args = process.argv.slice(2);
|
||
const wikiDir = args.includes("--wiki-dir") ? args[args.indexOf("--wiki-dir") + 1] : null;
|
||
const dryRun = args.includes("--dry-run");
|
||
const check = args.includes("--check");
|
||
const includeI18n = args.includes("--include-i18n");
|
||
const updateExisting = args.includes("--update-existing");
|
||
if (!wikiDir || !fs.existsSync(wikiDir)) {
|
||
console.error("usage: sync-wiki.mjs --wiki-dir <path> [--dry-run|--check] [--include-i18n]");
|
||
process.exit(2);
|
||
}
|
||
|
||
const enDocs = indexEnglishDocs();
|
||
const wikiPages = listWikiPages(wikiDir);
|
||
const enWikiKeys = new Set();
|
||
const localeIndexes = new Map();
|
||
|
||
const plan = { update: [], add: [], untouched: [], countsChanged: false };
|
||
|
||
// 1. Update existing wiki pages from their docs source.
|
||
for (const page of wikiPages) {
|
||
if (page === "Home") continue; // handled by counts below
|
||
const { locale, name } = parseWikiPage(page);
|
||
const key = normKey(name);
|
||
let srcFile = null;
|
||
if (!locale) {
|
||
enWikiKeys.add(key);
|
||
srcFile = enDocs.get(key)?.file ?? null;
|
||
} else if (includeI18n) {
|
||
if (!localeIndexes.has(locale)) localeIndexes.set(locale, indexLocaleDocs(locale));
|
||
srcFile = localeIndexes.get(locale).get(key) ?? null;
|
||
}
|
||
if (!srcFile) {
|
||
plan.untouched.push(page);
|
||
continue;
|
||
}
|
||
const next = toWikiContent(fs.readFileSync(srcFile, "utf8"));
|
||
const cur = fs.readFileSync(path.join(wikiDir, `${page}.md`), "utf8");
|
||
if (next !== cur) plan.update.push({ page, srcFile });
|
||
}
|
||
|
||
// 2. Add curated new English pages (unmatched docs, minus the exclude list).
|
||
for (const [key, { file, base }] of enDocs) {
|
||
if (enWikiKeys.has(key)) continue;
|
||
if (NEW_PAGE_EXCLUDE.has(base)) continue;
|
||
plan.add.push({ page: toWikiName(base), srcFile: file, base });
|
||
}
|
||
|
||
// 3. Home cover counts.
|
||
const counts = readCounts();
|
||
const homePath = path.join(wikiDir, "Home.md");
|
||
let homeAfter = null;
|
||
if (fs.existsSync(homePath)) {
|
||
const before = fs.readFileSync(homePath, "utf8");
|
||
homeAfter = syncHomeCounts(before, counts);
|
||
plan.countsChanged = homeAfter !== before;
|
||
}
|
||
|
||
// ---- report ----
|
||
// Updating existing pages is opt-in: several docs SOURCES carry stale counts (e.g.
|
||
// ARCHITECTURE.md still says "177 providers / 37 MCP tools" while the wiki cover was
|
||
// hand-patched to 226/87). Overwriting from a staler source would REGRESS the wiki, so
|
||
// by default we only ADD missing pages and sync Home counts. Pass --update-existing
|
||
// once the docs sources are regenerated.
|
||
const updates = updateExisting ? plan.update : [];
|
||
const total = updates.length + plan.add.length + (plan.countsChanged ? 1 : 0);
|
||
console.log(`[wiki-sync] counts: ${JSON.stringify(counts)}`);
|
||
console.log(
|
||
`[wiki-sync] add: ${plan.add.length} | Home counts: ${plan.countsChanged ? "drift" : "in-sync"} | ` +
|
||
`existing-page updates: ${plan.update.length} (${updateExisting ? "ENABLED" : "skipped — needs --update-existing"}) | untouched: ${plan.untouched.length}`
|
||
);
|
||
if (dryRun || check) {
|
||
if (plan.add.length) console.log(` add → ${plan.add.map((a) => a.page).join(", ")}`);
|
||
if (plan.update.length)
|
||
console.log(
|
||
` ${updateExisting ? "update" : "would-update (skipped)"} → ${plan.update
|
||
.map((u) => u.page)
|
||
.slice(0, 60)
|
||
.join(", ")}${plan.update.length > 60 ? " …" : ""}`
|
||
);
|
||
if (check) {
|
||
if (total > 0) {
|
||
console.error(`✗ wiki out of sync (${total} change(s) pending)`);
|
||
process.exit(1);
|
||
}
|
||
console.log("✓ wiki in sync");
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ---- write ----
|
||
for (const { page, srcFile } of [...updates, ...plan.add]) {
|
||
fs.writeFileSync(
|
||
path.join(wikiDir, `${page}.md`),
|
||
toWikiContent(fs.readFileSync(srcFile, "utf8"))
|
||
);
|
||
}
|
||
if (plan.countsChanged && homeAfter != null) fs.writeFileSync(homePath, homeAfter);
|
||
console.log(
|
||
`[wiki-sync] wrote ${total} page(s) (add: ${plan.add.length}, updates: ${updates.length}, counts: ${plan.countsChanged ? 1 : 0}).`
|
||
);
|
||
}
|
||
|
||
const invokedDirectly =
|
||
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||
if (invokedDirectly) main();
|