File size: 3,422 Bytes
b2d9e47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env node
//
// Sync-check: every env var read by `src/` MUST be documented in
// `.env.example`. Runs in CI as a soft guard rail — keeps `.env.example`
// from drifting behind real config-surface additions.
//
// Usage:
//   node scripts/check-env-example.mjs
//
// Returns 0 when in sync, 1 with a diff when out of sync.

import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";

const ROOT = new URL("..", import.meta.url).pathname;
const SRC = join(ROOT, "src");
const ENV_FILE = join(ROOT, ".env.example");

// Env vars read by the runtime but NOT user-facing config — these are
// either process-injected (HOME, PATH, USERPROFILE), set by the build /
// wrapper (NODE_*, npm_*), or set by tests (VITEST, *_TEST_*). Skipping
// them keeps `.env.example` a documented config surface rather than an
// inventory of every getenv anywhere in the codebase.
const RUNTIME_ONLY = new Set([
  "HOME",
  "PATH",
  "USERPROFILE",
  "NODE_ENV",
  "AGENTMEMORY_SDK_CHILD",
]);

// Walk src/ for .ts / .mts / .mjs / .js files (excluding `.d.ts` declarations
// and dotfile dirs / node_modules). test/ lives outside src/ so it never enters.
function walk(dir) {
  const out = [];
  for (const entry of readdirSync(dir)) {
    const full = join(dir, entry);
    const s = statSync(full);
    if (s.isDirectory()) {
      if (entry === "node_modules" || entry.startsWith(".")) continue;
      out.push(...walk(full));
    } else if (/\.(ts|mts|mjs|js)$/.test(entry) && !entry.endsWith(".d.ts")) {
      out.push(full);
    }
  }
  return out;
}

// Multiple patterns:
//   process.env["KEY"]                    — direct access
//   env["KEY"]                            — local alias inside detectProvider, etc.
//   getEnvVar("KEY")                      — helper from src/config.ts
//   env: ProcessEnv → env.KEY             — caught as `env["KEY"]` only; if you add
//   a dotted-access path, extend the regex.
const PATTERNS = [
  // Direct map index: process.env["KEY"], env["KEY"], getMergedEnv()["KEY"].
  // The trailing `]\s*` form covers `…)["KEY"]` and `…env["KEY"]`.
  /\[\s*"([A-Z][A-Z0-9_]+)"\s*\]/g,
  /getEnvVar\(\s*"([A-Z][A-Z0-9_]+)"\s*\)/g,
];
const used = new Set();
for (const file of walk(SRC)) {
  const text = readFileSync(file, "utf8");
  for (const pat of PATTERNS) {
    pat.lastIndex = 0;
    let m;
    while ((m = pat.exec(text)) !== null) {
      const name = m[1];
      if (!RUNTIME_ONLY.has(name)) used.add(name);
    }
  }
}

const envText = readFileSync(ENV_FILE, "utf8");
const documented = new Set();
for (const line of envText.split("\n")) {
  const m = line.match(/^#?\s*([A-Z][A-Z0-9_]+)=/);
  if (m) documented.add(m[1]);
}

const missing = [...used].filter((k) => !documented.has(k)).sort();
const orphan = [...documented].filter((k) => !used.has(k)).sort();

if (missing.length === 0 && orphan.length === 0) {
  console.log(`env-example: in sync (${used.size} keys documented)`);
  process.exit(0);
}

if (missing.length > 0) {
  console.error(
    `env-example: MISSING from .env.example — add documentation for these keys:`,
  );
  for (const k of missing) console.error(`  - ${k}`);
}
if (orphan.length > 0) {
  console.error(
    `env-example: ORPHAN in .env.example — no longer read by src/, remove or move to runtime-only allowlist:`,
  );
  for (const k of orphan) console.error(`  - ${k}`);
}
process.exit(1);