Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
245 changes: 220 additions & 25 deletions scripts/i18n-guard.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,218 @@
#!/usr/bin/env node
// Heuristic guard against hardcoded user-facing strings in already-migrated
// areas of the frontend. It scans `.svelte` markup (not <script>/<style>) for
// visible text nodes and a fixed set of human-facing attributes.
// i18n guard with two independent checks:
//
// This is intentionally a lightweight, dependency-free heuristic, not a parser:
// it runs at WARNING level (exit 0) by default so occasional false positives
// are tolerable. Pass `--strict` to make findings fatal (exit 1) — the final
// i18n migration chunk flips the quality pipeline to strict once every listed
// area is clean.
// 1. Catalog integrity (exact, always fatal). Scans every locale file under
// `web-common/src/lib/i18n/messages/` and reports duplicate keys, keys
// missing from any locale (against the union of keys across all locales),
// empty message texts, and parameter mismatches (each locale is checked
// against the superset of `input` parameters seen for that key). Messages
// may be plain strings or variant arrays (declarations/selectors/match, as
// compiled by paraglide); variant messages are also checked for internal
// consistency: selectors, match keys, and placeholders must resolve to
// declared inputs or locals.
//
// Suppress a specific line with an `i18n-ignore` comment on it or the line above.
// 2. Hardcoded-string heuristic (warning by default). Scans `.svelte` markup
// (not <script>/<style>) in already-migrated areas for visible text nodes
// and a fixed set of human-facing attributes. Intentionally a lightweight,
// dependency-free heuristic, not a parser: it runs at WARNING level
// (exit 0) by default so occasional false positives are tolerable. Pass
// `--strict` to make findings fatal (exit 1) — the final i18n migration
// chunk flips the quality pipeline to strict once every listed area is
// clean. Suppress a specific line with an `i18n-ignore` comment on it or
// the line above.
//
// Usage: node scripts/i18n-guard.js [--strict]

import { globSync, readFileSync } from "node:fs";
import { relative } from "node:path";
import { globSync, readFileSync, readdirSync } from "node:fs";
import { join, relative } from "node:path";

const strict = process.argv.includes("--strict");

// ---------------------------------------------------------------------------
// Catalog integrity
// ---------------------------------------------------------------------------

const MESSAGES_DIR = "web-common/src/lib/i18n/messages";
// Keys that are part of the file format, not messages.
const NON_MESSAGE_KEYS = new Set(["$schema"]);

const PLACEHOLDER_RE = /\{(\w+)\}/g;
const INPUT_DECL_RE = /^input\s+(\w+)$/;
const LOCAL_DECL_RE = /^local\s+(\w+)\s*=\s*(\w+)\s*:/;

// Top-level keys read from the raw text (JSON.parse silently drops duplicate
// keys, so duplicates must be detected before parsing).
function topLevelKeys(raw) {
const keys = [];
let depth = 0;
let i = 0;
while (i < raw.length) {
const ch = raw[i];
if (ch === '"') {
const start = ++i;
while (i < raw.length && raw[i] !== '"') {
if (raw[i] === "\\") i++;
i++;
}
const str = raw.slice(start, i);
i++;
if (depth === 1 && /^\s*:/.test(raw.slice(i))) keys.push(str);
} else {
if (ch === "{" || ch === "[") depth++;
else if (ch === "}" || ch === "]") depth--;
i++;
}
}
return keys;
}

function placeholders(text) {
return [...String(text).matchAll(PLACEHOLDER_RE)].map((m) => m[1]);
}

// Returns the set of parameter (input) names for a message, pushing any
// internal-consistency problems onto `errors`. A plain string's parameters
// are its placeholders; a variant message's parameters are its declared
// inputs.
function messageInputs(value, where, errors) {
if (typeof value === "string") {
if (!value.trim()) errors.push(`${where}: empty message text`);
return new Set(placeholders(value));
}

if (!Array.isArray(value)) {
errors.push(`${where}: message must be a string or a variant array`);
return new Set();
}

const inputs = new Set();
value.forEach((variant, idx) => {
const at = value.length === 1 ? where : `${where}[${idx}]`;
if (typeof variant !== "object" || variant === null) {
errors.push(`${at}: variant must be an object`);
return;
}

const locals = new Set();
for (const decl of variant.declarations ?? []) {
const input = INPUT_DECL_RE.exec(decl);
const local = LOCAL_DECL_RE.exec(decl);
if (input) {
inputs.add(input[1]);
} else if (local) {
locals.add(local[1]);
if (!inputs.has(local[2])) {
errors.push(`${at}: local "${local[1]}" reads undeclared input "${local[2]}"`);
}
} else {
errors.push(`${at}: unrecognized declaration "${decl}"`);
}
}

const selectors = variant.selectors ?? [];
for (const sel of selectors) {
if (!locals.has(sel) && !inputs.has(sel)) {
errors.push(`${at}: selector "${sel}" is not a declared input or local`);
}
}

const match = variant.match ?? {};
if (Object.keys(match).length === 0) {
errors.push(`${at}: variant has no match branches`);
}
for (const [branch, text] of Object.entries(match)) {
for (const cond of branch.split(/[,\s]+/).filter(Boolean)) {
const name = cond.split("=")[0];
if (!selectors.includes(name)) {
errors.push(`${at}: match branch "${branch}" uses unknown selector "${name}"`);
}
}
if (typeof text !== "string" || !text.trim()) {
errors.push(`${at}: empty text for match branch "${branch}"`);
}
for (const ref of placeholders(text)) {
if (!inputs.has(ref) && !locals.has(ref)) {
errors.push(`${at}: placeholder "{${ref}}" is not a declared input or local`);
}
}
}
});
return inputs;
}

