#!/usr/bin/env node /** * The i18n guard allowlist may only grow. * * `i18nGuardFiles` in eslint.i18n.options.mjs is what makes migrated code stay * migrated: once a path is listed, `i18next/no-literal-string` is an error there * forever. That protection has one obvious failure mode — someone hits the error, * deletes the entry, and the build goes green with the migration silently undone. * docs/i18n.md says never to do that; this makes it mechanical. * * Removing an entry is legitimate exactly once: when a file is deleted or renamed. * Both are detectable, so the check only complains when the path still exists. * * The list also has to stay honest about what it claims, so a second check * rejects a path listed twice. A duplicate costs nothing at runtime, so before * this check existed it slipped through every gate the repo had while quietly * presenting an earlier migration's work as the current PR's. * * A third check rejects an entry that matches no file. Both of the above compare * two STATES of the array and so cannot see an entry that was dead the day it * was written — which is what a dynamic route produces, since `[id]` is a glob * character class. That entry guards nothing while every check reports green. * * Usage: node scripts/check-guard-allowlist.mjs [--base ] */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { git, REPO_ROOT, resolveBase, WEB_DIR } from "./lib/git-base.mjs"; import { duplicateEntries, filesForEntry, unmatchedEntries } from "./lib/guard-allowlist.mjs"; const OPTIONS_PATH = "apps/web/eslint.i18n.options.mjs"; /** Shared by the removal and unmatched checks so they cannot disagree. */ const filesFor = (entry) => filesForEntry(entry, { cwd: WEB_DIR, fsImpl: fs }); const resolved = resolveBase(); if (resolved.skip) { console.log(`⚠ guard allowlist check skipped — ${resolved.skip}`); process.exit(0); } const { base } = resolved; let baseSource; try { // stderr silenced: "exists on disk, but not in " is the expected result // for any branch forked before the options module landed, and printing a // `fatal:` line there reads as a failure in CI logs when it is not one. baseSource = git(["show", `${base}:${OPTIONS_PATH}`], { quiet: true }); } catch { // The options module does not exist at the base commit, so nothing can have // been removed from it. True for any branch forked before i18n landed. console.log("✓ guard allowlist — no baseline to compare (options module is new)"); process.exit(0); } /** * The options module is a dependency-free set of exports, so it imports cleanly * from a temp path. Parsing the array out with a regex would break the moment * someone reformats it. */ async function guardFilesFrom(source) { const file = path.join( fs.mkdtempSync(path.join(os.tmpdir(), "kandev-i18n-guard-")), "options.mjs", ); fs.writeFileSync(file, source); try { const module = await import(`file://${file}`); return module.i18nGuardFiles ?? []; } finally { fs.rmSync(path.dirname(file), { recursive: true, force: true }); } } const before = await guardFilesFrom(baseSource); const after = await guardFilesFrom(fs.readFileSync(path.join(REPO_ROOT, OPTIONS_PATH), "utf8")); const afterSet = new Set(after); /** * An entry may be removed once its path is genuinely gone — a deleted or renamed * file, or a migrated directory that was later deleted wholesale. Only a removal * whose target still EXISTS un-protects live code, so that is all we flag. * * Globs are resolved rather than assumed live: flagging every removed glob would * block the legitimate flow of migrating `components/foo/**`, listing it, then * deleting `foo/` entirely. */ const removed = before.filter((entry) => !afterSet.has(entry) && filesFor(entry).length > 0); if (removed.length > 0) { console.error( `\n✖ ${removed.length} entr(ies) removed from i18nGuardFiles:\n\n` + removed.map((entry) => ` ${entry}`).join("\n") + `\n\n Those paths are still present, so removing them un-protects copy that` + `\n was already migrated. If a literal there is failing lint, externalize it` + `\n — do not drop the entry. See docs/i18n.md ("The guard is scoped").\n`, ); process.exit(1); } /** * Nothing above catches a path listed twice: a removal check only ever looks at * what left. A duplicate changes no behaviour either, so `i18next/no-literal-string`, * `i18n:check` and the new-code half of the ratchet are all indifferent to one — * which is exactly why it needs its own check here. See `duplicateEntries` for * why this is deliberately exact-match and not a subsumption check. */ const duplicates = duplicateEntries(after); if (duplicates.length > 0) { console.error( `\n✖ ${duplicates.length} duplicate entr(ies) in i18nGuardFiles:\n\n` + duplicates.map((entry) => ` ${entry}`).join("\n") + `\n\n Each of those is already listed elsewhere in the array, so the second` + `\n copy protects nothing new — it only reads as coverage this change added.` + `\n Delete the copy you just wrote; the existing entry already covers it.\n`, ); process.exit(1); } /** * And nothing above catches an entry that never matched anything: both checks * compare two states of the array, so an entry that was born dead looks exactly * like a healthy one. See `unmatchedEntries` for how a dynamic route produces * that silently. * * Uses the same `filesFor` resolver as the removal check above, so the two * cannot disagree about what "this entry has files" means. */ const unmatched = unmatchedEntries(after, filesFor); if (unmatched.length > 0) { console.error( `\n✖ ${unmatched.length} entr(ies) in i18nGuardFiles match no file:\n\n` + unmatched.map((entry) => ` ${entry}`).join("\n") + `\n\n Each is listed but guards nothing, so migrated copy there can drift` + `\n back with every check reporting green. A dynamic route is the usual` + `\n cause: glob brackets are a character class, so \`[id]\` matches the` + `\n letters i and d, never the directory — write \`[[]id[]]\`. If the path` + `\n was genuinely deleted, drop the entry instead.` + `\n See docs/i18n.md ("An entry can be born dead").\n`, ); process.exit(1); } const added = after.filter((entry) => !new Set(before).has(entry)); console.log( `✓ guard allowlist intact — ${after.length} entr(ies)` + (added.length ? `, ${added.length} added` : ""), );