docs(docs): add drift detection scripts + minor refinements

Adds three automated drift-detection scripts under scripts/, plus npm
helpers to run them, and applies small follow-ups to TERMUX, I18N, and
CODEBASE docs flagged by the docs audit.

New scripts (scripts/):
- check-env-doc-sync.mjs — cross-checks process.env.X in code vs
  .env.example vs docs/ENVIRONMENT.md. Soft-fails by default; --strict
  exits 1 on drift.
- check-docs-counts-sync.mjs — validates counts (executors, routing
  strategies, OAuth providers, A2A skills, cloud agents) match between
  code and docs.
- check-deprecated-versions.mjs — flags hardcoded stale versions and
  "Last updated" dates older than 60 days. Uses hardcoded regexes to
  satisfy semgrep ReDoS guidance.

package.json:
- New scripts: check:env-doc-sync, check:docs-counts,
  check:deprecated-versions, check:docs-all (umbrella).

Doc refinements:
- TERMUX_GUIDE: spell out the Node version range required by package.json.
- I18N: note that docs/i18n/in/ tree is an orphan duplicate of hi/ that
  the generator no longer writes to; replace hard-coded
  UNTRANSLATABLE_KEYS count with "varies per release".
- CODEBASE_DOCUMENTATION: minor wording.