function checkCatalogs() {
const errors = [];
const files = readdirSync(MESSAGES_DIR)
.filter((f) => f.endsWith(".json"))
.sort();
if (files.length === 0) {
errors.push(`${MESSAGES_DIR}: no locale files found`);
return errors;
}

const catalogs = new Map(); // file -> { keys: Set, inputs: Map<key, Set> }
for (const file of files) {
const raw = readFileSync(join(MESSAGES_DIR, file), "utf8");

const rawKeys = topLevelKeys(raw).filter((k) => !NON_MESSAGE_KEYS.has(k));
const seen = new Set();
for (const key of rawKeys) {
if (seen.has(key)) errors.push(`${file}: duplicate key "${key}"`);
seen.add(key);
}

const messages = JSON.parse(raw);
const inputs = new Map();
for (const [key, value] of Object.entries(messages)) {
if (NON_MESSAGE_KEYS.has(key)) continue;
inputs.set(key, messageInputs(value, `${file}: ${key}`, errors));
}
catalogs.set(file, { keys: seen, inputs });
}

// Key parity: every locale must define the union of keys across locales.
const allKeys = new Set();
for (const { keys } of catalogs.values()) {
for (const key of keys) allKeys.add(key);
}
for (const [file, { keys }] of catalogs) {
const missing = [...allKeys].filter((k) => !keys.has(k)).sort();
for (const key of missing) errors.push(`${file}: missing key "${key}"`);
}

// Parameter parity: each locale must accept the superset of inputs seen for
// a key, so no locale breaks when a caller passes every declared input.
for (const key of allKeys) {
const superset = new Set();
for (const { inputs } of catalogs.values()) {
for (const name of inputs.get(key) ?? []) superset.add(name);
}
if (superset.size === 0) continue;
for (const [file, { inputs }] of catalogs) {
if (!inputs.has(key)) continue; // already reported as missing key
const missing = [...superset].filter((p) => !inputs.get(key).has(p)).sort();
if (missing.length > 0) {
errors.push(
`${file}: ${key}: missing parameter(s) ${missing.map((p) => `"{${p}}"`).join(", ")}`,
);
}
}
}

return errors;
}

const catalogErrors = checkCatalogs();
if (catalogErrors.length === 0) {
console.log("i18n-guard: message catalogs are consistent.");
} else {
console.log(`i18n-guard: ${catalogErrors.length} catalog error(s):`);
for (const e of catalogErrors) console.log(` ${e}`);
}

// ---------------------------------------------------------------------------
// Hardcoded-string heuristic
// ---------------------------------------------------------------------------

