morphit/apps/web/scripts/env-example-schema-parity-smoke.ts

298 lines
9.8 KiB
TypeScript

#!/usr/bin/env tsx
/**
* env-example-schema-parity-smoke.
*
* Part 122 cp57 STRUCTURAL DEFENSE (LL #61 / O-11).
*
* BIDIRECTIONAL canonical-example ↔ Zod-schema parity for the
* indexer and relay services. Catches the cp57-D1/D2 drift class
* where the Zod schema (source of truth) ships an env var that
* never makes it into the operator-facing canonical example.
*
* Two checks per service:
*
* DIRECTION A — every Zod-schema env var MUST be in the canonical
* example. This is the primary catch: if you add a new knob to
* the Zod schema (via .default() or .optional()), this smoke
* fires until you also document it in ops/env/<service>.env.example.
*
* DIRECTION B — every canonical-example env var MUST be EITHER in
* the Zod schema OR consumed by a sibling script (apps/<svc>/scripts/).
* The script-consumed exception exists because some env vars are
* read by helper scripts in apps/<svc>/scripts/ rather than by the
* main server, and are legitimately not part of the server Zod
* schema. (The original motivating case was the relay's
* MORPHIT_RELAY_WEEKLY_ACT_COUNT, read by apps/relay/scripts/mint-acts.ts;
* that ACT-minting model was removed at beta.28 / cp329 — account
* creation is now a direct account_create op paying the fee inline —
* so neither the var nor the script exists any more. The exception
* mechanism is kept for any future script-consumed env var.)
*
* Drift surfaced at cp57:
* - Indexer: 13 vars in schema but not in ops/env/indexer.env.example
* (cp57-D1 MEDIUM) — mostly MORPHIT_INSTANCE_* metadata vars +
* MORPHIT_INDEXER_COINGECKO_API_KEY + MORPHIT_INDEXER_OPERATOR_MATRIX_ROOM.
* - Relay: 17 vars in schema but not in ops/env/relay.env.example
* (cp57-D2 HIGH) — includes SECURITY-CRITICAL VAPID Web Push
* keys, TRUSTED_PROXY_IPS, and SEQUENTIAL_* squatter-defense
* knobs that operators couldn't tune through canonical docs.
* - Relay: 1 var (MORPHIT_RELAY_WEEKLY_ACT_COUNT) in example but
* not in schema → false-positive Direction-B candidate at cp57;
* was consumed by apps/relay/scripts/mint-acts.ts (since removed
* at beta.28 / cp329 along with the var itself).
*
* Recurring class scope progression (11 defenses across 10 checkpoints):
* cp48-O1: standalone smoke scripts
* cp49-O2: vitest unit tests
* cp50-O3: HTTP route handler regex
* cp51-O4: ops-cli per-ticker tables
* cp51-O5: per-asset i18n FAQ key coverage
* cp52-O6: Ansible env-template required-vars (different surface!)
* cp53-O7: operator doc per-asset coverage ("totally absent")
* cp54-O8: what_is_<asset> FAQ native-locale floor
* cp55-O9: multi-family per-asset native-locale floor (registry)
* cp56-O10: operator doc per-asset CONFIG EXAMPLE coverage (shallow)
* cp57-O11: env-example ↔ schema parity (bidirectional) (THIS)
*
* Relation to cp52-O6:
* cp52-O6 checks REQUIRED-only parity between the Zod schema and
* the Ansible Jinja2 TEMPLATE (apps/<svc>/...).env.j2. It catches
* "REQUIRED schema var not in Ansible template". Cp57-O11 checks
* FULL-SURFACE parity between the Zod schema and the canonical
* EXAMPLE (ops/env/<svc>.env.example). Different surfaces, different
* scopes; both needed.
*
* Mutation test verification: M-125 — deleting the
* MORPHIT_INSTANCE_NAME documentation block from indexer.env.example
* fires:
* "env-example-schema-parity FAILED:
* indexer: 1 schema var(s) missing from canonical example:
* MORPHIT_INSTANCE_NAME"
*/
import { readFileSync, readdirSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = join(__dirname, '..', '..', '..');
let failed = 0;
let passed = 0;
function pass(name: string): void { console.log(`${name}`); passed++; }
function fail(name: string, detail: string): void {
console.error(`${name}`); console.error(` ${detail}`); failed++;
}
console.log('\n── env-example-schema-parity smoke (cp57 LL #61 / O-11) ──\n');
interface Service {
name: string;
schemaPath: string;
examplePath: string;
scriptsDir: string;
}
const SERVICES: Service[] = [
{
name: 'indexer',
schemaPath: 'apps/indexer/src/config/index.ts',
examplePath: 'ops/env/indexer.env.example',
scriptsDir: 'apps/indexer/scripts'
},
{
name: 'relay',
schemaPath: 'apps/relay/src/config/index.ts',
examplePath: 'ops/env/relay.env.example',
scriptsDir: 'apps/relay/scripts'
},
{
// Added cp58 — matrix-bot got a canonical example for the
// first time at cp58. Schema lives in `config.ts` directly
// (not under `src/config/`).
name: 'matrix-bot',
schemaPath: 'apps/matrix-bot/src/config.ts',
examplePath: 'ops/env/matrix-bot.env.example',
scriptsDir: 'apps/matrix-bot/scripts'
}
];
/**
* Extract every MORPHIT_<NAME>: key from the envSchema z.object({...})
* block in a config file. Skips strings, // line comments, /* block
* comments, and template literals.
*/
function parseSchemaVars(schemaPath: string): Set<string> {
const src = readFileSync(join(REPO_ROOT, schemaPath), 'utf-8');
// Try multiple conventional schema variable names. Indexer
// and relay use `envSchema`; matrix-bot uses `SCHEMA`.
let startIdx = src.indexOf('const envSchema = z.object({');
if (startIdx === -1) startIdx = src.indexOf('const SCHEMA = z.object({');
if (startIdx === -1) {
throw new Error(
`No 'const envSchema = z.object({' or 'const SCHEMA = z.object({' found in ${schemaPath}`
);
}
let i = src.indexOf('{', startIdx);
let depth = 0;
let inStr: string | false = false;
let inLC = false;
let inBC = false;
let end = i;
while (i < src.length) {
const ch = src[i];
const nxt = src[i + 1] ?? '';
if (inLC) {
if (ch === '\n') inLC = false;
i++;
continue;
}
if (inBC) {
if (ch === '*' && nxt === '/') {
inBC = false;
i += 2;
continue;
}
i++;
continue;
}
if (inStr) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === inStr) inStr = false;
i++;
continue;
}
if (ch === '/' && nxt === '/') {
inLC = true;
i += 2;
continue;
}
if (ch === '/' && nxt === '*') {
inBC = true;
i += 2;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') {
inStr = ch;
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) {
end = i + 1;
break;
}
}
i++;
}
const block = src.slice(src.indexOf('{', startIdx), end);
const result = new Set<string>();
const re = /^\s*(MORPHIT_[A-Z_]+):/gm;
let m: RegExpExecArray | null;
while ((m = re.exec(block)) !== null) {
result.add(m[1]);
}
return result;
}
/** Parse every MORPHIT_<NAME>= line (commented or not) from a .env file. */
function parseExampleVars(examplePath: string): Set<string> {
const src = readFileSync(join(REPO_ROOT, examplePath), 'utf-8');
const result = new Set<string>();
// Match: optional `#` + `MORPHIT_NAME=` at line start
const re = /^#?\s*(MORPHIT_[A-Z_]+)=/gm;
let m: RegExpExecArray | null;
while ((m = re.exec(src)) !== null) {
result.add(m[1]);
}
return result;
}
/**
* Scan apps/<svc>/scripts/ for any env var references. Returns a
* set of MORPHIT_<NAME> names referenced via process.env.
*/
function parseScriptEnvRefs(scriptsDir: string): Set<string> {
const fullDir = join(REPO_ROOT, scriptsDir);
const result = new Set<string>();
let entries: string[];
try {
entries = readdirSync(fullDir);
} catch {
// No scripts directory; empty set
return result;
}
for (const entry of entries) {
const full = join(fullDir, entry);
try {
const s = statSync(full);
if (!s.isFile() || !entry.endsWith('.ts')) continue;
const src = readFileSync(full, 'utf-8');
const re = /process\.env\.(MORPHIT_[A-Z_]+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(src)) !== null) {
result.add(m[1]);
}
} catch {
// skip unreadable file
}
}
return result;
}
for (const svc of SERVICES) {
const schemaVars = parseSchemaVars(svc.schemaPath);
const exampleVars = parseExampleVars(svc.examplePath);
const scriptVars = parseScriptEnvRefs(svc.scriptsDir);
// Direction A: every schema var must be in example
const missingInExample: string[] = [];
for (const v of schemaVars) {
if (!exampleVars.has(v)) missingInExample.push(v);
}
// Direction B: every example var must be in schema OR script-consumed
const orphansInExample: string[] = [];
for (const v of exampleVars) {
if (!schemaVars.has(v) && !scriptVars.has(v)) orphansInExample.push(v);
}
console.log(
`${svc.name}: schema=${schemaVars.size} vars · example=${exampleVars.size} vars · scripts=${scriptVars.size} refs`
);
if (missingInExample.length === 0) {
pass(`${svc.name}: every schema var (${schemaVars.size}) appears in canonical example`);
} else {
fail(
`${svc.name}: schema → example parity`,
`${missingInExample.length} schema var(s) missing from canonical example:\n ${missingInExample.sort().join('\n ')}`
);
}
if (orphansInExample.length === 0) {
const scriptAllowed = [...exampleVars].filter((v) => !schemaVars.has(v) && scriptVars.has(v));
pass(
`${svc.name}: every example var is either in schema or script-consumed (${scriptAllowed.length} script-consumed allowed: ${scriptAllowed.length > 0 ? scriptAllowed.join(', ') : 'none'})`
);
} else {
fail(
`${svc.name}: example → schema parity`,
`${orphansInExample.length} example var(s) neither in schema nor script-referenced (phantom):\n ${orphansInExample.sort().join('\n ')}`
);
}
}
const total = passed + failed;
console.log(`\n${passed} passed, ${failed} failed (${total} total)`);
if (failed > 0) {
console.error('\nenv-example-schema-parity smoke FAILED');
process.exit(1);
}
console.log(`✓ all ${total} parity scenarios passed`);