Pre-commit hook is unchanged — the new checks are heuristic and ship as
on-demand npm scripts to avoid false-positive blocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
diegosouzapw
2026-05-13 03:46:40 -03:00
parent 20cb648ea9
commit 4437b0040e
7 changed files with 1192 additions and 580 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -6,24 +6,26 @@ OmniRoute supports **30 languages** with full dashboard UI translation, translat
## Quick Reference
| Task | Command |
|------|---------|
| Generate translations | `node scripts/i18n/generate-multilang.mjs messages` |
| Translate docs (LLM) | `python3 scripts/i18n_autotranslate.py --api-url <url> --api-key <key> --model <model>` |
| Validate a locale | `python3 scripts/validate_translation.py quick -l cs` |
| Check code keys | `python3 scripts/check_translations.py` |
| Generate QA report | `node scripts/i18n/generate-qa-checklist.mjs` |
| Visual QA (Playwright) | `node scripts/i18n/run-visual-qa.mjs` |
| Task | Command |
| ---------------------- | --------------------------------------------------------------------------------------- |
| Generate translations | `node scripts/i18n/generate-multilang.mjs messages` |
| Translate docs (LLM) | `python3 scripts/i18n_autotranslate.py --api-url <url> --api-key <key> --model <model>` |
| Validate a locale | `python3 scripts/validate_translation.py quick -l cs` |
| Check code keys | `python3 scripts/check_translations.py` |
| Generate QA report | `node scripts/i18n/generate-qa-checklist.mjs` |
| Visual QA (Playwright) | `node scripts/i18n/run-visual-qa.mjs` |
## Architecture
### Source of Truth
- **UI strings**: `src/i18n/messages/en.json` (English source, ~2800 keys)
- **Locale files**: `src/i18n/messages/{locale}.json` (30 translations)
- **Framework**: `next-intl` with cookie-based locale resolution
- **Config**: `src/i18n/config.ts` — defines all 30 locales, language names, flags
### Runtime Flow
1. User selects language → `NEXT_LOCALE` cookie set
2. `src/i18n/request.ts` resolves locale: cookie → `Accept-Language` header → fallback `en`
3. Dynamic import loads `messages/{locale}.json`
@@ -31,44 +33,46 @@ OmniRoute supports **30 languages** with full dashboard UI translation, translat
### Supported Locales
| Code | Language | RTL | Google Translate Code |
|------|----------|-----|----------------------|
| `ar` | العربية | Yes | `ar` |
| `bg` | Български | No | `bg` |
| `cs` | Čeština | No | `cs` |
| `da` | Dansk | No | `da` |
| `de` | Deutsch | No | `de` |
| `es` | Español | No | `es` |
| `fi` | Suomi | No | `fi` |
| `fr` | Français | No | `fr` |
| `he` | עברית | Yes | `iw` |
| `hi` | हिन्दी | No | `hi` |
| `hu` | Magyar | No | `hu` |
| `id` | Bahasa Indonesia | No | `id` |
| `it` | Italiano | No | `it` |
| `ja` | 日本語 | No | `ja` |
| `ko` | 한국어 | No | `ko` |
| `ms` | Bahasa Melayu | No | `ms` |
| `nl` | Nederlands | No | `nl` |
| `no` | Norsk | No | `no` |
| `phi` | Filipino | No | `tl` |
| `pl` | Polski | No | `pl` |
| `pt` | Português (Portugal) | No | `pt` |
| `pt-BR` | Português (Brasil) | No | `pt` |
| `ro` | Română | No | `ro` |
| `ru` | Русский | No | `ru` |
| `sk` | Slovenčina | No | `sk` |
| `sv` | Svenska | No | `sv` |
| `th` | ไทย | No | `th` |
| `tr` | Türkçe | No | `tr` |
| `uk-UA` | Українська | No | `uk` |
| `vi` | Tiếng Việt | No | `vi` |
| `zh-CN` | 中文 (简体) | No | `zh-CN` |
| Code | Language | RTL | Google Translate Code |
| ------- | -------------------- | --- | --------------------- |
| `ar` | العربية | Yes | `ar` |
| `bg` | Български | No | `bg` |
| `cs` | Čeština | No | `cs` |
| `da` | Dansk | No | `da` |
| `de` | Deutsch | No | `de` |
| `es` | Español | No | `es` |
| `fi` | Suomi | No | `fi` |
| `fr` | Français | No | `fr` |
| `he` | עברית | Yes | `iw` |
| `hi` | हिन्दी | No | `hi` |
| `hu` | Magyar | No | `hu` |
| `id` | Bahasa Indonesia | No | `id` |
| `it` | Italiano | No | `it` |
| `ja` | 日本語 | No | `ja` |
| `ko` | 한국어 | No | `ko` |
| `ms` | Bahasa Melayu | No | `ms` |
| `nl` | Nederlands | No | `nl` |
| `no` | Norsk | No | `no` |
| `phi` | Filipino | No | `tl` |
| `pl` | Polski | No | `pl` |
| `pt` | Português (Portugal) | No | `pt` |
| `pt-BR` | Português (Brasil) | No | `pt` |
| `ro` | Română | No | `ro` |
| `ru` | Русский | No | `ru` |
| `sk` | Slovenčina | No | `sk` |
| `sv` | Svenska | No | `sv` |
| `th` | ไทย | No | `th` |
| `tr` | Türkçe | No | `tr` |
| `uk-UA` | Українська | No | `uk` |
| `vi` | Tiếng Việt | No | `vi` |
| `zh-CN` | 中文 (简体) | No | `zh-CN` |
## Adding a New Language
### 1. Register the Locale
Edit `src/i18n/config.ts`:
```ts
// Add to LOCALES array
"xx",
@@ -77,7 +81,9 @@ Edit `src/i18n/config.ts`:
```
### 2. Add to Generator
Edit `scripts/i18n/generate-multilang.mjs` — add entry to `LOCALE_SPECS`:
```js
{
code: "xx",
@@ -91,24 +97,30 @@ Edit `scripts/i18n/generate-multilang.mjs` — add entry to `LOCALE_SPECS`:
```
### 3. Generate Initial Translation
```bash
node scripts/i18n/generate-multilang.mjs messages
```
This creates `src/i18n/messages/xx.json` auto-translated from `en.json` via Google Translate.
### 4. Review & Fix Auto-Translations
Auto-translations are a starting point. Review manually for:
- Technical accuracy
- Context-appropriate terminology
- Proper handling of placeholders (`{count}`, `{value}`, etc.)
### 5. Validate
```bash
python3 scripts/validate_translation.py quick -l xx
python3 scripts/validate_translation.py diff common -l xx
```
### 6. Generate Translated Documentation
```bash
node scripts/i18n/generate-multilang.mjs docs
```
@@ -123,15 +135,16 @@ node scripts/i18n/generate-multilang.mjs docs
node scripts/i18n/generate-multilang.mjs [messages|readme|docs|all]
```
| Mode | What it does |
|------|-------------|
| `messages` | Translates missing keys in `src/i18n/messages/{locale}.json` from `en.json` |
| `readme` | Translates `README.md` into all locales as `README.{code}.md` in project root |
| `docs` | Translates `DOC_SOURCE_FILES` into `docs/i18n/{locale}/{docName}` |
| `all` | Runs all three modes |
| Mode | What it does |
| ---------- | ----------------------------------------------------------------------------- |
| `messages` | Translates missing keys in `src/i18n/messages/{locale}.json` from `en.json` |
| `readme` | Translates `README.md` into all locales as `README.{code}.md` in project root |
| `docs` | Translates `DOC_SOURCE_FILES` into `docs/i18n/{locale}/{docName}` |
| `all` | Runs all three modes |
**Features:**
- **Text protection**: Masks code blocks (```` ``` ````), inline code (`` ` ``), markdown links/images (`[text](url)`), HTML tags, tables, and ICU placeholders (`{count}`, `{value}`, `{total}`, etc.) before translation, then restores them
- **Text protection**: Masks code blocks (` ``` `), inline code (`` ` ``), markdown links/images (`[text](url)`), HTML tags, tables, and ICU placeholders (`{count}`, `{value}`, `{total}`, etc.) before translation, then restores them
- **Chunked batching**: Joins multiple strings with `__OMNIROUTE_I18N_SEPARATOR__` delimiters to minimize API calls (max 1800 chars per request)
- **In-memory cache**: Avoids redundant API calls for repeated strings within a session
- **Retry logic**: Exponential backoff (up to 5 attempts with 300ms × attempt delay) for 429/5xx errors
@@ -139,6 +152,7 @@ node scripts/i18n/generate-multilang.mjs [messages|readme|docs|all]
- **Skip existing**: If target file already exists, it is NOT overwritten
**Important behaviors:**
- `docs/i18n/README.md` is **regenerated** each run — it's an auto-generated index of all docs
- Root `README.{code}.md` files are only created if they don't exist (skips locales in `EXISTING_README_CODES`)
- Language bars (`🌐 **Languages:** ...`) are automatically inserted/updated in all translated docs
@@ -155,6 +169,7 @@ python3 scripts/i18n_autotranslate.py \
```
**Features:**
- Scans `docs/i18n/` markdown files for English paragraphs
- Skips code blocks, tables, and already-translated content
- Sends paragraphs to LLM with technical translation system prompt
@@ -189,6 +204,7 @@ python3 scripts/validate_translation.py -l cs
```
**Detects:**
- **Missing keys** — keys in `en.json` but not in locale file
- **Extra keys** — keys in locale file but not in `en.json`
- **Untranslated keys** — keys where locale value equals English source (excluding allowlist)
@@ -228,6 +244,7 @@ node scripts/i18n/generate-qa-checklist.mjs
```
**Checks:**
- Fixed-width class usage (overflow risk)
- Directional left/right classes (RTL risk)
- Clipping-prone patterns
@@ -252,6 +269,7 @@ QA_ROUTES=/dashboard/settings,/dashboard/providers node scripts/i18n/run-visual-
```
**Detects:**
- Text overflow
- Element clipping
- RTL layout mismatches
@@ -279,6 +297,7 @@ Allowlist of keys that should remain identical to English source. Used by `valid
```
**What belongs here:**
- Brand/product names: `landing.brandName`, `common.social-github`
- Technical terms/acronyms: `health.cpu`, `mcpDashboard.pid`, `settings.ai`
- ICU/format strings: `apiManager.modelsCount`, `health.millisecondsShort`
@@ -307,6 +326,7 @@ python3 scripts/validate_translation.py quick -l '${{ matrix.lang }}'
```
**Dashboard output:**
```
## 🌍 Translations
| Metric | Value |
@@ -360,6 +380,7 @@ docs/
## Best Practices
### When Editing Translations
1. **Always edit `en.json` first** — it's the source of truth
2. **Run `generate-multilang.mjs messages`** to propagate new keys to all locales
3. **Review auto-translations** — Google Translate is a starting point, not final
@@ -367,11 +388,13 @@ docs/
5. **Update `untranslatable-keys.json`** if a key should remain in English
### Placeholder Safety
- ICU placeholders (`{count}`, `{value}`, `{total}`, `{seconds}`) must be preserved exactly
- Plural formats (`{count, plural, one {# model} other {# models}}`) must maintain structure
- The validator detects placeholder mismatches automatically
### Adding New Translation Keys in Code
```tsx
// Use namespaced keys
const t = useTranslations("settings");
@@ -382,6 +405,7 @@ python3 scripts/check_translations.py --verbose
```
### RTL Considerations
- Arabic (`ar`) and Hebrew (`he`) are RTL locales
- Avoid hardcoded `left`/`right` CSS — use `start`/`end` logical properties
- Visual QA catches RTL layout mismatches via `run-visual-qa.mjs`
@@ -389,21 +413,29 @@ python3 scripts/check_translations.py --verbose
## Known Issues & History
### `in.json` → `hi.json` Fix
The generator originally used `code: "in"` (deprecated Google Translate code) for Hindi instead of the correct ISO 639-1 `hi`. This created an orphaned `in.json` duplicate of `hi.json`. Fixed by changing `code: "in"` to `code: "hi"` in `generate-multilang.mjs` and removing the orphaned file.
> ⚠️ **Audit (2026-05-13):** The `docs/i18n/in/` directory still exists on disk (full duplicate of `hi/`). Translation generator no longer writes to it, but the historical tree was not pruned. Safe to delete with `rm -rf docs/i18n/in/` after confirming no external links reference the old path.
### `docs/i18n/README.md` Is Auto-Generated
The `docs/i18n/README.md` file is completely regenerated by `generate-multilang.mjs docs`. Any manual edits will be lost. Use `docs/I18N.md` (this file) for hand-written documentation that should persist.
### External Untranslatable Keys List
The `untranslatable-keys.json` allowlist was moved from an inline Python set in `validate_translation.py` to an external JSON file for easier maintenance. The validator loads it at runtime.
### `generate-multilang.mjs` Hindi Code Fix
The generator originally used `code: "in"` (deprecated Google Translate code) for Hindi instead of the correct ISO 639-1 `hi`. This was introduced in upstream commit `952b0b22c` by `diegosouzapw`. Fixed by changing `code: "in"` to `code: "hi"` in the `LOCALE_SPECS` array and removing the orphaned `in.json` file.
### `validate_translation.py` Ignored Count Output
The `quick` check now displays the count of ignored keys from `untranslatable-keys.json`:
```
Missing: 0
Untranslated: 0
Ignored (UNTRANSLATABLE_KEYS): 236
Ignored (UNTRANSLATABLE_KEYS): <varies per release>
```

View File

@@ -12,6 +12,8 @@ pkg upgrade
pkg install nodejs-lts python build-essential git
```
> **Node.js version:** OmniRoute requires Node `>=20.20.2 <21 || >=22.22.2 <23 || >=24.0.0 <27` (per `engines` in `package.json`). Termux's `nodejs-lts` typically ships Node 20 LTS, which is compatible. If `node --version` reports an older line, install `pkg install nodejs` (current) and verify the major matches a supported range.
If native package compilation fails, rerun the `pkg install` command above and then retry the OmniRoute install.
## Install

View File

@@ -83,6 +83,10 @@
"check:route-validation:t06": "node scripts/check-route-validation.mjs",
"check:any-budget:t11": "node scripts/check-t11-any-budget.mjs",
"check:docs-sync": "node scripts/check-docs-sync.mjs",
"check:env-doc-sync": "node scripts/check-env-doc-sync.mjs",
"check:docs-counts": "node scripts/check-docs-counts-sync.mjs",
"check:deprecated-versions": "node scripts/check-deprecated-versions.mjs",
"check:docs-all": "npm run check:docs-sync && npm run check:docs-counts && npm run check:env-doc-sync && npm run check:deprecated-versions",
"check:node-runtime": "node --import tsx/esm scripts/check-supported-node-runtime.ts",
"check:pack-artifact": "node --import tsx/esm scripts/validate-pack-artifact.ts",
"audit:deps": "npm audit --audit-level=moderate && npm run audit:electron",

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
// Detects hardcoded old versions / stale dates in docs that should follow the current release.
// Uses hardcoded regexes to avoid dynamic RegExp() (ReDoS concern flagged by semgrep).
// Exits 0 if clean, 1 (in --strict mode) if drift detected.
//
// Run: node scripts/check-deprecated-versions.mjs
// Strict: node scripts/check-deprecated-versions.mjs --strict
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, "..");
const DOCS_DIR = path.join(ROOT, "docs");
const PKG_JSON = path.join(ROOT, "package.json");
const STRICT = process.argv.includes("--strict");
function currentVersion() {
try {
return JSON.parse(fs.readFileSync(PKG_JSON, "utf8")).version || "0.0.0";
} catch {
return "0.0.0";
}
}
// Hardcoded set of obviously-stale version patterns. Update when bumping major/minor.
// These match any version <= v3.6.x or any pre-3 major.
const STALE_VERSION_PATTERNS = [
/\bv?[12]\.\d+\.\d+\b/, // 1.x.x / 2.x.x
/\bv?3\.[0-6]\.\d+\b/, // 3.0.x..3.6.x
];
const SAFE_CONTEXTS =
/(historical|archive|legacy|previously|deprecated|since|introduced|was|originally|fix|fixed)/i;
// Dates older than 60 days are considered stale for "Last updated" / "Last consolidated".
const STALE_DATE_DAYS = 60;
const today = new Date();
const LAST_UPDATED_RE = /Last (?:updated|consolidated|generated)[^\d]{0,30}(\d{4}-\d{2}-\d{2})/i;
function isStaleDate(yyyy_mm_dd) {
const d = new Date(yyyy_mm_dd);
if (Number.isNaN(d.getTime())) return false;
const ageDays = (today - d) / (1000 * 60 * 60 * 24);
return ageDays > STALE_DATE_DAYS;
}
const IGNORED_DIRS = new Set(["archive", "i18n", "superpowers"]);
const IGNORED_BASENAMES = new Set(["CHANGELOG.md", "RFC-AUTO-ASSESSMENT-DRAFT.md"]);
function walkDocs(dir) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (IGNORED_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...walkDocs(full));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
if (IGNORED_BASENAMES.has(entry.name)) continue;
out.push(full);
}
}
return out;
}
function main() {
const cur = currentVersion();
console.log(`Version drift report (current: v${cur})`);
console.log("=".repeat(40));
const files = walkDocs(DOCS_DIR);
let drift = 0;
for (const file of files) {
const rel = path.relative(ROOT, file);
const txt = fs.readFileSync(file, "utf8");
const lines = txt.split("\n");
const issues = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.length > 500) continue; // skip very long lines (likely code blocks / data)
// 1. Stale version refs
for (const re of STALE_VERSION_PATTERNS) {
const m = re.exec(line);
if (m && !SAFE_CONTEXTS.test(line)) {
issues.push({
line: i + 1,
type: "stale-version",
match: m[0],
text: line.trim().slice(0, 100),
});
break;
}
}
// 2. Stale "Last updated/consolidated/generated" dates
const dateMatch = LAST_UPDATED_RE.exec(line);
if (dateMatch && isStaleDate(dateMatch[1])) {
issues.push({
line: i + 1,
type: "stale-date",
match: dateMatch[1],
text: line.trim().slice(0, 100),
});
}
}
if (issues.length > 0) {
console.log(`\n ${rel}`);
for (const iss of issues.slice(0, 5)) {
console.log(` L${iss.line} [${iss.type}] ${iss.match}: ${iss.text}`);
}
if (issues.length > 5) console.log(` ... and ${issues.length - 5} more`);
drift += issues.length;
}
}
console.log();
if (drift > 0) {
console.warn(`${drift} potential drift(s) detected across ${files.length} doc files.`);
if (STRICT) process.exit(1);
} else {
console.log(`✓ No drift detected across ${files.length} doc files.`);
}
}
main();

View File

@@ -0,0 +1,115 @@
#!/usr/bin/env node
// Validates that count-based assertions in docs match the actual code state.
// Examples checked:
// - executors count in open-sse/executors/
// - routing strategies in src/shared/constants/routingStrategies.ts
// - OAuth providers in src/lib/oauth/providers/
// - A2A skills in src/lib/a2a/skills/
// - Cloud agents in src/lib/cloudAgent/agents/
//
// Exits 0 on success, 1 on detected drift.
// Run: node scripts/check-docs-counts-sync.mjs
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, "..");
const COMMON_NON_IMPL_BASENAMES = new Set([
"index.ts",
"index.mts",
"types.ts",
"base.ts",
"constants.ts",
]);
function countFiles(dir, suffix = ".ts") {
const abs = path.join(ROOT, dir);
if (!fs.existsSync(abs)) return 0;
return fs
.readdirSync(abs)
.filter(
(f) =>
f.endsWith(suffix) &&
!f.endsWith(".test.ts") &&
!f.startsWith("__") &&
!COMMON_NON_IMPL_BASENAMES.has(f)
).length;
}
function countRoutingStrategies() {
const file = path.join(ROOT, "src", "shared", "constants", "routingStrategies.ts");
if (!fs.existsSync(file)) return 0;
const txt = fs.readFileSync(file, "utf8");
const m = txt.match(/ROUTING_STRATEGY_VALUES\s*=\s*\[([^\]]*)\]/);
if (!m) return 0;
return (m[1].match(/"[^"]+"/g) || []).length;
}
function docContains(docPath, needle) {
const abs = path.join(ROOT, "docs", docPath);
if (!fs.existsSync(abs)) return false;
return fs.readFileSync(abs, "utf8").includes(needle);
}
const checks = [
{
label: "Executors count",
actual: countFiles("open-sse/executors"),
docKey: "executors",
docs: ["ARCHITECTURE.md", "CODEBASE_DOCUMENTATION.md"],
},
{
label: "Routing strategies count",
actual: countRoutingStrategies(),
docKey: "strategies",
docs: ["AUTO-COMBO.md", "RESILIENCE_GUIDE.md"],
},
{
label: "OAuth providers count",
actual: countFiles("src/lib/oauth/providers"),
docKey: "OAuth providers",
docs: ["ARCHITECTURE.md"],
},
{
label: "A2A skills count",
actual: countFiles("src/lib/a2a/skills"),
docKey: "A2A skills",
docs: ["A2A-SERVER.md"],
},
{
label: "Cloud agents count",
actual: countFiles("src/lib/cloudAgent/agents"),
docKey: "cloud agents",
docs: ["CLOUD_AGENT.md", "AGENT_PROTOCOLS_GUIDE.md"],
},
];
let drift = 0;
console.log("Docs counts sync report");
console.log("=======================");
for (const c of checks) {
console.log(`\n${c.label}: ${c.actual} (real)`);
for (const doc of c.docs) {
const found = docContains(doc, String(c.actual));
if (found) {
console.log(` ✓ docs/${doc} mentions "${c.actual}"`);
} else {
console.log(` ⚠ docs/${doc} does NOT mention "${c.actual}" for ${c.docKey}`);
drift++;
}
}
}
console.log();
if (drift > 0) {
console.warn(`${drift} potential drift(s) detected. Review the docs above.`);
// Soft-fail by default (count-based heuristic can false-positive).
// To enforce, pass --strict.
if (process.argv.includes("--strict")) process.exit(1);
} else {
console.log("✓ All checks pass.");
}

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env node
// Validates that env vars referenced in code appear in .env.example AND in docs/ENVIRONMENT.md.
// Exits 0 on success, 1 on missing entries. Designed for use in pre-commit / CI.
//
// Run: node scripts/check-env-doc-sync.mjs
// Strict mode: node scripts/check-env-doc-sync.mjs --strict
// In strict mode, missing entries cause failure. In default mode, only summary is printed.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const ENV_EXAMPLE = path.join(ROOT, ".env.example");
const ENV_DOC = path.join(ROOT, "docs", "ENVIRONMENT.md");
const STRICT = process.argv.includes("--strict");
// Vars that are intentionally not documented or detected in code via dynamic patterns.
const IGNORE = new Set([
"NODE_ENV",
"PATH",
"HOME",
"USER",
"PWD",
"SHELL",
"TERM",
"TZ",
"LANG",
"LC_ALL",
"CI",
"GITHUB_ACTIONS",
"RUNNER_OS",
// Add false positives here as discovered.
]);
function readEnvExampleVars() {
if (!fs.existsSync(ENV_EXAMPLE)) {
console.error(`${ENV_EXAMPLE} not found`);
process.exit(2);
}
const txt = fs.readFileSync(ENV_EXAMPLE, "utf8");
const vars = new Set();
for (const line of txt.split("\n")) {
// Match both "VAR=value" and "# VAR=value" (commented-out examples are still documented)
const m = line.match(/^#?\s*([A-Z][A-Z0-9_]+)\s*=/);
if (m) vars.add(m[1]);
}
return vars;
}
function readEnvDocVars() {
if (!fs.existsSync(ENV_DOC)) {
console.error(`${ENV_DOC} not found`);
process.exit(2);
}
const txt = fs.readFileSync(ENV_DOC, "utf8");
const vars = new Set();
// Match `VAR_NAME` in inline code or table cells.
for (const m of txt.matchAll(/`([A-Z][A-Z0-9_]{2,})`/g)) {
vars.add(m[1]);
}
return vars;
}
function readCodeVars() {
const vars = new Set();
let stdout;
try {
stdout = execSync(
"grep -rhoE 'process\\.env\\.[A-Z][A-Z0-9_]+' src/ open-sse/ bin/ scripts/ 2>/dev/null || true",
{ cwd: ROOT, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }
);
} catch (e) {
console.error(`✗ grep failed: ${e.message}`);
process.exit(2);
}
for (const line of stdout.split("\n")) {
const m = line.match(/^process\.env\.([A-Z][A-Z0-9_]+)$/);
if (m && !IGNORE.has(m[1])) vars.add(m[1]);
}
return vars;
}
function diff(set, against) {
return [...set].filter((v) => !against.has(v)).sort();
}
function main() {
const codeVars = readCodeVars();
const exampleVars = readEnvExampleVars();
const docVars = readEnvDocVars();
const inCodeMissingExample = diff(codeVars, exampleVars);
const inCodeMissingDoc = diff(codeVars, docVars);
const inExampleMissingDoc = diff(exampleVars, docVars);
const inExampleMissingCode = diff(exampleVars, codeVars);
console.log("Env var sync report");
console.log("===================");
console.log(`Code references: ${codeVars.size} unique vars`);
console.log(`In .env.example: ${exampleVars.size} unique vars`);
console.log(`In docs/ENVIRONMENT.md: ${docVars.size} unique vars (heuristic)`);
console.log();
function printList(label, list) {
if (list.length === 0) {
console.log(`${label}: none`);
} else {
console.log(`${label}: ${list.length}`);
for (const v of list.slice(0, 30)) console.log(` - ${v}`);
if (list.length > 30) console.log(` ... and ${list.length - 30} more`);
}
}
printList("In code but missing from .env.example", inCodeMissingExample);
printList("In code but missing from ENVIRONMENT.md", inCodeMissingDoc);
printList("In .env.example but missing from ENVIRONMENT.md", inExampleMissingDoc);
printList("In .env.example but not referenced in code (dead?)", inExampleMissingCode);
const errors = inCodeMissingExample.length + inExampleMissingDoc.length;
if (STRICT && errors > 0) {
console.error(`\n${errors} drift(s) detected (strict mode)`);
process.exit(1);
}
console.log(`\n${errors === 0 ? "✓" : "⚠"} Done.`);
}
main();