mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
* fix(ci): stop the reconciliation range and the fragment sweep from hiding work Two release-tooling defects found during the v3.8.49 run (gaps 4 and 7 of the process dossier). Both fail by hiding work rather than announcing themselves, which is why each one had already cost a real mistake. ## The reconciliation range was 62× too wide `list-uncovered-commits.mjs` bounded its scan with `git describe --tags`. Releases reach `main` by SQUASH, so no commit on a release branch is ever an ancestor of the tag, and `vPREV..HEAD` re-lists the un-squashed history of every earlier cycle. Measured on release/v3.8.50 @7eca04fd12: v3.8.49..HEAD ....... 1361 commits cycle open..HEAD ..... 22 commits The report drowns in noise, and that is how a previous reconciliation let ~200 PRs through with no CHANGELOG bullet. The base is now resolved by CONTENT — the oldest commit that introduced this version string into package.json — deliberately NOT by commit subject, because the subject has already changed format once: chore(release): bump v3.8.49 (development cycle version) older chore(release): open v3.8.50 development cycle current A message-matching resolver would have silently reverted to the broken tag base the first time someone reworded the bump. The fallback now writes a WARNING to stderr explaining that a tag range re-lists previous cycles, so a shallow clone degrades loudly instead of quietly reproducing the bug. One of the five tests asserts exactly that the warning says "squash" and "noise". ## The back-merge resurrects fragments that already shipped The release lands on `main` as one squash commit, so `main` still carries every `changelog.d/` fragment the reconciliation folded in and deleted. Back-merging `main` restores all of them — 191 in the v3.8.49 run. Nothing breaks at that instant; the next aggregation folds them in a SECOND time and the section grows duplicates that have to be hand-unpicked. New `scripts/release/sweep-stale-fragments.mjs` (`npm run sweep:stale-fragments`) reports them, and `--apply` removes them. Report mode exits 1 so the back-merge step can gate on it. The identity rule took two attempts, and the second one exists because running the script against the live repo refuted the first. Matching on any `#N` in the bullet flagged `changelog.d/features/8980-deprecate-gemini-cli-provider.md` as stale, because that bullet cites issue **#7034** for context and #7034 shipped in an earlier cycle — it would have deleted an unreleased fragment and dropped its credit. A bullet routinely cites issues it merely references; only the `<PR-number>-<slug>.md` filename says which PR the fragment *is*. That case is now a regression test. Every ambiguous case resolves toward KEEPING: no number in the filename falls back to normalized text, text shorter than 12 chars is never matched, and anything matching neither is kept. A surviving duplicate is a nuisance someone notices; a deleted fragment silently costs a contributor their credit. node --import tsx/esm --test tests/unit/release-cycle-base-resolver.test.ts # 5 pass node --import tsx/esm --test tests/unit/sweep-stale-fragments.test.ts # 11 pass node scripts/release/list-uncovered-commits.mjs --json → baseed2db6cb19, baseSource "cycle-open", total 22 (was 1361) node scripts/release/sweep-stale-fragments.mjs → 4 fragments, 0 stale, exit 0 * docs(changelog): fragment for #8985 * fix(ci): four quality gates that punished the wrong thing Gaps 6, 9, 10 and 23 of the v3.8.49 process dossier. Each one either blocked work it should have waved through, or reported a number that was never the code's. ## 6 — test-masking is unusable at release scale My own dossier entry for this was WRONG and the measurement says so: tracked test files ............ 3977 (I had written 1277) absolute tautology scan ....... ~1 s (I had written >30 min) the diff uses base...HEAD three dots — already merge-base diff vs release branch ........ 0 files, 0 s diff vs main (today) .......... 3 files, 0 s The base choice was never the problem, and it cannot be reproduced today at all: `main` has since received the v3.8.49 squash, so the merge-base is recent. The pathology only exists DURING a release, in the window before `main` gets the squash — then the merge-base is the PREVIOUS cycle's fork point and the diff legitimately spans the whole cycle (~1277 changed test files, each costing a `git show` process plus a full regex pass). That is the same squash-merge topology as gap 4, and it is why the check ran twice without finishing. Fix: above 300 changed test files the per-file diff subchecks are skipped, since every one of those files was already gated by this check on its own PR. The absolute tautology scan still runs unconditionally over all 3977 files, so the floor is untouched. The skip is deliberately loud — a silent skip is gap 12, which cost two production bugs this cycle. `shouldSkipDiffSubchecks` never skips on unparseable input, so a broken count cannot disable the gate. ## 9 — a capital letter invalidated 41 translations `"Reset Defaults"` → `"Reset defaults"` marked the key stale in 41 locales. Every translation was still correct, and in locales with no letter case the "fix" is not expressible. Worse, the escape hatch (`__MISSING__:`) is BANNED in `vi` by tests/unit/i18n-vi-completeness.test.ts, so `vi` had no legitimate way out. `isCosmeticRewrite` folds case, whitespace runs and trailing punctuation — and nothing else. Most of the nine tests exist to pin what is NOT cosmetic: a changed word, an added word, and any edit inside an interpolation like `{count}` all still flag. Two end-to-end tests hold both directions: a cosmetic edit leaves every locale alone, a real rewrite still flags all of them. ## 10 — the ratchet compared numbers from two different auditors `pipx install zizmor` was unpinned, so the runner installed whatever PyPI served that day and measured 1 finding MORE than the devbox on the identical commit (190 vs 189) — a second rebaseline push per release, chasing a number that was never the code's. Pinned to 1.25.2 (what the devbox runs), and check-workflows.mjs now prints `zizmorVersion=` next to the count so any future rebaseline is traceable to the tool that produced it. ## 23 — a PR pointed at its own branch #8912 has head == base == release/v3.8.50: no diff, can never merge, and it sits in the queue with a full check board on every push to that branch. It survived because nothing looks wrong — the checks pass, since there is nothing to check. New guard in the `changes` job (one field comparison, before anything is spent). The distinction that makes it safe to block on: an equal head/base BRANCH is conclusive, an equal head/base SHA is NOT — a branch cut moments ago has an identical tip and is legitimate, so that case warns instead of failing. Half a signal never fails either. node --import tsx/esm --test tests/unit/test-masking-release-scale.test.ts # 6 pass node --import tsx/esm --test tests/unit/ui-value-drift-cosmetic.test.ts # 9 pass node --import tsx/esm --test tests/unit/pr-self-target-guard.test.ts # 7 pass check:workflows --ratchet → 178 findings, zizmorVersion=zizmor 1.25.2, baseline 190 the i18n suite is unaffected (5 files re-run, all green) * fix(ci): allowlist the four CI-only env vars the new gates read The env-doc-sync gate failed three unit shards plus Docs Gates on this PR, and it was right to: it requires every `process.env.X` read in code to be documented in `.env.example`, and this PR introduced four new reads. They do not belong in `.env.example`. That file is OmniRoute's runtime configuration; these are CI signals with no meaning in a user's `.env`: HEAD_REF / HEAD_SHA / BASE_SHA the `changes` job passes github.head_ref, github.base_ref and the PR head/base SHAs to the self-targeting-PR guard TEST_MASKING_MAX_CHANGED_TESTS the escape hatch that raises the test-masking gate's release-scale skip threshold So they go in IGNORE_FROM_CODE, which exists for exactly this and already carries the precedent one line above: `BASE_REF`, allowlisted because CI passes it to the OpenAPI breaking-change gate. `BASE_REF` being already listed is also why only four of my five reads failed. Each entry carries its justification and the script that reads it, per the allowlist policy. node --import tsx/esm --test tests/unit/issue-7793-env-doc-sync-repro.test.ts # 1 pass npm run check:env-doc-sync → all three directions in sync * fix(i18n): narrow the cosmetic-rewrite exemption to the scope actually reported The gap-9 fix folded whitespace in addition to case, and that collided with a pre-existing test which pins the opposite — tests/unit/i18n-ui-value-drift.test.ts, "a value that only changes whitespace still counts as an edit". Its comment states the reasoning: Conservative on purpose: trailing-space churn is rare, and treating it as a no-op would let a real reword slip through behind an innocuous-looking diff. That is a documented decision by whoever wrote it. The problem actually reported was CASE — `"Reset Defaults"` → `"Reset defaults"` invalidating 41 correct translations — and whitespace was scope I added on my own. Reversing someone else's reasoned call, silently, to fix something nobody reported is not this change's job, so the exemption is narrowed to case + trailing terminal punctuation. No test pins either of those. The reported case is still fixed, verified end to end: that rewrite invalidates 0 locales. And whitespace is now asserted NON-cosmetic in my own test file too, so a later tidy-up cannot quietly fold it back in. node --import tsx/esm --test tests/unit/i18n-ui-value-drift.test.ts # 11 pass (pre-existing) node --import tsx/esm --test tests/unit/ui-value-drift-cosmetic.test.ts # 10 pass --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
414 lines
16 KiB
JavaScript
414 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Strict environment variable contract checker.
|
|
*
|
|
* Enforces that every env var referenced in OmniRoute source code appears in
|
|
* both `.env.example` and `docs/reference/ENVIRONMENT.md`, and that the two files agree
|
|
* on the documented var set. Falls back to a small allowlist for variables
|
|
* that are intentionally documented but not literally referenced (legacy
|
|
* aliases, future-supported hooks) or vice versa.
|
|
*
|
|
* Usage:
|
|
* node scripts/check/check-env-doc-sync.mjs # strict (CI mode)
|
|
* node scripts/check/check-env-doc-sync.mjs --lenient # legacy report-only mode
|
|
*
|
|
* Strict mode exits non-zero if any of these are non-empty:
|
|
* - vars in code but missing from .env.example
|
|
* - vars in .env.example but missing from ENVIRONMENT.md
|
|
* - vars in ENVIRONMENT.md but missing from .env.example
|
|
*
|
|
* Programmatic API:
|
|
* Other Node tests can `import { runEnvDocSync } from "./check-env-doc-sync.mjs"`
|
|
* and pass `{ root, envExample, envDoc, codeVars, ignore, docOnlyAllowlist,
|
|
* envOnlyAllowlist }` to drive the checker against fixtures.
|
|
*/
|
|
|
|
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 REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
|
|
// ─── Allowlists ────────────────────────────────────────────────────────────
|
|
// Env vars referenced in code that should NOT trigger documentation drift.
|
|
// These are usually system/process vars or harness-only knobs.
|
|
const IGNORE_FROM_CODE = new Set([
|
|
"NODE_ENV",
|
|
"PATH",
|
|
"HOME",
|
|
"USER",
|
|
"LOGNAME",
|
|
"XDG_CURRENT_DESKTOP",
|
|
"PWD",
|
|
"SHELL",
|
|
"TERM",
|
|
"TZ",
|
|
"LANG",
|
|
"LC_ALL",
|
|
"LC_MESSAGES",
|
|
"CI",
|
|
"GITHUB_ACTIONS",
|
|
"RUNNER_OS",
|
|
// Quality-gate harness knobs (optional cache/report paths for CI scripts — not product config).
|
|
"ESLINT_RESULTS_JSON",
|
|
"COMPLEXITY_ESLINT_REPORT",
|
|
// Agent environment / system execution paths.
|
|
"PROJECT_ROOT",
|
|
"ARTIFACTS_DIR",
|
|
// OS / Node internals frequently surfaced by indirect dependencies.
|
|
"APPDATA",
|
|
"LOCALAPPDATA",
|
|
"XDG_CONFIG_HOME",
|
|
// XDG Base Directory cache root — read (never defined by OmniRoute) so the
|
|
// Android/Termux serve path can honor an operator-set cache location (#8519).
|
|
"XDG_CACHE_HOME",
|
|
"USERPROFILE",
|
|
"PREFIX",
|
|
// X11 display server — set by the OS/session manager, not OmniRoute config.
|
|
"DISPLAY",
|
|
// POSIX session vars surfaced by cloudflaredTunnel.ts (env passthrough).
|
|
"LOGNAME",
|
|
"XDG_CURRENT_DESKTOP",
|
|
// Next.js / Node test runners — these are framework-managed.
|
|
"NEXT_DIST_DIR",
|
|
"NEXT_PHASE",
|
|
"NEXT_RUNTIME",
|
|
// Set/read by Next.js's own dev server (next-dev-server.js) when the turbopack
|
|
// bundler is active — framework-internal. The OmniRoute-facing knob is
|
|
// OMNIROUTE_USE_TURBOPACK (scripts/dev/run-next.mjs), which IS documented.
|
|
"TURBOPACK",
|
|
"NODE_TEST_CONTEXT",
|
|
"VITEST",
|
|
// Instruction snippet shown to users (Traffic Inspector HttpProxySnippetCard) — not OmniRoute config.
|
|
"NODE_TLS_REJECT_UNAUTHORIZED",
|
|
// Claude Code's own auth env var — read from the CLI environment to detect
|
|
// existing auth and written into the generated Claude Code settings (so the CLI
|
|
// points at OmniRoute). A downstream client-tool var, not an OmniRoute server
|
|
// input (src/shared/services/claudeCliConfig.ts, api/cli-tools/claude-settings).
|
|
"ANTHROPIC_AUTH_TOKEN",
|
|
// CI providers (set by the runner).
|
|
"GITHUB_BASE_REF",
|
|
"GITHUB_BASE_SHA",
|
|
// CI passes BASE_REF=${{ github.base_ref }} to the OpenAPI breaking-change gate
|
|
// (scripts/check/check-openapi-breaking.mjs) — a build/check signal, not OmniRoute runtime config.
|
|
"BASE_REF",
|
|
// Same class as BASE_REF above: the `changes` job passes these four to the
|
|
// self-targeting-PR guard (scripts/check/check-pr-self-target.mjs) so it can compare a PR's
|
|
// head against its base. CI-only signals from github.head_ref / github.base_ref /
|
|
// pull_request.{head,base}.sha — never OmniRoute runtime config, and meaningless in a .env.
|
|
"HEAD_REF",
|
|
"HEAD_SHA",
|
|
"BASE_SHA",
|
|
// Escape hatch for the test-masking gate's release-scale skip
|
|
// (scripts/check/check-test-masking.mjs): above ~300 changed test files the per-file diff
|
|
// subchecks are skipped, and this raises that cap for anyone who wants the full pass anyway.
|
|
// A gate tuning knob, not application configuration.
|
|
"TEST_MASKING_MAX_CHANGED_TESTS",
|
|
// PR body injected by GitHub Actions into the pr-evidence gate (github.event.pull_request.body);
|
|
// a CI-only signal, never an OmniRoute runtime config (Phase 7.10).
|
|
"PR_BODY",
|
|
// CLI machine-id token opt-out (server-side flag; not user-configurable via .env).
|
|
"OMNIROUTE_DISABLE_CLI_TOKEN",
|
|
// Gated combo live-smoke harness (scripts/test/_vpsClient.mjs) — override the VPS HTTP
|
|
// smoke target host/key. Test/CI-only signals with safe defaults
|
|
// ("http://192.168.0.15:20128" / null), never OmniRoute runtime config (#5151).
|
|
"COMBO_LIVE_BASE_URL",
|
|
"COMBO_LIVE_API_KEY",
|
|
// Homologation E2E suite (npm run homolog) vars — configured via the dedicated
|
|
// .env.homolog file (template: .env.homolog.example), never in the runtime .env.
|
|
// Test/ops-only signals against the homologation VPS, same class as COMBO_LIVE_*.
|
|
// See docs/ops/HOMOLOGATION.md.
|
|
"HOMOLOG_BASE_URL",
|
|
"HOMOLOG_ADMIN_PASSWORD",
|
|
"HOMOLOG_API_KEY",
|
|
"HOMOLOG_CRITICAL_PROVIDERS",
|
|
"HOMOLOG_EXPECT_VERSION",
|
|
// update-notifier opt-out for the CLI binary.
|
|
"OMNIROUTE_NO_UPDATE_NOTIFIER",
|
|
// Headless CLI execution flag for Electron.
|
|
"OMNIROUTE_HEADLESS",
|
|
// Platform / OS detection vars read by CLI environment helper (bin/cli/utils/environment.mjs).
|
|
// These are external signals set by the host OS or cloud provider — not OmniRoute config.
|
|
"CODESPACES",
|
|
"GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN",
|
|
"GITPOD_WORKSPACE_ID",
|
|
"NO_COLOR",
|
|
"REPL_ID",
|
|
"REPL_SLUG",
|
|
"WSL_DISTRO_NAME",
|
|
"WSL_INTEROP",
|
|
// X11/Wayland display server vars used by tray heuristic (isTraySupported).
|
|
"DISPLAY",
|
|
"WAYLAND_DISPLAY",
|
|
// Build-time override for OpenAPI spec path used by generate-api-commands.mjs.
|
|
"OPENAPI_SPEC",
|
|
// Aliases for documented vars handled via fallback ordering.
|
|
"API_KEY",
|
|
"APP_URL",
|
|
"PUBLIC_URL",
|
|
"ANTHROPIC_API_URL",
|
|
"OPENAI_API_URL",
|
|
"LOG_LEVEL",
|
|
// Internal QA helpers used only by scripts/ and Playwright.
|
|
"QA_BASE_URL",
|
|
"QA_LOCALES",
|
|
"QA_REPORT_SUFFIX",
|
|
"QA_ROUTES",
|
|
// Post-publish verifier (scripts/release/verify-published.mjs): env passed INTO the
|
|
// clean Docker container script (Hard Rule #13 env-option pattern) — release tooling
|
|
// internals, never OmniRoute runtime config.
|
|
"VERIFY_DEADLINE_S",
|
|
"VERIFY_PORT",
|
|
"VERIFY_VERSION",
|
|
// Doctor diagnostic flags (no runtime behavior yet — placeholders).
|
|
"OMNIROUTE_DOCTOR_HOST",
|
|
"OMNIROUTE_DOCTOR_LIVENESS_URL",
|
|
"OMNIROUTE_PROVIDER_CATALOG_PATH",
|
|
"OMNIROUTE_PROVIDER_TEST_MODEL",
|
|
// Test-only opt-out: instructs bin/omniroute.mjs to skip auto-loading the
|
|
// repository .env so isolation tests get a deterministic environment.
|
|
"OMNIROUTE_CLI_SKIP_REPO_ENV",
|
|
// Eval-harness only: operator-supplied provider credentials JSON read by the
|
|
// opt-in `npm run eval:compression` CLI (scripts/compression-eval/index.ts).
|
|
// A dev/ops measurement tool, never OmniRoute runtime config.
|
|
"OMNIROUTE_EVAL_CREDENTIALS",
|
|
// Build-time only: set by `build:release` (git short SHA) and read by
|
|
// write-build-sha.mjs to stamp dist/BUILD_SHA — injected by the build, never
|
|
// configured by users in .env.
|
|
"OMNIROUTE_BUILD_SHA",
|
|
// Listener-owned self-fetch transport signal. The HTTP/HTTPS launchers set
|
|
// this before application imports; it is not user-configurable product env.
|
|
"OMNIROUTE_INTERNAL_SCHEME",
|
|
// Source typo / placeholder.
|
|
"OMNIROUT",
|
|
// Static config alias path (the canonical var is OMNIROUTE_PAYLOAD_RULES_PATH).
|
|
"PAYLOAD_RULES_PATH",
|
|
// Node.js module resolution path — OS/Node internal, not an OmniRoute config var.
|
|
// Referenced in resolveSpawnArgs (ninerouter) to pass bundled native modules to subprocess.
|
|
"NODE_PATH",
|
|
// NVIDIA diagnostic/test helpers used only by ad-hoc scripts.
|
|
"NVIDIA_BASE_URL",
|
|
"NVIDIA_MODEL",
|
|
// XDG standard data directory — set by OS/desktop session, not OmniRoute config.
|
|
// Read by setup-open-code.mjs to locate platform-specific OpenCode data dir.
|
|
"XDG_DATA_HOME",
|
|
// Test-only override: points setup-open-code.mjs at a fixture plugin dir without
|
|
// requiring the real bundled plugin to be built.
|
|
"OMNIROUTE_OPENCODE_PLUGIN_DIR",
|
|
]);
|
|
|
|
// Vars documented in ENVIRONMENT.md but intentionally absent from .env.example.
|
|
// Used for past-tense documentation (Audit / Dead vars section), legacy aliases
|
|
// with no runtime hook, and section anchors that look like vars to the regex.
|
|
const DOC_ONLY_ALLOWLIST = new Set([
|
|
// Audit history (Removed / Dead Variables section).
|
|
"CEREBRAS_API_KEY",
|
|
"COHERE_API_KEY",
|
|
"FIREWORKS_API_KEY",
|
|
"GROQ_API_KEY",
|
|
"MISTRAL_API_KEY",
|
|
"NEBIUS_API_KEY",
|
|
"PERPLEXITY_API_KEY",
|
|
"TOGETHER_API_KEY",
|
|
"XAI_API_KEY",
|
|
"QIANFAN_API_KEY",
|
|
"CURSOR_PROTOBUF_DEBUG",
|
|
"CLI_COMPAT_KIRO",
|
|
"CLI_KIMI_CODING_BIN",
|
|
"CLI_ROO_BIN",
|
|
"IFLOW_OAUTH_CLIENT_ID",
|
|
"IFLOW_OAUTH_CLIENT_SECRET",
|
|
// Source-code constants accidentally captured by the doc regex.
|
|
"CLI_COMPAT_OMITTED_PROVIDER_IDS",
|
|
// The stream-recovery tuning object in open-sse/config/constants.ts (`STREAM_RECOVERY.HOLDBACK_MS`
|
|
// etc.) — documented for reference; the real operator-facing env vars are STREAM_RECOVERY_ENABLED /
|
|
// STREAM_RECOVERY_MIDSTREAM_ENABLED (both in .env.example). The bare prefix is not an env var.
|
|
"STREAM_RECOVERY",
|
|
// Sample default values that look like SHOUTY_NAMES (not env vars).
|
|
"CHANGEME",
|
|
// Legacy aliases — present in docs as "would be aliases" but read-only
|
|
// through their canonical names today.
|
|
"OMNIROUTE_CRYPT_KEY",
|
|
"OMNIROUTE_API_KEY_BASE64",
|
|
// Future-supported hooks: documented but currently hardcoded constants.
|
|
"MAX_RETRY_INTERVAL_SEC",
|
|
"REQUEST_RETRY",
|
|
"SKILLS_EXECUTION_TIMEOUT_MS",
|
|
"SKILLS_SANDBOX_DOCKER_IMAGE",
|
|
// Source-code constants referenced in the docs narrative for the local
|
|
// endpoints / route-guard classification (PR-3 in #3932).
|
|
"LOCAL_ONLY_API_PREFIXES",
|
|
// SQL keyword mentioned in the new VACUUM scheduler docs (#4437).
|
|
// The check's regex picks up the bare word in description text.
|
|
"VACUUM",
|
|
]);
|
|
|
|
// Vars present in .env.example but intentionally absent from ENVIRONMENT.md.
|
|
// Empty today — kept for forward compatibility / explicit exemption.
|
|
const ENV_ONLY_ALLOWLIST = new Set([
|
|
// Documented in .env.example but not yet in docs/reference/ENVIRONMENT.md
|
|
"CODEX_REFRESH_SPACING_MS",
|
|
"DEBUG",
|
|
"HEAP_PRESSURE_THRESHOLD_MB",
|
|
"OMNIROUTE_TRACE",
|
|
"PII_TEST_BYPASS_MIN_WINDOW",
|
|
"PII_WINDOW_SIZE",
|
|
"TRAE_STREAM_TIMEOUT_MS",
|
|
"TRAE_TOKEN",
|
|
]);
|
|
|
|
// ─── Parsing helpers ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract VAR= entries from a `.env`-style file (handles commented examples).
|
|
*/
|
|
export function parseEnvExampleVars(text) {
|
|
const vars = new Set();
|
|
for (const line of String(text ?? "").split("\n")) {
|
|
const m = line.match(/^#?\s*([A-Z][A-Z0-9_]+)\s*=/);
|
|
if (m) vars.add(m[1]);
|
|
}
|
|
return vars;
|
|
}
|
|
|
|
/**
|
|
* Extract `VARNAME` tokens from a markdown doc — matches anything in backticks
|
|
* that looks like an env var (uppercase + digit + underscore).
|
|
*/
|
|
export function parseEnvDocVars(text) {
|
|
const vars = new Set();
|
|
for (const m of String(text ?? "").matchAll(/`([A-Z][A-Z0-9_]{2,})`/g)) {
|
|
vars.add(m[1]);
|
|
}
|
|
return vars;
|
|
}
|
|
|
|
/**
|
|
* Collect environment variable references in source code via grep against
|
|
* the `process.env` member access pattern.
|
|
*/
|
|
function scanCodeVars({ cwd } = {}) {
|
|
const repoRoot = cwd ?? REPO_ROOT;
|
|
const stdout = execSync(
|
|
"grep -rhoE 'process\\.env\\.[A-Z][A-Z0-9_]+' " +
|
|
"src/ open-sse/ bin/ scripts/ electron/main.js electron/preload.js 2>/dev/null || true",
|
|
{ cwd: repoRoot, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }
|
|
);
|
|
const vars = new Set();
|
|
for (const line of stdout.split("\n")) {
|
|
const m = line.match(/^process\.env\.([A-Z][A-Z0-9_]+)$/);
|
|
if (m) vars.add(m[1]);
|
|
}
|
|
return vars;
|
|
}
|
|
|
|
/**
|
|
* Diff helper.
|
|
*/
|
|
function diff(set, against) {
|
|
return [...set].filter((v) => !against.has(v)).sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
// ─── Programmatic entry point ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Run the contract checker. All inputs are overridable for tests.
|
|
*
|
|
* Returns `{ ok: boolean, summary, problems: { codeMissingEnv, envMissingDoc,
|
|
* docMissingEnv } }`.
|
|
*/
|
|
export function runEnvDocSync(options = {}) {
|
|
const ignore = options.ignore ?? IGNORE_FROM_CODE;
|
|
const docOnly = options.docOnlyAllowlist ?? DOC_ONLY_ALLOWLIST;
|
|
const envOnly = options.envOnlyAllowlist ?? ENV_ONLY_ALLOWLIST;
|
|
|
|
const envExampleText =
|
|
options.envExampleText ??
|
|
(options.envExamplePath
|
|
? fs.readFileSync(options.envExamplePath, "utf8")
|
|
: fs.readFileSync(path.join(REPO_ROOT, ".env.example"), "utf8"));
|
|
const envDocText =
|
|
options.envDocText ??
|
|
(options.envDocPath
|
|
? fs.readFileSync(options.envDocPath, "utf8")
|
|
: fs.readFileSync(path.join(REPO_ROOT, "docs", "reference", "ENVIRONMENT.md"), "utf8"));
|
|
|
|
const envVars = parseEnvExampleVars(envExampleText);
|
|
const docVars = parseEnvDocVars(envDocText);
|
|
|
|
const codeVars = new Set(
|
|
[...(options.codeVars ?? scanCodeVars({ cwd: options.root }))].filter((v) => !ignore.has(v))
|
|
);
|
|
|
|
const codeMissingEnv = diff(codeVars, envVars);
|
|
const envMissingDoc = diff(envVars, docVars).filter((v) => !envOnly.has(v));
|
|
const docMissingEnv = diff(docVars, envVars).filter((v) => !docOnly.has(v));
|
|
|
|
const ok =
|
|
codeMissingEnv.length === 0 && envMissingDoc.length === 0 && docMissingEnv.length === 0;
|
|
|
|
return {
|
|
ok,
|
|
summary: {
|
|
code: codeVars.size,
|
|
envExample: envVars.size,
|
|
doc: docVars.size,
|
|
},
|
|
problems: {
|
|
codeMissingEnv,
|
|
envMissingDoc,
|
|
docMissingEnv,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── CLI ───────────────────────────────────────────────────────────────────
|
|
|
|
function printList(label, list, marker) {
|
|
if (list.length === 0) {
|
|
console.log(` ${marker || "✓"} ${label}: none`);
|
|
return;
|
|
}
|
|
console.log(` ✗ ${label}: ${list.length}`);
|
|
for (const v of list.slice(0, 50)) console.log(` - ${v}`);
|
|
if (list.length > 50) console.log(` ... and ${list.length - 50} more`);
|
|
}
|
|
|
|
function main() {
|
|
const lenient = process.argv.includes("--lenient");
|
|
const result = runEnvDocSync();
|
|
|
|
console.log("Env var contract sync report");
|
|
console.log("============================");
|
|
console.log(`Code references: ${result.summary.code} unique vars`);
|
|
console.log(`In .env.example: ${result.summary.envExample} unique vars`);
|
|
console.log(`In docs/reference/ENVIRONMENT.md: ${result.summary.doc} unique vars`);
|
|
console.log();
|
|
|
|
printList("In code but missing from .env.example", result.problems.codeMissingEnv);
|
|
printList("In .env.example but missing from ENVIRONMENT.md", result.problems.envMissingDoc);
|
|
printList("In ENVIRONMENT.md but missing from .env.example", result.problems.docMissingEnv);
|
|
|
|
if (result.ok) {
|
|
console.log("\n✓ Env / docs contract is in sync.");
|
|
process.exit(0);
|
|
}
|
|
|
|
if (lenient) {
|
|
console.log("\n⚠ Drift detected (lenient mode — exit 0).");
|
|
process.exit(0);
|
|
}
|
|
|
|
console.log(
|
|
"\n✗ Env / docs contract is out of sync. Update .env.example, docs/reference/ENVIRONMENT.md,"
|
|
);
|
|
console.log(" or the allowlists in scripts/check/check-env-doc-sync.mjs and try again.");
|
|
process.exit(1);
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
main();
|
|
}
|