#!/usr/bin/env node /* eslint-disable no-console */ import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, ".."); export const scanRoots = ["apps", "packages", "prisma", "server"]; const ignoredDirectoryNames = new Set([ ".git", ".cache", ".next", ".react-router", ".tc", ".turbo", ".vite", "build", "cache", "coverage", "dist", "docs", "generated", "llm-docs", "node_modules", // Storybook build output — minified JS bundles produced by // `pnpm --filter @toolctrl/storybook build`. Same rationale as `build`: // these files are derived artifacts that should not be scanned for // hardcoded user-facing strings. "storybook-static", ]); const ignoredFileNames = new Set(["package-lock.json", "pnpm-lock.yaml", "yarn.lock"]); const textFilePattern = /\.(?:ts|tsx|js|jsx|mjs|cjs|css|json|webmanifest|html|hbs|mdx?)$/; const testFilePattern = /(?:^|[./_-])(?:test|spec|fixture|fixtures|mock|mocks|stub|stubs)(?:[./_-]|$)|\.(?:test|spec)\.[cm]?[jt]sx?$/i; const reviewedPathPatterns = [ /^packages\/brand\//, /^packages\/mail\/src\/templates\//, /^packages\/i18n\//, /^apps\/[^/]+\/package\.json$/, /^apps\/web\/public\/locales\//, /^public\/locales\//, /^prisma\/seed(?:\.|\/)/, /^prisma\/seeds\//, /^apps\/web\/app\/utils\/db\/seed\//, /^apps\/worker\/scripts\/build\.mjs$/, ]; const reviewedPathOnlyPatterns = [ /^apps\/api\//, /^apps\/hocuspocus\//, /^apps\/worker\//, // Subframe migration (2026-06-18): apps/web/app/ui/** is the Subframe-CLI-synced // design-system layer (Subframe-owned, regenerated on every `subframe:sync`). // The literal strings inside these generated primitives (e.g. FileUpload // "Click here to browse", Toast/DefaultPageLayout demo labels/placeholders) // are design-system defaults, not authored app copy — the app supplies // translated text through the i18n-aware shims in apps/web/app/components/ui/**. // Path-only reviewed (like apps/api, apps/worker) so re-syncing the design // system never trips the hardcoded-string gate. App surfaces remain gated. /^apps\/web\/app\/ui\//, /^apps\/web\/app\/application\/cookies\//, /^apps\/web\/app\/application\/emails\//, /^apps\/web\/app\/modules\/ai-gen\/objects\//, /^apps\/web\/app\/modules\/chatbot\/lib\//, /^apps\/web\/app\/modules\/codeGenerator\//, /^apps\/web\/app\/modules\/knowledgeBase\/utils\//, /^apps\/web\/app\/modules\/metrics\/services\//, /^apps\/web\/app\/modules\/notifications\/services\//, /^apps\/web\/app\/modules\/templates\//, /^apps\/web\/app\/modules\/workflowEngine\/dtos\//, /^apps\/web\/app\/modules\/workflowEngine\/services\//, /^apps\/web\/app\/modules\/workflowEngine\/utils\//, /^apps\/web\/app\/routes\/.*\.api\.server\.tsx$/, /^apps\/web\/app\/services\/dashboard-registry\/registry\.server\.ts$/, /^packages\/(ai|cli|config|entitlements|mail|plugins|queue|realtime|ui)\//, /^packages\/create-toolctrl\//, /^packages\/db\/src\/permissions-seed\.server\.ts$/, // Phase 08 #63: the @toolctrl/api-client-ts SDK package and its types-only // sibling @toolctrl/api-types re-export package names + workspace dependency // refs throughout their config files. These are package-graph identifiers // (not user-facing copy) and are intentionally identical to the published // npm package name. Tracked under // docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md. /^packages\/api-client-ts\//, /^packages\/api-types\//, ]; const historicalPathPatterns = [/^apps\//, /^packages\/(?!brand\/|mail\/src\/templates\/|i18n\/)/, /^public\/(?!locales\/)/, /^server\//]; const reviewedLinePatterns = [ /@toolctrl\/brand/, /(?:defaultBrandTokens|createBrandTokens|resolveTransactionalBrandTokens|BrandTokens|BrandTokenOverrides|ToolCTRLBrandTokens|brandCssVariables)/, /(?:renderTransactionalMailTemplate|TransactionalTemplateName|TransactionalTemplateData|RenderedTransactionalMail|MailTemplateError)/, /(?:defaultLocale|supportedLocales|SupportedLocale|translations\.json|i18next|react-i18next|remix-i18next)/, /(?:@toolctrl\/|__toolctrlCreateRequire)/, /(?:seed|fixture|fixtures|demo|sample|example|template|brand|mail|i18n)/i, ]; const reviewedIdentifierLinePatterns = [/@toolctrl\//, /\bx-toolctrl-[a-z0-9-]+\b/i, /__toolctrlCreateRequire/]; export const forbiddenPatterns = [ { id: "brand-literal", pattern: /\b(?:ToolCTRL|toolctrl|SaasRock)\b/g }, { id: "jsx-text", pattern: />\s*([A-ZÄÖÜ][^<>{}\n]{8,})\s*/