// Directories whose strings have been migrated to paraglide. Each migration
// chunk appends its directories here; the guard only polices these areas so
Expand All @@ -29,8 +227,6 @@ const MIGRATED_GLOBS = [
// `href`, `name`, etc. are deliberately excluded.
const TEXT_ATTRS = ["placeholder", "title", "aria-label", "alt", "label"];

const strict = process.argv.includes("--strict");

function stripBlocks(src) {
// Replace <script> and <style> bodies with blank lines so line numbers and
// offsets are preserved while their contents are ignored.
Expand Down Expand Up @@ -92,17 +288,16 @@ for (const pattern of MIGRATED_GLOBS) {

if (findings.length === 0) {
console.log("i18n-guard: no hardcoded strings found in migrated areas.");
process.exit(0);
} else {
const label = strict ? "ERROR" : "WARNING";
console.log(
`i18n-guard: ${findings.length} hardcoded string(s) in migrated areas (${label}):`,
);
for (const f of findings) console.log(` ${f}`);
console.log(
"\nWrap these in paraglide messages (see web-common/src/lib/i18n/README.md), " +
"or add an `i18n-ignore` comment if intentional.",
);
}

const label = strict ? "ERROR" : "WARNING";
console.log(
`i18n-guard: ${findings.length} hardcoded string(s) in migrated areas (${label}):`,
);
for (const f of findings) console.log(` ${f}`);
console.log(
"\nWrap these in paraglide messages (see web-common/src/lib/i18n/README.md), " +
"or add an `i18n-ignore` comment if intentional.",
);

process.exit(strict ? 1 : 0);
process.exit(catalogErrors.length > 0 || (strict && findings.length > 0) ? 1 : 0);
15 changes: 8 additions & 7 deletions scripts/web-test-code-quality.sh
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,14 @@ if [[ "$COMMON" == "true" ]]; then
fi

echo ""
echo "== i18n guard for migrated areas (warning only) =="
# Scans a fixed set of already-migrated areas on the filesystem, so it runs
# unconditionally rather than under an app filter: the migrated areas span
# multiple apps and are independent of which files a given PR touched.
# Reports hardcoded strings; non-fatal for now: the final i18n migration chunk
# adds --strict to make it fatal.
node ./scripts/i18n-guard.js || true
echo "== i18n guard: catalog integrity + migrated areas =="
# Scans the message catalogs and a fixed set of already-migrated areas on the
# filesystem, so it runs unconditionally rather than under an app filter: the
# migrated areas span multiple apps and are independent of which files a given
# PR touched. Catalog integrity errors are exact and fatal; hardcoded-string
# findings are heuristic and non-fatal for now: the final i18n migration chunk
# adds --strict to make them fatal too.
node ./scripts/i18n-guard.js || exit_code=$?

if [[ "$LOCAL" == "true" ]]; then
echo ""
Expand Down
45 changes: 23 additions & 22 deletions web-admin/src/components/errors/user-facing-errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { m } from "@rilldata/web-common/lib/i18n/gen/messages";
import { type UserFacingError } from "./error-store";

export function createUserFacingError(
Expand All @@ -8,77 +9,77 @@ export function createUserFacingError(
if (message === "Network Error") {
return {
statusCode: null,
header: "Network Error",
body: "It seems we're having trouble reaching our servers. Check your connection or try again later.",
header: m.error_network_header(),
body: m.error_network_body(),
};
}

// Handle some application errors
if (status === 400 && message === "driver: not found") {
return {
statusCode: status,
header: "Project deployment not found",
body: "This is potentially a temporary state if the project has just been reset.",
header: m.error_deployment_not_found_header(),
body: m.error_deployment_not_found_body(),
};
} else if (status === 401 && message === "auth token is expired") {
return {
statusCode: 401,
header: "Oops! This link has expired",
body: "It looks like this link is no longer active. Please reach out to the sender to request a new link.",
header: m.error_link_expired_header(),
body: m.error_link_expired_body(),
fatal: true,
};
} else if (status === 401) {
return {
statusCode: 401,
header: "Authentication error",
body: "Try refreshing the page. If the problem persists, try signing out and back in.",
header: m.error_auth_header(),
body: m.error_auth_body(),
};
} else if (status === 403) {
return {
statusCode: status,
header: "Access denied",
body: "You don't have access to this page. Please check that you have the correct permissions.",
header: m.error_access_denied_header(),
body: m.error_access_denied_body(),
};
} else if (message === "org not found") {
return {
statusCode: status,
header: "Organization not found",
body: "The organization you requested could not be found. Please check that you have provided a valid organization name.",
header: m.error_org_not_found_header(),
body: m.error_org_not_found_body(),
};
} else if (message === "project not found") {
return {
statusCode: status,
header: "Project not found",
body: "The project you requested could not be found. Please check that you have provided a valid project name.",
header: m.error_project_not_found_header(),
body: m.error_project_not_found_body(),
};
} else if (
status === 400 &&
message.includes("failed to find the conversation")
) {
return {
statusCode: 404,
header: "Conversation not found",
body: "Please check that you have the correct link or if you have access to it.",
header: m.error_conversation_not_found_header(),
body: m.error_conversation_not_found_body(),
};
} else if (status === 404 && message === "resource not found") {
return {
statusCode: 404,
header: "Resource not found",
body: "This resource may have been deleted, renamed, or is temporarily unavailable.",
header: m.error_resource_not_found_header(),
body: m.error_resource_not_found_body(),
};
} else if (status === 404) {
return {
statusCode: 404,
header: "Sorry, we can't find this page!",
body: "The page you're looking for might have been removed, had its name changed, or is temporarily unavailable.",
header: m.error_page_not_found_header(),
body: m.error_page_not_found_body(),
};
}

// Fallback for all other errors (including 5xx errors)
return {
statusCode: status,
header: "Sorry, something went wrong!",
body: "Try refreshing the page, and reach out to us if the problem persists.",
header: m.error_generic_header(),
body: m.error_generic_body(),
detail: message,
};
}
Loading