#!/usr/bin/env node // tools/scripts/lint-fly-env.mjs // [doc->REQ-DEP-01] [impl->REQ-DEP-01] // Source: Phase 06.5 Plan 02 — static client asset split (volume-backed). // // Asserts that BOTH apps/server/fly.staging.toml AND apps/server/fly.prod.toml: // (a) declare `STATIC_ASSETS_DIR = "/data/client-assets/current"` on its own // line (after the `[env]` block header, before the next `[`/`[[` section). // (b) carry the `[doc->REQ-DEP-01]` tag in a header comment (top of file). // // Why: future toml edits could silently drop the env declaration; without it, // the server's static-asset resolver falls back to bundled /app/public and the // volume-backed client-asset split (the entire reason for this phase) becomes // inert. This lint is the CI tripwire for that regression class (threat // T-06.5-04 in the plan's STRIDE register). // // Usage: // node tools/scripts/lint-fly-env.mjs // pnpm lint:fly-env // // Exit: 0 clean, 1 violation. All failures listed (not just the first). import { readFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; const FILES = [ 'apps/server/fly.staging.toml', 'apps/server/fly.prod.toml', ]; const REQUIRED_LINE_RE = /^\s*STATIC_ASSETS_DIR\s*=\s*"\/data\/client-assets\/current"\s*$/m; const REQUIRED_TAG = '[doc->REQ-DEP-01]'; const errors = []; for (const rel of FILES) { const abs = resolve(process.cwd(), rel); if (!existsSync(abs)) { errors.push(`FAIL: ${rel}: file not found`); continue; } const text = readFileSync(abs, 'utf8'); // (a) required env line — must appear within the `[env]` block. // Strategy: find the `[env]` header, then scan until the next top-level // section header (`[xxx]` or `[[xxx]]`) and assert the canonical line is in // that slice. This prevents a stray decoy line inside, say, `[build]` from // satisfying the lint. const envIdx = text.indexOf('\n[env]'); if (envIdx < 0 && !text.startsWith('[env]')) { errors.push(`FAIL: ${rel}: missing [env] block`); } else { const start = envIdx < 0 ? 0 : envIdx + 1; // strip the leading \n const after = text.slice(start + '[env]'.length); // Next section header marks the end of [env]. const nextSectionMatch = after.match(/^\s*\[/m); const endRel = nextSectionMatch == null ? after.length : after.indexOf(nextSectionMatch[0]); const envBlock = after.slice(0, endRel); if (!REQUIRED_LINE_RE.test(envBlock)) { errors.push( `FAIL: ${rel}: STATIC_ASSETS_DIR missing or wrong value in [env] block (expected: STATIC_ASSETS_DIR = "/data/client-assets/current")`, ); } } // (b) header comment must carry the REQ-DEP-01 doc tag. // Scan only the contiguous comment lines at the top of the file (until the // first blank line or non-comment line). This pins the assertion to the // header, not to incidental tags lower down. const lines = text.split(/\r?\n/); let headerHasTag = false; for (const line of lines) { const trimmed = line.trim(); if (trimmed === '') break; if (!trimmed.startsWith('#')) break; if (trimmed.includes(REQUIRED_TAG)) { headerHasTag = true; break; } } if (!headerHasTag) { errors.push( `FAIL: ${rel}: missing REQ-DEP-01 doc tag in header comment (expected token: ${REQUIRED_TAG})`, ); } if ( errors.find((e) => e.startsWith(`FAIL: ${rel}:`)) === undefined ) { console.log(`OK: ${rel}`); } } if (errors.length) { for (const e of errors) console.error(e); console.error( `lint-fly-env: ${errors.length} violation(s) — STATIC_ASSETS_DIR must be present in both fly toml files.`, ); process.exit(1); } console.log('lint-fly-env: OK');