581 lines
22 KiB
TypeScript
581 lines
22 KiB
TypeScript
/**
|
|
* Morphit smoke — raw exception message → UI anti-pattern detector.
|
|
*
|
|
* Closes C-28 from Audit Part 31(R3). The bug pattern:
|
|
*
|
|
* } catch (err) {
|
|
* errorMsg = err instanceof Error ? err.message : String(err);
|
|
* // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
* // raw English exception text → renders in UI
|
|
* }
|
|
*
|
|
* Sally in a non-English locale sees "Seed must be 12 or 24
|
|
* words" or "fetch failed" mixed in with otherwise-localized UI.
|
|
* This pattern was found at 11 sites during Part 31(R3) and
|
|
* fixed; this smoke prevents regression.
|
|
*
|
|
* The fix pattern (see Part 31(R3) commits):
|
|
*
|
|
* } catch (err) {
|
|
* console.warn('[component] thing failed:', err);
|
|
* errorMsg = $_('component.error.specific_localized_key');
|
|
* }
|
|
*
|
|
* What this smoke flags:
|
|
*
|
|
* Any line in apps/web/src/routes or apps/web/src/lib/components
|
|
* that assigns `err instanceof Error ? err.message : ...` (or
|
|
* close variants) to anything OTHER than:
|
|
* - a `const`/`let` local (debug, used for further analysis)
|
|
* - a console.* call argument
|
|
*
|
|
* I.e., any assignment to a `let $state` variable — which is
|
|
* the Svelte 5 pattern for UI-bound state.
|
|
*
|
|
* Heuristic:
|
|
*
|
|
* For each .svelte file, find lines that:
|
|
* 1. Contain `err instanceof Error ? err.message : ...`
|
|
* 2. Are an assignment (have `=` before `err instanceof`)
|
|
* 3. The LHS is NOT a `const ` or `let ` declaration with a
|
|
* local-scope name (those are debug intermediates)
|
|
*
|
|
* Catches the pattern; misses the case where the raw err.message
|
|
* is assigned to a local that's later assigned to state. That's
|
|
* acceptable because the fix in that case is the same — convert
|
|
* the local to a localized key at the point where it crosses
|
|
* the local→state boundary.
|
|
*
|
|
* Allowlist:
|
|
*
|
|
* /dev/* routes (developer-only, not user-facing)
|
|
*
|
|
* Specific file:line sites can be allowlisted with a one-line
|
|
* justification when raw text is intentional (e.g. a fallback
|
|
* rendered only inside a <code> debug block that's hidden in
|
|
* production).
|
|
*
|
|
* Run via the standard smoke runner:
|
|
* bash scripts/run-smokes.sh
|
|
*/
|
|
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
let scenarios = 0;
|
|
let failures = 0;
|
|
function scenario(name: string, fn: () => void): void {
|
|
scenarios++;
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
} catch (err) {
|
|
failures++;
|
|
console.log(` ✗ ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
console.log('\n── raw-exception-to-ui smoke ─────────────────────────────\n');
|
|
|
|
// ─── Resolve repo root ────────────────────────────────────────────
|
|
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
|
|
|
|
// ─── Files to scan ────────────────────────────────────────────────
|
|
const SCAN_DIRS = [
|
|
path.join(REPO_ROOT, 'apps/web/src/routes'),
|
|
path.join(REPO_ROOT, 'apps/web/src/lib/components')
|
|
];
|
|
|
|
const EXCLUDE_PATH_PATTERNS = [/\/dev\//, /__tests__/, /\.test\./];
|
|
|
|
// ─── Allowlisted file:line pairs ──────────────────────────────────
|
|
//
|
|
// Add `relative/path/to/file.svelte:LINE` here ONLY when raw text
|
|
// is intentional and never reaches user-facing UI. Each entry
|
|
// MUST have a one-line justification comment above it.
|
|
const ALLOWLIST_LOCATIONS = new Set<string>([
|
|
// Allowlist entries can be keyed by either:
|
|
// - `path:lineNumber` (brittle — shifts when imports are
|
|
// added; tolerated for unique sites)
|
|
// - `path:varname` (stable across line-number drift;
|
|
// preferred for sites where the variable is unique
|
|
// within the file)
|
|
// The detector tries both keys for every candidate.
|
|
|
|
// `error` here is bound only to a `title=` attribute (debug
|
|
// tooltip on hover); the visible text is i18n'd separately.
|
|
// Raw exception text in the tooltip is desirable — it's the
|
|
// debug detail an operator looks at when investigating.
|
|
'apps/web/src/routes/[lang]/instances/+page.svelte:error',
|
|
// `feeError` here is bound only to a `title=` attribute
|
|
// (debug tooltip on hover at /post); the visible text uses
|
|
// $_('post_order.fee.error_friendly'). Same rationale as
|
|
// /instances:error.
|
|
'apps/web/src/routes/[lang]/post/+page.svelte:feeError'
|
|
]);
|
|
|
|
// ─── Walk directories ─────────────────────────────────────────────
|
|
function* walk(dir: string): Generator<string> {
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = path.join(dir, entry);
|
|
const stat = statSync(full);
|
|
if (stat.isDirectory()) {
|
|
yield* walk(full);
|
|
} else if (stat.isFile() && full.endsWith('.svelte')) {
|
|
yield full;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Detector ─────────────────────────────────────────────────────
|
|
//
|
|
// The pattern we're looking for:
|
|
//
|
|
// <something> = err instanceof Error ? err.message : ...
|
|
//
|
|
// where <something> is anything OTHER than a `const`/`let`
|
|
// declaration of a local variable.
|
|
//
|
|
// Real bugs (FLAGGED):
|
|
// errorMsg = err instanceof Error ? err.message : String(err);
|
|
// broadcastError = err instanceof Error ? err.message : 'fallback';
|
|
// loadError = err instanceof Error ? err.message : 'snapshot parse failed';
|
|
// pwError =
|
|
// err instanceof Error
|
|
// ? err.message
|
|
// : $_('something'); // multi-line variant
|
|
//
|
|
// Legitimate uses (NOT flagged):
|
|
// const msg = err instanceof Error ? err.message : String(err);
|
|
// const raw = err instanceof Error ? err.message : String(err);
|
|
// console.warn('failed:', err instanceof Error ? err.message : err);
|
|
//
|
|
// Approach: scan the file as a single string. For each occurrence of
|
|
// `err instanceof Error ? err.message`, walk BACKWARDS through any
|
|
// whitespace/newlines until we hit the most recent `=` token, then
|
|
// look at what's before that `=`. If the LHS is `const`/`let`/`var`
|
|
// (local), skip. If it's inside a `console.*(...)` call, skip.
|
|
// Otherwise it's a real hit.
|
|
|
|
interface Hit {
|
|
readonly file: string;
|
|
readonly line: number;
|
|
readonly text: string;
|
|
}
|
|
|
|
function detectRawException(absPath: string): readonly Hit[] {
|
|
const src = readFileSync(absPath, 'utf8');
|
|
const hits: Hit[] = [];
|
|
// Whitespace-tolerant patterns covering the four common raw-
|
|
// exception leak shapes:
|
|
//
|
|
// 1. `err instanceof Error ? err.message : ...` (the "main"
|
|
// pattern; survives multi-line splits)
|
|
// 2. `err.message` bare (no instanceof check; less common
|
|
// but seen in some legacy code paths)
|
|
// 3. `String(err)` (toString fallback; also leaks raw text)
|
|
// 4. `err.toString()` (rare but possible)
|
|
//
|
|
// Each pattern's match is then checked against the same
|
|
// "is this an assignment to a non-local var?" gate.
|
|
const PATTERNS: RegExp[] = [
|
|
/err\s+instanceof\s+Error\s*\?\s*err\.message/g,
|
|
/\bString\(\s*err\s*\)/g,
|
|
/\berr\.toString\(\s*\)/g,
|
|
/\berr\.message\b/g
|
|
];
|
|
const newlinePositions: number[] = [-1];
|
|
for (let i = 0; i < src.length; i++) {
|
|
if (src[i] === '\n') newlinePositions.push(i);
|
|
}
|
|
function lineOf(offset: number): number {
|
|
// Binary-search for the largest newline position < offset.
|
|
let lo = 0,
|
|
hi = newlinePositions.length - 1;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi + 1) >> 1;
|
|
if (newlinePositions[mid] < offset) lo = mid;
|
|
else hi = mid - 1;
|
|
}
|
|
return lo + 1; // newline 0 means "we're on line 1"
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
for (const PATTERN of PATTERNS) {
|
|
PATTERN.lastIndex = 0; // reset since we reuse the literal regex
|
|
let m: RegExpExecArray | null;
|
|
while ((m = PATTERN.exec(src)) !== null) {
|
|
const errStart = m.index;
|
|
|
|
// Walk backwards from errStart looking for the most recent
|
|
// `=` that isn't `==`, `===`, `!=`, `<=`, `>=`. Skip past
|
|
// whitespace and newlines. Stop if we hit a `;` or `{` or
|
|
// `(` first (means the err... isn't on the RHS of an
|
|
// assignment — e.g., it's an argument inside console.warn).
|
|
let j = errStart - 1;
|
|
let foundEq = -1;
|
|
while (j >= 0) {
|
|
const c = src[j];
|
|
if (c === '=') {
|
|
// Disambiguate. Look at neighbors.
|
|
const prev = j > 0 ? src[j - 1] : '';
|
|
const next = j + 1 < src.length ? src[j + 1] : '';
|
|
if (prev === '!' || prev === '<' || prev === '>' || prev === '=') {
|
|
j--;
|
|
continue;
|
|
}
|
|
if (next === '=') {
|
|
j--;
|
|
continue;
|
|
}
|
|
foundEq = j;
|
|
break;
|
|
}
|
|
if (c === ';' || c === '{' || c === ',') break;
|
|
// `(` would mean we're inside a call; skip pattern.
|
|
if (c === '(') break;
|
|
j--;
|
|
}
|
|
if (foundEq === -1) continue;
|
|
|
|
// Look at the chunk before `=`. If it contains `const`/`let`/
|
|
// `var` declaration of a local OR is inside a console.* call,
|
|
// skip. Bound the look-back to the start-of-statement: walk
|
|
// back from `=` until we hit `;`, `{`, or start-of-file.
|
|
let stmtStart = foundEq - 1;
|
|
while (stmtStart >= 0) {
|
|
const c = src[stmtStart];
|
|
if (c === ';' || c === '{' || c === '\n') {
|
|
stmtStart++;
|
|
break;
|
|
}
|
|
stmtStart--;
|
|
}
|
|
if (stmtStart < 0) stmtStart = 0;
|
|
const beforeEq = src.slice(stmtStart, foundEq);
|
|
// Local declaration?
|
|
if (/\b(?:const|let|var)\s+\w/.test(beforeEq)) continue;
|
|
// Inside a console.*(...) call? (a console call wouldn't have
|
|
// `=` before err inside its arg list, so this is mostly
|
|
// belt-and-suspenders.)
|
|
if (/\bconsole\.\w+\s*\(/.test(beforeEq)) continue;
|
|
|
|
const lineNum = lineOf(errStart);
|
|
const relPath = path.relative(REPO_ROOT, absPath);
|
|
const locationKey = `${relPath}:${lineNum}`;
|
|
// Extract the LHS identifier (the variable being
|
|
// assigned) so the allowlist can also be keyed by
|
|
// `path:varname` — robust to line-number shifts when
|
|
// imports get added at the top of the file. The LHS
|
|
// is the trailing identifier in beforeEq.
|
|
const lhsMatch = /(\w+)\s*$/.exec(beforeEq.trim());
|
|
const lhsName = lhsMatch?.[1] ?? '';
|
|
const lhsKey = lhsName ? `${relPath}:${lhsName}` : '';
|
|
if (ALLOWLIST_LOCATIONS.has(locationKey) || (lhsKey && ALLOWLIST_LOCATIONS.has(lhsKey))) {
|
|
continue;
|
|
}
|
|
// Multiple patterns can match the same line (e.g., a
|
|
// line containing both `err.message` and the larger
|
|
// `err instanceof Error ? err.message` superpattern).
|
|
// Dedupe so each site reports once.
|
|
if (seen.has(locationKey)) continue;
|
|
seen.add(locationKey);
|
|
|
|
// Build a one-line preview for the error message. Take the
|
|
// stmtStart..end-of-pattern slice, collapse whitespace.
|
|
const previewEnd = m.index + m[0].length;
|
|
const preview = src.slice(stmtStart, previewEnd).replace(/\s+/g, ' ').trim().slice(0, 100);
|
|
hits.push({ file: relPath, line: lineNum, text: preview });
|
|
}
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
/** Second detector — catches the indirection pattern:
|
|
*
|
|
* } catch (err) {
|
|
* const raw = err.message; // ← flag this declaration
|
|
* uiState = raw.slice(0, 200); // (uiState use happens here,
|
|
* // but the smoke can't easily
|
|
* // prove uiState is non-local)
|
|
* }
|
|
*
|
|
* Heuristic: if a `const`/`let` LHS RHS contains one of the raw-err
|
|
* tokens (err.message / String(err) / err.toString()), AND the
|
|
* variable is referenced LATER within the same enclosing block, AND
|
|
* at least one reference is NOT inside a `console.*(...)` call, flag
|
|
* the DECLARATION line.
|
|
*
|
|
* Trade-offs:
|
|
* - True positives: the MyBalanceCard / explorer-route pattern.
|
|
* - False positives: a local `const raw = err.message` used only
|
|
* to call `console.warn('...', raw)` — that's safe but flagged.
|
|
* Annotate those sites with a `// smoke-ok-raw-local` comment
|
|
* and the smoke will skip them.
|
|
*
|
|
* The trade-off is acceptable because legitimate uses of err.message
|
|
* (console + log + telemetry) almost always pass `err` itself rather
|
|
* than an intermediate variable. When they do, the comment annotation
|
|
* is a one-line cost.
|
|
*/
|
|
function detectRawExceptionViaLocal(absPath: string): readonly Hit[] {
|
|
const src = readFileSync(absPath, 'utf8');
|
|
const hits: Hit[] = [];
|
|
const newlinePositions: number[] = [-1];
|
|
for (let i = 0; i < src.length; i++) {
|
|
if (src[i] === '\n') newlinePositions.push(i);
|
|
}
|
|
function lineOf(offset: number): number {
|
|
let lo = 0,
|
|
hi = newlinePositions.length - 1;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi + 1) >> 1;
|
|
if (newlinePositions[mid] < offset) lo = mid;
|
|
else hi = mid - 1;
|
|
}
|
|
return lo + 1;
|
|
}
|
|
|
|
// Match: `const|let|var IDENT = <something with err...>`.
|
|
// The RHS may span multiple lines (terminated by `;`); we
|
|
// don't actually care about the RHS shape past finding one
|
|
// of the err tokens.
|
|
const DECL_RE =
|
|
/\b(?:const|let|var)\s+(\w+)\s*=\s*([^;]*?(?:err\.message|String\(\s*err\s*\)|err\.toString\(\s*\))[^;]*?);/g;
|
|
|
|
let m: RegExpExecArray | null;
|
|
while ((m = DECL_RE.exec(src)) !== null) {
|
|
const varName = m[1];
|
|
const declStart = m.index;
|
|
const declEnd = declStart + m[0].length;
|
|
|
|
// Skip explicit allowlist annotations.
|
|
const declLine = src.slice(Math.max(0, declStart - 80), Math.min(src.length, declEnd + 80));
|
|
if (/smoke-ok-raw-local/.test(declLine)) continue;
|
|
|
|
// Find the enclosing block. Walk forward from declEnd until
|
|
// we hit either matching `}` or end of file. Track brace
|
|
// depth.
|
|
let depth = 1;
|
|
let scanEnd = declEnd;
|
|
for (let i = declEnd; i < src.length; i++) {
|
|
if (src[i] === '{') depth++;
|
|
else if (src[i] === '}') {
|
|
depth--;
|
|
if (depth <= 0) {
|
|
scanEnd = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Look for references to varName in [declEnd, scanEnd).
|
|
const region = src.slice(declEnd, scanEnd);
|
|
const refRe = new RegExp(`\\b${varName}\\b`, 'g');
|
|
let refM: RegExpExecArray | null;
|
|
let nonConsoleUse = false;
|
|
while ((refM = refRe.exec(region)) !== null) {
|
|
// Look back ~80 chars for the start of the call/statement
|
|
const lookBack = region.slice(Math.max(0, refM.index - 80), refM.index);
|
|
// If the most recent `console.<word>(` is unclosed (open
|
|
// paren not followed by close before our position), we're
|
|
// inside a console call. Cheap approximation: check if
|
|
// the lookback contains console.* and no `;`/`}` between.
|
|
const mc = lookBack.match(/\bconsole\.\w+\s*\(([^)]*)$/);
|
|
if (mc) continue; // inside a console call
|
|
nonConsoleUse = true;
|
|
break;
|
|
}
|
|
if (!nonConsoleUse) continue;
|
|
|
|
const lineNum = lineOf(declStart);
|
|
const relPath = path.relative(REPO_ROOT, absPath);
|
|
const locationKey = `${relPath}:${lineNum}`;
|
|
if (ALLOWLIST_LOCATIONS.has(locationKey)) continue;
|
|
|
|
const preview = m[0].replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
hits.push({ file: relPath, line: lineNum, text: preview });
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
// ─── Run ──────────────────────────────────────────────────────────
|
|
scenario('apps/web/src/routes + lib/components: no raw err.message → UI state', () => {
|
|
const allHits: Hit[] = [];
|
|
for (const dir of SCAN_DIRS) {
|
|
for (const file of walk(dir)) {
|
|
const rel = path.relative(REPO_ROOT, file);
|
|
if (EXCLUDE_PATH_PATTERNS.some((rx) => rx.test(rel))) continue;
|
|
allHits.push(...detectRawException(file));
|
|
}
|
|
}
|
|
if (allHits.length > 0) {
|
|
const sample = allHits
|
|
.map((h) => `\n ${h.file}:${h.line}: ${JSON.stringify(h.text)}`)
|
|
.join('');
|
|
throw new Error(
|
|
`found ${allHits.length} raw-exception-to-UI site(s). ` +
|
|
'Replace with: ' +
|
|
'`console.warn("[where] failed:", err); errorMsg = $_("specific.localized.key");`. ' +
|
|
`Hits:${sample}`
|
|
);
|
|
}
|
|
});
|
|
|
|
scenario('apps/web/src/routes + lib/components: no raw err.message via local var', () => {
|
|
const allHits: Hit[] = [];
|
|
for (const dir of SCAN_DIRS) {
|
|
for (const file of walk(dir)) {
|
|
const rel = path.relative(REPO_ROOT, file);
|
|
if (EXCLUDE_PATH_PATTERNS.some((rx) => rx.test(rel))) continue;
|
|
allHits.push(...detectRawExceptionViaLocal(file));
|
|
}
|
|
}
|
|
if (allHits.length > 0) {
|
|
const sample = allHits
|
|
.map((h) => `\n ${h.file}:${h.line}: ${JSON.stringify(h.text)}`)
|
|
.join('');
|
|
throw new Error(
|
|
`found ${allHits.length} raw-exception-via-local site(s). ` +
|
|
'Pattern: `const raw = err.message; uiState = raw...`. ' +
|
|
'Replace with: ' +
|
|
'`console.warn("[where] failed:", err); errorMsg = $_("specific.localized.key");`. ' +
|
|
'If a local var holding raw err text is used ONLY in console.* ' +
|
|
'calls, annotate the declaration line with a `// smoke-ok-raw-local` comment. ' +
|
|
`Hits:${sample}`
|
|
);
|
|
}
|
|
});
|
|
|
|
/** Third detector — catches the indexer-result raw-message leak:
|
|
*
|
|
* const r = await getOrderbook(...);
|
|
* if (!r.ok) {
|
|
* errorMessage = r.message; // ← raw English from indexer-client
|
|
* }
|
|
*
|
|
* Heuristic: any assignment of the shape `\w+ = (\w+)\.message;` where
|
|
* the RHS variable is one of the conventional indexer-result names
|
|
* (`r`, `res`, `result`). Skips local `const`/`let` declarations.
|
|
*
|
|
* Trade-offs:
|
|
* - True positives: the orderbook/listing-detail/profile/operators
|
|
* pattern.
|
|
* - False positives: legitimate uses where `result.message` IS
|
|
* pre-localized — flag with `// smoke-ok-result-message` comment
|
|
* on the line.
|
|
*/
|
|
function detectRawResultMessage(absPath: string): readonly Hit[] {
|
|
const src = readFileSync(absPath, 'utf8');
|
|
const hits: Hit[] = [];
|
|
const newlinePositions: number[] = [-1];
|
|
for (let i = 0; i < src.length; i++) {
|
|
if (src[i] === '\n') newlinePositions.push(i);
|
|
}
|
|
function lineOf(offset: number): number {
|
|
let lo = 0,
|
|
hi = newlinePositions.length - 1;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi + 1) >> 1;
|
|
if (newlinePositions[mid] < offset) lo = mid;
|
|
else hi = mid - 1;
|
|
}
|
|
return lo + 1;
|
|
}
|
|
|
|
// Match: `(\w+)\s*=\s*(r|res|result)\.message`
|
|
// Excludes:
|
|
// - declarations: `const X = result.message`
|
|
// - the property setter: `result.message = ...`
|
|
const PAT = /\b(\w+)\s*=\s*(r|res|result|be|err)\b\s*\.\s*message\b/g;
|
|
|
|
let m: RegExpExecArray | null;
|
|
while ((m = PAT.exec(src)) !== null) {
|
|
// Skip if RHS rooted in a property access like `foo.bar.message`
|
|
// (need to look back for the dot). We handle by checking the
|
|
// char immediately before the matched root identifier.
|
|
const rootStart = m.index + m[0].indexOf(m[2]);
|
|
const charBefore = rootStart > 0 ? src[rootStart - 1] : '';
|
|
if (charBefore === '.') continue;
|
|
|
|
// Find the LHS name (m[1]); look back to the start of statement
|
|
// to see if it's a `const`/`let`/`var` declaration.
|
|
let stmtStart = m.index;
|
|
while (stmtStart > 0) {
|
|
const c = src[stmtStart - 1];
|
|
if (c === ';' || c === '{' || c === '\n') break;
|
|
stmtStart--;
|
|
}
|
|
const lhsContext = src.slice(stmtStart, m.index);
|
|
if (/\b(?:const|let|var)\s+\w*$/.test(lhsContext.trimEnd())) continue;
|
|
// Skip object-literal context: `{ message: r.message, ...}`
|
|
// — these are usually wire-payload constructions, not UI leaks.
|
|
// Heuristic: the LHS identifier in lhsContext ends with `:`
|
|
// rather than `=` indicates an object-literal property.
|
|
// (Our PAT matches `=`, but be defensive about indented styles.)
|
|
// Skip if lhsContext contains a `{` without a closing `}`.
|
|
const opens = (lhsContext.match(/\{/g) ?? []).length;
|
|
const closes = (lhsContext.match(/\}/g) ?? []).length;
|
|
if (opens > closes) continue;
|
|
// Skip if lhsContext clearly is inside a function call args
|
|
// (open `(` not closed yet).
|
|
const opens2 = (lhsContext.match(/\(/g) ?? []).length;
|
|
const closes2 = (lhsContext.match(/\)/g) ?? []).length;
|
|
if (opens2 > closes2) continue;
|
|
|
|
// Skip explicit allowlist annotation.
|
|
const lineStartIdx = src.lastIndexOf('\n', m.index) + 1;
|
|
const lineEndIdx = src.indexOf('\n', m.index);
|
|
const fullLine = src.slice(lineStartIdx, lineEndIdx === -1 ? src.length : lineEndIdx);
|
|
if (/smoke-ok-result-message/.test(fullLine)) continue;
|
|
|
|
const lineNum = lineOf(m.index);
|
|
const relPath = path.relative(REPO_ROOT, absPath);
|
|
const locationKey = `${relPath}:${lineNum}`;
|
|
// Robust allowlist: also try varname (m[1] is the LHS).
|
|
const lhsName = m[1] ?? '';
|
|
const lhsKey = lhsName ? `${relPath}:${lhsName}` : '';
|
|
if (ALLOWLIST_LOCATIONS.has(locationKey) || (lhsKey && ALLOWLIST_LOCATIONS.has(lhsKey))) {
|
|
continue;
|
|
}
|
|
|
|
hits.push({ file: relPath, line: lineNum, text: fullLine.trim().slice(0, 120) });
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
scenario('apps/web/src/routes + lib/components: no raw r.message → UI state', () => {
|
|
const allHits: Hit[] = [];
|
|
for (const dir of SCAN_DIRS) {
|
|
for (const file of walk(dir)) {
|
|
const rel = path.relative(REPO_ROOT, file);
|
|
if (EXCLUDE_PATH_PATTERNS.some((rx) => rx.test(rel))) continue;
|
|
allHits.push(...detectRawResultMessage(file));
|
|
}
|
|
}
|
|
if (allHits.length > 0) {
|
|
const sample = allHits
|
|
.map((h) => `\n ${h.file}:${h.line}: ${JSON.stringify(h.text)}`)
|
|
.join('');
|
|
throw new Error(
|
|
`found ${allHits.length} raw-result-message-to-UI site(s). ` +
|
|
'Pattern: `errorMessage = r.message` where `r` is an ' +
|
|
'indexer-client result. These leak raw English text. ' +
|
|
'Replace with: ' +
|
|
'`console.warn("[where]", r.message); errorMessage = $_("specific.localized.key");`. ' +
|
|
'For pre-localized result.message values, annotate with ' +
|
|
'`// smoke-ok-result-message`. ' +
|
|
`Hits:${sample}`
|
|
);
|
|
}
|
|
});
|
|
|
|
// ─── Summary ──────────────────────────────────────────────────────
|
|
console.log(`\n${'─'.repeat(54)}`);
|
|
if (failures === 0) {
|
|
console.log(`✓ all ${scenarios} scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${failures}/${scenarios} scenarios failed`);
|
|
process.exit(1);
|
|
}
|