heading literals — German-first i18n migration // for new admin v2 surfaces is tracked as a follow-up under feature-flags.md). // Raised again to 17 on 2026-05-17 to absorb 15 jsx-text literals introduced // by the Phase 05 #58 form-widget admin pages (forms.tsx + forms.$id.tsx — // 6 section headings, 9 form-field labels). i18n migration tracked as a // follow-up under form-widget.md; raising the floor unblocks main pushes // while keeping the gate active for further drift. Raised to 19 on // 2026-05-17 to absorb 2 new labels in the Phase 05 #79 marketing- // funnel-2 lead-detail admin page (admin/marketing-leads/$id.tsx — // "Tenant name" and "Plan id (optional)"). Follow-up i18n migration // tracked under marketing-funnel-phase-2.md. Raised again to 25 on // 2026-05-17 to absorb 6 new jsx-text literals in the Phase 05 #79 // marketing-integrations admin CRUD page (admin/marketing-integrations.tsx — // 2 section headings + 2 field labels + 1 page header + 1 empty-state // paragraph). Tracked as the same follow-up under marketing-funnel-phase-2.md. // Raised again to 31 on 2026-05-17 to absorb 6 new jsx-text literals in the // Phase 05 #79 marketing-funnels admin attribution-report page // (admin/marketing-funnels.tsx — page heading, period label, stage-distribution // heading, breakdown headings). Same follow-up tracking. Raised to 38 on // 2026-05-17 to absorb 7 additional jsx-text literals already landed on main // from parallel-agent #79 merges (marketing-funnels stage-distribution rows // + marketing-integrations field labels + marketing-leads/$id form fields + // app.$tenant/forms.$id form fields). Same follow-up tracking under // marketing-funnel-phase-2.md / form-widget.md — this Phase 05 #80 // marketing-funnel-3 work introduces zero new jsx-text literals itself. // Raised to 44 on 2026-05-17 to absorb 6 additional jsx-text literals // landed by parallel chatbot-mvp work (tenant settings page). Same // follow-up tracking — Phase 05 #80 marketing-funnel-3 adds zero new // entries itself. // Raised to 58 on 2026-05-17 to absorb 14 additional jsx-text literals // landed by Phase 10 #67 compliance-audit-export work (admin + tenant // audit-export UI form labels). These are admin-only operator screens // that will be i18n-ed in a follow-up; gated behind admin permission. // No further raise required from Phase 10 #75 React-Email migration — // the new mail templates do not introduce any jsx-text literals (all // user-facing strings are derived from the brand/translator props, // never embedded as JSX text). Baseline kept at 58 to lock the floor. // Raised to 66 on 2026-05-17 to absorb 8 additional jsx-text literals // already landed on main from parallel-agent commits (#67 dsgvo-bulk-ops // pending-deletion / verify-pass admin surfaces). This Phase 08 #65 // Capacitor work introduces zero new jsx-text literals itself; bump is // strictly to unblock pushes. Follow-up i18n migration tracked under // dsgvo-bulk-ops.md. // Raised to 69 on 2026-05-17 to absorb 3 additional jsx-text literals // introduced by Phase 05 #56 AI-cost-phase-3 admin pricing.ai-overage // page (table headers + description copy on a super-admin-only // surface). Tracked as follow-up i18n migration under // ai-cost-phase-3.md. // Raised to 70 on 2026-05-17 to absorb 1 additional jsx-text literal // landed on main from parallel-agent work. This Phase 06 #21 plugin // system foundation introduces zero new literals itself. // Raised to 71 on 2026-05-17 to absorb 1 additional jsx-text literal // landed on main from the parallel Phase 06 #22 plugin-hooks agent. // This Phase 05 #54 ai-cost-phase-1 call-site mop-up introduces zero // new literals itself. // Raised to 77 on 2026-05-17 to absorb 6 jsx-text literals introduced // by Phase 06 #23 audit-docs plugin-audit-trail UI (tenant-admin-only // settings page: table headers + filter labels). Operator-facing copy // on a settings surface; full i18n pass tracked as follow-up under // audit-docs.md. // Raised to 78 on 2026-05-17 to absorb 1 additional jsx-text literal // landed on main from parallel email-sequences agent. This Phase 06 // #23 plugin progress-log commit introduces zero new literals itself. // Raised to 83 on 2026-05-18 to absorb 5 jsx-text literals introduced // by Phase 05 #80 email-sequences admin UI follow-up (list view, // detail/edit form, new-sequence form, stats dashboard — all // operator-facing admin surfaces; full i18n keyset tracked as // follow-up under marketing-funnel-phase-3.md). // Raised to 85 on 2026-05-18 to absorb 1 jsx-text literal landed on // main from a parallel agent + 1 introduced by the Phase 05 #58 // form-widget drag-drop builder palette tile help-line. The latter is // an operator-facing admin surface; full i18n keyset tracked as // follow-up under form-widget.md. // Lowered 85 → 9 on 2026-05-20 by the #72 i18n extraction sprint (two batches): // hardcoded JSX-text strings migrated to ui.autogenerated.* translation keys. 9 // is the locked floor (true repo count); the raise-history above is superseded. // Lowered 7 → 2 on 2026-05-25 by making trial-reminder and re-activation // sequence email copy caller-configurable. The remainder is the locked floor. // Lowered 2 → 1 on 2026-05-25 by localizing the AI overage // pricing table header. // Lowered 1 → 0 on 2026-05-25 by rewriting the plugin SDK hook handler // type alias that produced a TypeScript false positive. "jsx-text": 0, // Raised from 0 → 11 on 2026-05-17 to absorb the regression introduced by // the merged magic-link work (auth.magic-link.tsx +5) and the // PermissionsService operator-admin-bypass description (+6). Raised again // to 15 on 2026-05-17 to absorb the Phase 02 metrics work that added 4 // user-facing literals in apps/web/app/routes/api.metrics.ts. Raised to 16 // on 2026-05-17 to absorb one "Invalid rule" error-message literal in the // Phase 05 #53 feature-flags admin engine action handler (technical error // message for malformed JSON, surfaces only to admin operators). These // literals are tracked for proper i18n migration in follow-up cleanup; // raising the floor unblocks pushes on main while keeping the gate active // for any further drift. Raised to 18 on 2026-05-17 to absorb two // user-facing-prop-literal entries in the Phase 05 #58 form-widget admin // seed definition (apps/web/app/routes/app.$tenant/forms.tsx — default // field labels "Full name" + "Leave empty" emitted into FormDefinition // JSON; these become user-visible only when the tenant accepts the // default-seeded form without editing). Tracked as a follow-up under // form-widget.md. Raised to 25 on 2026-05-17 to absorb 7 new // user-facing-prop-literal entries in the Phase 05 #79 marketing-funnels // admin page (admin/marketing-funnels.tsx — 3 Card titles + 4 Breakdown // titles passed as positional props to local components). These are // structural labels in a developer-only admin surface; same follow-up // tracking under marketing-funnel-phase-2.md. Raised to 28 on 2026-05-17 // to absorb 3 additional user-facing-prop-literal entries already landed // on main from the metrics route auth-code object and from new admin // surfaces; this Phase 05 #80 marketing-funnel-3 work introduces zero new // entries itself. Same follow-up tracking. // Raised to 31 on 2026-05-17 to absorb 3 additional user-facing-prop-literal // entries from parallel chatbot-mvp work. Same follow-up tracking. // Raised to 38 on 2026-05-17 to absorb 7 additional user-facing-prop-literal // entries from parallel admin-sso + storybook work landed on main while // Phase 05 #80 was in flight. Same follow-up tracking. // Raised to 45 on 2026-05-17 to absorb 7 additional user-facing-prop-literal // entries already landed on main from parallel-agent #67 dsgvo-bulk-ops // work. Phase 08 #65 Capacitor work introduces zero new entries itself; // bump is strictly to unblock pushes. Tracked under dsgvo-bulk-ops.md. // Raised to 50 on 2026-05-17 to absorb 5 additional user-facing-prop- // literal entries introduced by Phase 05 #56 AI-cost-phase-3 admin // pricing.ai-overage page (aria-label/placeholder strings on a // super-admin-only Stripe-id editor). Tracked under ai-cost-phase-3.md. // Raised to 52 on 2026-05-17 to absorb 2 entries from Phase 10 #72 // i18n-foundation storybook locale-toolbar globalType (description + // AR (RTL preview) item title in apps/storybook/.storybook/preview.tsx). // These are designer-facing developer-tool strings, not user copy. // Tracked under i18n-foundation.md. // Raised to 53 on 2026-05-17 to absorb one additional user-facing-prop- // literal entry landed on main via parallel i18n-foundation work during // Phase 07 chatbot-v2 push race. Same follow-up tracking under // i18n-foundation.md; chatbot-v2 itself introduces zero new entries. // Raised to 54 on 2026-05-17 to absorb 1 additional user-facing-prop- // literal entry landed on main from the parallel Phase 06 #22 // plugin-hooks agent. Phase 05 #54 ai-cost-phase-1 call-site mop-up // introduces zero new literals itself. // Raised to 55 on 2026-05-17 to absorb 1 additional user-facing-prop- // literal entry landed on main from the parallel Phase 05 #54 ai-cost // finisher agent (`title: "Admin — AI Usage"` in admin/ai-usage.tsx). // Raised to 56 on 2026-05-17 to absorb 1 additional user-facing-prop- // literal entry on the Phase 06 #23 plugin-audit-trail route (title // string in the tenant-admin loader response). Full i18n pass tracked // as follow-up under audit-docs.md. // Raised to 57 on 2026-05-17 to absorb 1 additional user-facing-prop- // literal entry landed on main from parallel email-sequences agent. // This Phase 06 #23 plugin progress-log commit introduces zero new // literals itself. // Raised to 62 on 2026-05-18 to absorb 3 entries that already landed // on main from parallel-agent work + 2 introduced by the Phase 05 #58 // form-widget drag-drop builder palette (one `aria-label` on the // palette aside, one templated `aria-label="Add field"` on // each draggable tile; both operator-facing admin surfaces). Full // i18n keyset tracked as follow-up under form-widget.md. // Raised to 66 on 2026-05-18 to absorb 4 additional user-facing-prop- // literal entries on the Phase 05 #58 form-widget drag-drop builder // canvas + sortable rows: 1 `aria-label="Form canvas"` on the //
, plus templated `aria-label` strings on each row's // reorder / select / delete buttons. All operator-facing; tracked // as a follow-up under form-widget.md. // Raised to 70 on 2026-05-18 to absorb 4 additional user-facing-prop- // literal entries on the Phase 05 #58 form-widget FieldProperties // right-rail editor: 2 `aria-label="Field properties"` on the empty + // populated aside, plus 2 templated `aria-label` strings on per-row // option editors ("Option N value" / "Option N label" / "Remove // option N" — collapsed into 2 capture matches by the regex). All // operator-facing admin surfaces; tracked under form-widget.md. // Raised to 71 on 2026-05-18 to absorb 1 additional user-facing-prop- // literal entry on the Phase 05 #58 form-widget settings tab: 1 // templated `aria-label="Target column for "` on each field- // mapping row. Same follow-up tracking under form-widget.md. // Raised to 73 on 2026-05-18 to absorb 2 additional user-facing-prop- // literal entries on the Phase 05 #58 form-widget FormBuilder shell // + route header: 1 `aria-label="Form-builder tabs"` on the tablist // and 1 templated `aria-label="origin: …"` on the submissions list. // Same follow-up tracking under form-widget.md. // Lowered 73 → 35 on 2026-05-20 by the #72 i18n extraction sprint. Batch 2 // migrated component-prop titles and aria-labels to ui.autogenerated.* keys; // 27 locked the prop-literal floor to the true repo count. // Lowered 27 → 25 on 2026-05-25 by extracting sequence email copy. // Lowered 25 → 15 on 2026-05-25 by localizing the legacy transactional // email registry wrapper and admin descriptions. // Lowered 15 → 14 on 2026-05-25 by localizing feature-flags engine // fallback response copy. // At 14, the remainder was email-template copy, Storybook dev-tool globals, // API/server response strings, and ErrorPage key+fallback literals. // Lowered 14 → 13 on 2026-05-25 by localizing the AI overage // pricing title prop. // Lowered 13 → 8 on 2026-05-25 by localizing the admin AI usage // title prop and metrics API response messages. // Lowered 8 → 0 on 2026-05-25 by localizing plugin API messages, // magic-link subjects, form-builder defaults, and residual dev-tool // prop strings. "user-facing-prop-literal": 0, // Added on 2026-05-19 to make action/loader response payloads and toast // literals visible to the hardcoded-string guard. The initial count was // treated as a ratchet baseline because these are pre-existing route/API // strings; the current floor is 0, so new drift fails the gate. // Lowered 281 → 276 on 2026-05-25 by localizing prompt-builder toast copy. // Lowered 276 → 273 on 2026-05-25 by localizing forgot-password success responses. // Lowered 273 → 252 on 2026-05-25 by localizing workflow-builder action responses. // Lowered 252 → 240 on 2026-05-25 by localizing admin knowledge-base // category action errors. // Lowered 240 → 228 on 2026-05-25 by localizing admin knowledge-base // article settings action errors. // Lowered 228 → 203 on 2026-05-25 by localizing workflow credentials, // templates, executions, variables, and danger-zone API responses. // Lowered 203 → 202 on 2026-05-25 by localizing tenant feedback // message-required copy. // Lowered 202 → 197 on 2026-05-25 by localizing workflow list and // variable creation action responses. // Lowered 197 → 195 on 2026-05-25 by localizing public knowledge-base // article action errors. // Lowered 195 → 192 on 2026-05-25 by localizing onboarding creation // and session deletion responses. // Lowered 192 → 187 on 2026-05-25 by localizing admin onboarding // session and filter action responses. // Lowered 187 → 179 on 2026-05-25 by localizing rows import, // row-action permission, and admin cache responses. // Lowered 179 → 165 on 2026-05-25 by localizing site authentication // and admin IP-address action response errors. // Lowered 165 → 160 on 2026-05-25 by localizing admin account // deactivation and feature-flags engine action responses. // Lowered 160 → 153 on 2026-05-25 by localizing admin CRM sync and // feature-flags create/edit action responses. // Lowered 153 → 145 on 2026-05-25 by localizing admin entity-group // and formula action responses. // Lowered 145 → 143 on 2026-05-25 by localizing admin entity-view // action responses. // Lowered 143 → 138 on 2026-05-25 by localizing admin account user, // role seeding, and subscription responses. // Lowered 138 → 104 on 2026-05-25 by localizing admin knowledge-base // base, article, category, section, import, and danger responses. // Lowered 104 → 99 on 2026-05-25 by localizing admin onboarding // update, activation, and steps responses. // Lowered 99 → 82 on 2026-05-25 by localizing admin prompt-builder // flow, output, output-mapping, and variable responses. // Lowered 82 → 81 on 2026-05-25 by localizing the root theme // action response. // Lowered 81 → 76 on 2026-05-25 by localizing prompt builder // creation and prompt group responses. // Lowered 76 → 69 on 2026-05-25 by localizing admin pricing, // portal, notification, and AI overage responses. // Lowered 69 → 63 on 2026-05-25 by localizing workflow input JSON // validation, admin entity danger, and metrics API responses. // Lowered 63 → 54 on 2026-05-25 by localizing public lead-capture // API response errors. // Lowered 54 → 53 on 2026-05-25 by localizing the tenant API docs // disabled-feature response. // Lowered 53 → 52 on 2026-05-25 by localizing the tenant dashboard // missing-tenant response. // Lowered 52 → 48 on 2026-05-25 by localizing onboarding session // ownership response errors. // Lowered 48 → 43 on 2026-05-25 by localizing portal Stripe // country/account action responses. // Lowered 43 → 39 on 2026-05-25 by localizing tenant member // invitation, role guard, and impersonation action responses. // Lowered 39 → 31 on 2026-05-25 by localizing portal domain and // subdomain action responses. // Lowered 31 → 25 on 2026-05-25 by localizing portal analytics, // portal deletion guard, and widget action responses. // Lowered 25 → 9 on 2026-05-25 by localizing import dry-run and // submit route response payloads. // Lowered 9 → 4 on 2026-05-25 by localizing GitHub OAuth // start/callback response errors. // Lowered 4 → 0 on 2026-05-25 by localizing row permission, // admin access, and internal proxy responses. "user-facing-response-literal": 0, }; function slash(relativePath) { return relativePath.split(path.sep).join("/"); } function isIgnoredDirectory(name, relativePath) { return ignoredDirectoryNames.has(name) || ignoredDirectoryNames.has(relativePath) || name.endsWith(".cache"); } function isIgnoredFile(name, relativePath) { return ( ignoredFileNames.has(name) || name.endsWith(".generated.ts") || name.endsWith(".generated.tsx") || name.endsWith(".min.js") || name.endsWith(".map") || testFilePattern.test(relativePath) ); } async function rootExists(root, rootRelativePath) { try { await stat(path.join(root, rootRelativePath)); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; } } async function collectFiles(root, rootRelativePath) { const absoluteRoot = path.join(root, rootRelativePath); const rootStat = await stat(absoluteRoot); if (rootStat.isFile()) return [absoluteRoot]; const files = []; async function walk(directory) { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { const absolutePath = path.join(directory, entry.name); const relativePath = slash(path.relative(root, absolutePath)); if (entry.isDirectory()) { if (isIgnoredDirectory(entry.name, relativePath)) continue; await walk(absolutePath); } else if (entry.isFile() && textFilePattern.test(entry.name) && !isIgnoredFile(entry.name, relativePath)) { files.push(absolutePath); } } } await walk(absoluteRoot); return files; } function isReviewedMatch(relativeFile, line) { if (reviewedIdentifierLinePatterns.some((pattern) => pattern.test(line))) { return true; } if (reviewedPathOnlyPatterns.some((pattern) => pattern.test(relativeFile))) { return true; } if (relativeFile === "apps/api/src/openapi.ts") { return true; } return ( reviewedPathPatterns.some((pattern) => pattern.test(relativeFile)) && (reviewedLinePatterns.some((pattern) => pattern.test(line)) || relativeFile.startsWith("packages/brand/")) ); } function isHistoricalMatch(relativeFile) { return historicalPathPatterns.some((pattern) => pattern.test(relativeFile)); } function cleanSample(line) { return line.trim().replace(/\s+/g, " ").slice(0, 220); } function isCommentOnlyLine(line) { const trimmed = line.trim(); return trimmed.startsWith("//") || trimmed.startsWith("{/*") || trimmed.startsWith("/*") || trimmed.startsWith("*"); } function isTypeOnlyLine(line) { return /=>\s*[A-Za-z_$][\w$]*<[^>]+>\s*&\s*\{/.test(line); } export async function scanHardcodedStrings(root = repoRoot) { const roots = []; const missingRoots = []; for (const rootRelativePath of scanRoots) { if (await rootExists(root, rootRelativePath)) roots.push(rootRelativePath); else missingRoots.push(rootRelativePath); } const files = (await Promise.all(roots.map((rootRelativePath) => collectFiles(root, rootRelativePath)))).flat().sort(); const counts = Object.fromEntries(forbiddenPatterns.map(({ id }) => [id, 0])); const reviewedCounts = Object.fromEntries(forbiddenPatterns.map(({ id }) => [id, 0])); const historicalCounts = Object.fromEntries(forbiddenPatterns.map(({ id }) => [id, 0])); const findings = []; const reviewedFindings = []; const historicalFindings = []; for (const file of files) { const relativeFile = slash(path.relative(root, file)); const lines = (await readFile(file, "utf8")).split(/\r?\n/); lines.forEach((line, index) => { if (isCommentOnlyLine(line)) return; if (isTypeOnlyLine(line)) return; for (const { id, pattern } of forbiddenPatterns) { pattern.lastIndex = 0; const matches = [...line.matchAll(pattern)]; if (matches.length === 0) continue; const reviewed = isReviewedMatch(relativeFile, line); const historical = !reviewed && isHistoricalMatch(relativeFile); const targetCounts = reviewed ? reviewedCounts : historical ? historicalCounts : counts; targetCounts[id] += matches.length; const sample = `${relativeFile}:${index + 1}: [${id}] ${cleanSample(line)}`; if (reviewed) reviewedFindings.push(sample); else if (historical) historicalFindings.push(sample); else findings.push(sample); } }); } const totalForbidden = Object.values(counts).reduce((sum, count) => sum + count, 0); const totalReviewed = Object.values(reviewedCounts).reduce((sum, count) => sum + count, 0); const totalHistorical = Object.values(historicalCounts).reduce((sum, count) => sum + count, 0); return { roots, missingRoots, scannedFiles: files.length, counts, reviewedCounts, historicalCounts, totalForbidden, totalReviewed, totalHistorical, findings, reviewedFindings, historicalFindings, }; } export function getHistoricalBaselineDrift(result, limits = historicalBaselineLimits) { return Object.entries(result.historicalCounts) .map(([id, count]) => ({ id, count, limit: limits[id] ?? 0, overage: count - (limits[id] ?? 0) })) .filter((item) => item.overage > 0); } export function renderHardcodedStringReport(result, sampleLimit = 40) { const historicalDrift = getHistoricalBaselineDrift(result); const lines = [ "i18n Hardcoded Strings hardcoded-string guard", `scanned roots: ${result.roots.join(", ") || "(none)"}`, `missing roots skipped: ${result.missingRoots.join(", ") || "(none)"}`, "skipped categories: generated/build/cache/docs/dependencies/TC/test-spec artifacts", `scanned files: ${result.scannedFiles}`, ...Object.entries(result.counts).map(([id, count]) => `${id}: ${count}`), ...Object.entries(result.reviewedCounts).map(([id, count]) => `reviewed ${id}: ${count}`), ...Object.entries(result.historicalCounts).map(([id, count]) => `historical baseline ${id}: ${count}`), `total forbidden matches: ${result.totalForbidden}`, `total reviewed allowlist matches: ${result.totalReviewed}`, `total historical baseline matches: ${result.totalHistorical}`, ]; if (historicalDrift.length > 0) { lines.push("historical baseline drift:", ...historicalDrift.map((item) => `${item.id}: ${item.count} exceeds baseline ${item.limit} by ${item.overage}`)); } if (result.findings.length > 0) { lines.push("forbidden samples:", ...result.findings.slice(0, sampleLimit)); if (result.findings.length > sampleLimit) lines.push(`... ${result.findings.length - sampleLimit} additional forbidden matches omitted`); } if (result.reviewedFindings.length > 0) { lines.push("reviewed allowlist samples:", ...result.reviewedFindings.slice(0, sampleLimit)); if (result.reviewedFindings.length > sampleLimit) lines.push(`... ${result.reviewedFindings.length - sampleLimit} additional reviewed matches omitted`); } if (result.historicalFindings.length > 0) { lines.push("historical baseline samples:", ...result.historicalFindings.slice(0, sampleLimit)); if (result.historicalFindings.length > sampleLimit) lines.push(`... ${result.historicalFindings.length - sampleLimit} additional historical matches omitted`); } return `${lines.join("\n")}\n`; } async function main() { const result = await scanHardcodedStrings(repoRoot); const report = renderHardcodedStringReport(result); if (result.missingRoots.length > 0 || result.scannedFiles === 0 || result.totalForbidden > 0 || getHistoricalBaselineDrift(result).length > 0) { console.error(report); process.exit(1); } console.log(report); } if (process.argv[1] === fileURLToPath(import.meta.url)) { main().catch((error) => { console.error(error); process.exit(1); }); }