File size: 12,259 Bytes
5448d8b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
#!/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",
  // Agent environment / system execution paths.
  "PROJECT_ROOT",
  "ARTIFACTS_DIR",
  // OS / Node internals frequently surfaced by indirect dependencies.
  "APPDATA",
  "LOCALAPPDATA",
  "XDG_CONFIG_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",
  "NODE_TEST_CONTEXT",
  "VITEST",
  // Instruction snippet shown to users (Traffic Inspector HttpProxySnippetCard) β€” not OmniRoute config.
  "NODE_TLS_REJECT_UNAUTHORIZED",
  // CI providers (set by the runner).
  "GITHUB_BASE_REF",
  "GITHUB_BASE_SHA",
  // CLI machine-id token opt-out (server-side flag; not user-configurable via .env).
  "OMNIROUTE_DISABLE_CLI_TOKEN",
  // 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",
  // 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",
  // 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",
  // 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",
]);

// 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",
  // 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",
]);

// 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",
  "OMNIRROUTE_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();
}