tests/support/env-example-scan.ts
Rama main · réplica de lectura del repositorio original.
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
/**
* Source the deployed app, its Trigger tasks, and the self-hosting scripts
* read configuration from. Other `scripts/` files are maintainer tooling
* (smoke tests, evals, one-off runbooks) whose inputs are documented with the
* script, not in `.env.example`.
*/
export const ENV_SCAN_ROOTS = [
"app",
"lib",
"trigger",
"proxy.ts",
"instrumentation.ts",
"instrumentation-client.ts",
"next.config.mjs",
"trigger.config.ts",
"scripts/apply-neon-migrations.ts",
"scripts/trigger-cli.ts",
] as const;
/** Prompt templates that quote example code; their env reads are prose. */
const ENV_SCAN_EXCLUDES = new Set(["lib/agents/templates"]);
const SOURCE_FILE = /\.(?:ts|tsx|mjs|js)$/;
const TEST_FILE = /\.(?:test|spec)\.(?:ts|tsx|mjs|js)$/;
/**
* Read patterns the scan recognizes:
* - `process.env.NAME`, `process.env?.NAME`, and `process.env["NAME"]`
* - `env.NAME` / `env?.NAME`, where `env` is an injected `ProcessEnv`
* - `const SOMETHING_ENV = "NAME"` / `const SOMETHING_FLAG = "NAME"`, the
* repo's convention for indirect `process.env[SOMETHING_ENV]` reads
* - a string literal passed to a local `require…Env("NAME")`,
* `read…Env("NAME")`, or `get…Env("NAME")` helper
*
* Fully dynamic keys (`process.env[name]` with a computed `name`) are not
* detectable statically; route them through one of the forms above.
*/
const READ_PATTERNS = [
/process\.env\??\.([A-Z][A-Z0-9_]+)\b/g,
/process\.env\[\s*["']([A-Z][A-Z0-9_]+)["']\s*\]/g,
/(?<![\w.$])env\??\.([A-Z][A-Z0-9_]{2,})\b/g,
/const\s+[A-Z0-9_]*(?:_ENV|_FLAG)\s*=\s*["']([A-Z][A-Z0-9_]+)["']/g,
/\b(?:require|read|get)\w*Env\(\s*["']([A-Z][A-Z0-9_]+)["']/g,
];
export function findEnvReads(source: string): Set<string> {
const names = new Set<string>();
for (const pattern of READ_PATTERNS) {
for (const match of source.matchAll(pattern)) {
names.add(match[1]);
}
}
return names;
}
const ENV_EXAMPLE_DECLARATION = /^#?\s*([A-Z][A-Z0-9_]+)=/;
/** Names `.env.example` declares, as `NAME=` or commented `# NAME=`. */
export function parseEnvExampleNames(contents: string): Set<string> {
const names = new Set<string>();
for (const line of contents.split("\n")) {
const match = ENV_EXAMPLE_DECLARATION.exec(line.trim());
if (match) names.add(match[1]);
}
return names;
}
/**
* Names `.env.example` declares more than once. Parallel branches that each
* add a variable tend to leave both copies behind after a merge.
*/
export function findDuplicateEnvExampleNames(contents: string): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const line of contents.split("\n")) {
const name = ENV_EXAMPLE_DECLARATION.exec(line.trim())?.[1];
if (!name) continue;
if (seen.has(name)) duplicates.add(name);
seen.add(name);
}
return [...duplicates].sort();
}
function listSourceFiles(root: string, path: string): string[] {
if (ENV_SCAN_EXCLUDES.has(path)) return [];
const absolute = join(root, path);
let stats;
try {
stats = statSync(absolute);
} catch {
return [];
}
if (stats.isFile()) {
return SOURCE_FILE.test(path) && !TEST_FILE.test(path) ? [absolute] : [];
}
return readdirSync(absolute).flatMap((entry) =>
entry === "node_modules" ? [] : listSourceFiles(root, join(path, entry))
);
}
/** Absolute paths of the non-test source files under `roots`. */
export function listScanSourceFiles(
repoRoot: string,
roots: readonly string[] = ENV_SCAN_ROOTS
): string[] {
return roots.flatMap((root) => listSourceFiles(repoRoot, root));
}
/** Every env name read under `roots`, mapped to the files that read it. */
export function collectEnvReads(
repoRoot: string,
roots: readonly string[] = ENV_SCAN_ROOTS
): Map<string, string[]> {
const reads = new Map<string, string[]>();
for (const file of listScanSourceFiles(repoRoot, roots)) {
for (const name of findEnvReads(readFileSync(file, "utf8"))) {
const files = reads.get(name) ?? [];
files.push(relative(repoRoot, file));
reads.set(name, files);
}
}
return reads;
}