chore: add i18n release hardening gates

This commit is contained in:
poyrazavsever
2026-07-19 03:08:07 +03:00
parent 178f285191
commit 7d82bcc4f1
13 changed files with 943 additions and 10 deletions
+19
View File
@@ -0,0 +1,19 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const distDir = path.join(process.cwd(), ".next", "i18n-phase7-smoke-dist");
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase7-smoke.json"], {
cwd: process.cwd(),
stdio: "inherit",
});
const serverOnlyStubDir = path.join(distDir, "node_modules", "server-only");
fs.mkdirSync(serverOnlyStubDir, { recursive: true });
fs.writeFileSync(path.join(serverOnlyStubDir, "index.js"), "\n");
execFileSync(process.execPath, [path.join(distDir, "scripts", "i18n-phase7-smoke.js")], {
cwd: process.cwd(),
stdio: "inherit",
});
+63
View File
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import {
compareCatalogKeys,
createTranslatorFromMessages,
flattenCatalog,
I18N_NAMESPACES,
} from "../lib/i18n";
import { enCatalog } from "../locales/en";
import { trCatalog } from "../locales/tr";
import { directionForLocale } from "../server/i18n/locale";
const parity = compareCatalogKeys(trCatalog, enCatalog, I18N_NAMESPACES);
assert.deepEqual(parity, { missingInLeft: [], missingInRight: [] }, "TR/EN catalog keys must match");
const requiredKeys = [
"auth.login.title",
"auth.login.description",
"auth.language",
"auth.forgot.title",
"auth.reset.title",
"auth.messages.invalidCredentials",
"auth.messages.setupUnavailable",
"common.notFound.title",
"common.error.title",
"common.maintenance.title",
"validation.unsupportedLocale",
];
for (const catalog of [trCatalog, enCatalog]) {
const messages = flattenCatalog(catalog, I18N_NAMESPACES);
for (const key of requiredKeys) {
assert.ok(messages[key], `${key} must exist in built-in catalogs`);
}
}
const tr = createTranslatorFromMessages("tr", flattenCatalog(trCatalog, I18N_NAMESPACES));
const en = createTranslatorFromMessages("en", flattenCatalog(enCatalog, I18N_NAMESPACES));
assert.equal(
tr.t("auth.marketing.description", { app: "Neta" }).includes("Neta"),
true,
"auth marketing interpolation must keep app name",
);
assert.equal(en.t("common.itemsCount", { count: 1 }), "1 item");
assert.equal(en.t("common.itemsCount", { count: 2 }), "2 items");
assert.equal(directionForLocale("ar"), "rtl");
assert.equal(directionForLocale("he-IL"), "rtl");
assert.equal(directionForLocale("tr"), "ltr");
const loginActions = fs.readFileSync(path.join(process.cwd(), "app", "login", "actions.ts"), "utf8");
assert.equal(loginActions.includes("message=${encodeURIComponent"), false, "login action redirects must use stable codes");
assert.equal(loginActions.includes("E-posta veya"), false, "login action must not embed Turkish user-facing errors");
const inviteActions = fs.readFileSync(path.join(process.cwd(), "app", "invite", "[token]", "actions.ts"), "utf8");
assert.equal(inviteActions.includes("message=${encodeURIComponent"), false, "invite action redirects must use stable codes");
const localeApi = fs.readFileSync(path.join(process.cwd(), "app", "api", "i18n", "locale", "route.ts"), "utf8");
assert.equal(localeApi.includes("Dil kodu"), false, "locale API must not embed Turkish validation messages");
assert.equal(localeApi.includes("messageKey"), true, "locale API must expose messageKey for localized clients");
console.log("I18n phase 7 auth/error/a11y smoke passed.");
+94
View File
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
for (const scriptName of [
"phase-i18n:boundary",
"i18n:phase1-smoke",
"i18n:phase2-smoke",
"i18n:phase3-smoke",
"i18n:phase4-boundary",
"i18n:phase5-smoke",
"i18n:phase7-smoke",
"i18n:phase8-smoke",
"i18n:phase9-hardening",
]) {
assert.ok(pkg.scripts?.[scriptName], `Missing package script: ${scriptName}`);
}
const runtimeFiles = listFiles(root, [
"app",
"components",
"config",
"lib",
"server",
]).filter((file) => /\.(ts|tsx|mjs|js)$/.test(file));
const boundaryAllowlist = new Set([
path.join(root, "lib", "i18n", "date-fns.ts"),
path.join(root, "lib", "i18n", "format.ts"),
]);
const forbiddenRuntimePatterns = [
/@supabase\//i,
/createClient\([^)]*supabase/i,
/supabase\.co/i,
/NEXT_PUBLIC_SUPABASE/i,
/SUPABASE_SERVICE_ROLE/i,
/from ["']date-fns\/locale\/tr["']/,
/from ["']date-fns\/locale["']/,
/["']tr-TR["']/,
];
const violations = [];
for (const file of runtimeFiles) {
if (boundaryAllowlist.has(file)) continue;
const text = fs.readFileSync(file, "utf8");
for (const pattern of forbiddenRuntimePatterns) {
if (pattern.test(text)) violations.push(`${path.relative(root, file)} :: ${pattern}`);
}
}
assert.deepEqual(violations, [], `I18n release boundary violations:\n${violations.join("\n")}`);
const phase9ApiBoundary = fs.readFileSync(path.join(root, "scripts", "phase9-api-boundary.mjs"), "utf8");
for (const marker of [
"instance.localization",
"UNSUPPORTED_LOCALE",
"Accept-Language",
"resolvedLocale",
"portalLocale",
]) {
assert.ok(phase9ApiBoundary.includes(marker), `Phase 9 API boundary must include ${marker}`);
}
const hardcodedReport = path.join(root, "docs", "self-hosted-redesign", "i18n-phase-4-hardcoded-text-report.md");
assert.ok(fs.existsSync(hardcodedReport), "Hardcoded text report must exist for release review");
assert.ok(
fs.readFileSync(hardcodedReport, "utf8").includes("release blocker"),
"Hardcoded text report must document release blocker policy",
);
console.log("I18n phase 9 boundary passed: scripts, Supabase/runtime locale and release report gates verified.");
function listFiles(baseDir, roots) {
const files = [];
for (const rootName of roots) {
const start = path.join(baseDir, rootName);
if (!fs.existsSync(start)) continue;
walk(start, files);
}
return files;
}
function walk(current, files) {
const stat = fs.lstatSync(current);
if (stat.isSymbolicLink()) return;
if (stat.isDirectory()) {
if (["node_modules", ".next", ".git"].includes(path.basename(current))) return;
for (const entry of fs.readdirSync(current)) walk(path.join(current, entry), files);
return;
}
if (stat.isFile()) files.push(current);
}
+295
View File
@@ -0,0 +1,295 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
const root = process.cwd();
const workRoot = path.join(root, ".data", `i18n-phase9-hardening-${Date.now()}`);
const emptyDataDir = path.join(workRoot, "empty");
const productionLikeDataDir = path.join(workRoot, "production-like");
const restoreDir = path.join(workRoot, "restore");
const failedRestoreDir = path.join(workRoot, "failed-restore");
fs.mkdirSync(workRoot, { recursive: true });
execFileSync(process.execPath, ["scripts/i18n-phase9-boundary.mjs"], {
cwd: root,
stdio: "inherit",
});
runEmptyMigrationRehearsal(emptyDataDir);
runProductionLikeMigrationRehearsal(productionLikeDataDir);
runBackupRestoreAndFailureRehearsal(productionLikeDataDir, restoreDir, failedRestoreDir);
assertSupabaseImportCoverage();
assertStandaloneCatalogPresence();
console.log("I18n phase 9 hardening passed: migration, backup/restore, import coverage and release boundary verified.");
function runEmptyMigrationRehearsal(dataDir) {
const env = envFor(dataDir);
execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: root, env, stdio: "inherit" });
execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: root, env, stdio: "inherit" });
const db = new Database(path.join(dataDir, "neta.db"));
try {
assert.equal(countRows(db, "instance_locales", "code in ('tr', 'en') and status = 'active'"), 2);
assert.equal(single(db, "select default_locale from instance_i18n_settings where key = 'default'"), "tr");
assert.equal(countRows(db, "content_translations"), 0);
assert.deepEqual(db.pragma("foreign_key_check"), []);
} finally {
db.close();
}
}
function runProductionLikeMigrationRehearsal(dataDir) {
fs.mkdirSync(dataDir, { recursive: true });
const oldMigrationsDir = createPartialMigrationsDir(7);
const dbPath = path.join(dataDir, "neta.db");
const db = new Database(dbPath);
try {
applyPragmas(db);
migrate(drizzle({ client: db }), { migrationsFolder: oldMigrationsDir });
seedProductionLikeTurkishData(db);
} finally {
db.close();
}
const env = envFor(dataDir);
execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: root, env, stdio: "inherit" });
assertBackfilledTurkishData(dbPath);
const before = snapshotBackfillCounts(dbPath);
execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: root, env, stdio: "inherit" });
assert.deepEqual(snapshotBackfillCounts(dbPath), before, "Backfill must be idempotent on repeated migrate");
}
function runBackupRestoreAndFailureRehearsal(sourceDataDir, targetRestoreDir, failedTargetDir) {
const sourceDbPath = path.join(sourceDataDir, "neta.db");
const db = new Database(sourceDbPath);
try {
db.prepare(`
insert into instance_locales (code, name, native_name, status, fallback_locale, text_direction, built_in, sort_order)
values ('fr', 'French', 'Français', 'active', 'en', 'ltr', 0, 30)
on conflict(code) do update set status = excluded.status
`).run();
db.prepare(`
insert into content_translations (entity_type, entity_id, field, locale, value)
values ('project', 'prod-project', 'name', 'fr', 'Site de marque')
on conflict(entity_type, entity_id, field, locale) do update set value = excluded.value
`).run();
} finally {
db.close();
}
execFileSync(process.execPath, ["scripts/backup.mjs", "--retention-count", "2"], {
cwd: root,
env: envFor(sourceDataDir),
stdio: "inherit",
});
const backupDir = latestBackupDir(path.join(sourceDataDir, "backups"));
execFileSync(process.execPath, ["scripts/restore.mjs", "--from", backupDir, "--target", targetRestoreDir], {
cwd: root,
env: process.env,
stdio: "inherit",
});
const restored = new Database(path.join(targetRestoreDir, "neta.db"), { readonly: true });
try {
assert.equal(single(restored, "select status from instance_locales where code = 'fr'"), "active");
assert.equal(
single(restored, "select value from content_translations where entity_type = 'project' and entity_id = 'prod-project' and field = 'name' and locale = 'fr'"),
"Site de marque",
);
assert.deepEqual(restored.pragma("foreign_key_check"), []);
} finally {
restored.close();
}
fs.mkdirSync(failedTargetDir, { recursive: true });
fs.copyFileSync(path.join(targetRestoreDir, "neta.db"), path.join(failedTargetDir, "neta.db"));
const tamperedBackup = path.join(workRoot, "tampered-backup");
fs.cpSync(backupDir, tamperedBackup, { recursive: true });
fs.appendFileSync(path.join(tamperedBackup, "neta.db"), "tamper");
assert.throws(
() => execFileSync(
process.execPath,
["scripts/restore.mjs", "--from", tamperedBackup, "--target", failedTargetDir, "--force"],
{ cwd: root, env: process.env, stdio: "pipe" },
),
/Command failed/,
);
const failedTarget = new Database(path.join(failedTargetDir, "neta.db"), { readonly: true });
try {
assert.equal(single(failedTarget, "select status from instance_locales where code = 'fr'"), "active");
} finally {
failedTarget.close();
}
}
function assertSupabaseImportCoverage() {
const importSmoke = fs.readFileSync(path.join(root, "scripts", "phase8-import-smoke.mjs"), "utf8");
for (const marker of [
"runImport([\"--dry-run\"])",
"runImport([\"--allow-existing\"])",
"invalid-enum",
"invalid-foreign-key",
"unsafe-storage",
"rollback",
]) {
assert.ok(importSmoke.includes(marker), `Supabase import smoke must cover ${marker}`);
}
}
function assertStandaloneCatalogPresence() {
const standaloneDir = path.join(root, ".next", "standalone");
if (!fs.existsSync(standaloneDir)) return;
const serverDir = path.join(standaloneDir, ".next", "server");
const chunkText = listFiles(serverDir, ["."])
.filter((file) => file.endsWith(".js"))
.map((file) => fs.readFileSync(file, "utf8"))
.join("\n");
for (const marker of [
"auth.marketing.headline",
"Türkçe",
"English",
"instance.localization",
]) {
assert.ok(chunkText.includes(marker), `Standalone server bundle must include marker: ${marker}`);
}
}
function createPartialMigrationsDir(maxIndex) {
const source = path.join(root, "server", "db", "migrations");
const destination = path.join(workRoot, `migrations-0-${maxIndex}`);
fs.mkdirSync(path.join(destination, "meta"), { recursive: true });
for (const entry of fs.readdirSync(source)) {
const match = /^(\d{4})_.*\.sql$/.exec(entry);
if (match && Number(match[1]) <= maxIndex) {
fs.copyFileSync(path.join(source, entry), path.join(destination, entry));
}
}
for (const entry of fs.readdirSync(path.join(source, "meta"))) {
const match = /^(\d{4})_snapshot\.json$/.exec(entry);
if (match && Number(match[1]) <= maxIndex) {
fs.copyFileSync(path.join(source, "meta", entry), path.join(destination, "meta", entry));
}
}
const journal = JSON.parse(fs.readFileSync(path.join(source, "meta", "_journal.json"), "utf8"));
journal.entries = journal.entries.filter((entry) => entry.idx <= maxIndex);
fs.writeFileSync(path.join(destination, "meta", "_journal.json"), `${JSON.stringify(journal, null, 2)}\n`);
return destination;
}
function seedProductionLikeTurkishData(db) {
const now = Date.now();
db.prepare("insert into user (id, name, email, email_verified, created_at, updated_at) values (?, ?, ?, 1, ?, ?)")
.run("prod-owner", "Prod Owner", "prod-owner@example.com", now, now);
db.prepare("insert into app_profiles (auth_user_id, email, display_name, role, disabled, created_at, updated_at) values (?, ?, ?, 'freelancer', 0, ?, ?)")
.run("prod-owner", "prod-owner@example.com", "Prod Owner", now, now);
db.prepare("insert into user_preferences (owner_user_id, language, created_at, updated_at) values (?, 'tr', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
.run("prod-owner");
db.prepare("insert into clients (id, owner_user_id, name, email, status, pipeline_stage, created_at, updated_at) values (?, ?, ?, ?, 'active', 'won', ?, ?)")
.run("prod-client", "prod-owner", "Türkçe Müşteri", "client@example.com", now, now);
db.prepare(`
insert into projects (id, owner_user_id, client_id, name, description, status, currency, progress, progress_type, revision_quota, cover_image_alt, created_at, updated_at)
values (?, ?, ?, ?, ?, 'active', 'TRY', 25, 'manual', 2, ?, ?, ?)
`).run("prod-project", "prod-owner", "prod-client", "Marka Sitesi", "Türkçe proje açıklaması", "Kapak görseli", now, now);
db.prepare(`
insert into tasks (id, owner_user_id, client_id, project_id, title, description, status, priority, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, 'todo', 'medium', ?, ?)
`).run("prod-task", "prod-owner", "prod-client", "prod-project", "Hero alanını hazırla", "Başlık ve CTA metnini düzenle", now, now);
db.prepare(`
insert into project_planning_sections (id, owner_user_id, project_id, category, title, content, metadata, sort_order, created_at, updated_at)
values (?, ?, ?, 'overview', ?, ?, '{}', 1, ?, ?)
`).run("prod-section", "prod-owner", "prod-project", "Genel Bakış", "Türkçe plan içeriği", now, now);
db.prepare(`
insert into instance_branding (id, owner_user_id, application_name, short_name, primary_color, accent_color, default_color_mode, radius_scale, organization_name, portal_welcome_text, portal_footer_text, updated_by_user_id, created_at, updated_at)
values ('default', ?, 'Neta', 'Neta', '#C81E1E', '#E6EDF5', 'system', 'default', 'Prod Workspace', ?, ?, ?, ?, ?)
`).run("prod-owner", "Portala hoş geldiniz", "Tüm hakları saklıdır", "prod-owner", now, now);
}
function assertBackfilledTurkishData(dbPath) {
const db = new Database(dbPath);
try {
assert.equal(single(db, "select value from content_translations where entity_type = 'project' and entity_id = 'prod-project' and field = 'name' and locale = 'tr'"), "Marka Sitesi");
assert.equal(single(db, "select value from content_translations where entity_type = 'project' and entity_id = 'prod-project' and field = 'description' and locale = 'tr'"), "Türkçe proje açıklaması");
assert.equal(single(db, "select value from content_translations where entity_type = 'task' and entity_id = 'prod-task' and field = 'title' and locale = 'tr'"), "Hero alanını hazırla");
assert.equal(single(db, "select value from content_translations where entity_type = 'planning_section' and entity_id = 'prod-section' and field = 'title' and locale = 'tr'"), "Genel Bakış");
assert.equal(single(db, "select value from content_translations where entity_type = 'branding' and entity_id = 'default' and field = 'portalWelcome' and locale = 'tr'"), "Portala hoş geldiniz");
assert.equal(single(db, "select default_locale from instance_i18n_settings where key = 'default'"), "tr");
assert.equal(countRows(db, "instance_locales", "code in ('tr', 'en') and status = 'active'"), 2);
assert.deepEqual(db.pragma("foreign_key_check"), []);
} finally {
db.close();
}
}
function snapshotBackfillCounts(dbPath) {
const db = new Database(dbPath);
try {
return {
translations: countRows(db, "content_translations"),
locales: countRows(db, "instance_locales"),
settings: countRows(db, "instance_i18n_settings"),
};
} finally {
db.close();
}
}
function latestBackupDir(backupsDir) {
return fs
.readdirSync(backupsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(backupsDir, entry.name))
.sort((left, right) => path.basename(right).localeCompare(path.basename(left)))[0];
}
function envFor(dataDir) {
return {
...process.env,
DATA_DIR: dataDir,
DATABASE_PATH: path.join(dataDir, "neta.db"),
};
}
function countRows(db, table, where = "1 = 1") {
return db.prepare(`select count(*) as value from ${table} where ${where}`).get().value;
}
function single(db, sql) {
const row = db.prepare(sql).get();
assert.ok(row, `Expected row for query: ${sql}`);
return Object.values(row)[0];
}
function applyPragmas(db) {
db.pragma("foreign_keys = ON");
db.pragma("journal_mode = WAL");
db.pragma("synchronous = NORMAL");
db.pragma("busy_timeout = 5000");
}
function listFiles(baseDir, roots) {
const files = [];
for (const rootName of roots) {
const start = path.join(baseDir, rootName);
if (!fs.existsSync(start)) continue;
walk(start, files);
}
return files;
}
function walk(current, files) {
const stat = fs.lstatSync(current);
if (stat.isSymbolicLink()) return;
if (stat.isDirectory()) {
for (const entry of fs.readdirSync(current)) walk(path.join(current, entry), files);
return;
}
if (stat.isFile()) files.push(current);
}