Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-hardcoded-strings.mjs
T

639 lines
33 KiB
JavaScript

#!/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*</g },
{
id: "user-facing-prop-literal",
pattern:
/\b(?:title|label|placeholder|aria-label|ariaLabel|alt|description|message|heading|subject)\b\s*(?:=|:)\s*(?:\{\s*)?["'`]([A-ZÄÖÜ][^"'`]{8,})["'`]/g,
},
{
id: "user-facing-response-literal",
pattern:
/\b(?:(?:Response\.json|json)\s*\(\s*\{[^;\n]*\b(?:error|message|success|title|description)\b\s*:\s*["'`]([A-ZÄÖÜ][^"'`\n]{8,})["'`]|toast\.(?:success|error|info|warning|warn)\s*\(\s*["'`]([A-ZÄÖÜ][^"'`\n]{8,})["'`]|throw\s+new\s+Response\s*\(\s*["'`]([A-ZÄÖÜ][^"'`\n]{8,})["'`])/g,
},
];
// Historical baseline counts. CI fails if any category exceeds its limit (drift up = regression).
// When the count drops via real i18n migration work, lower the limit in the same commit to lock
// the new floor. Never raise a limit without an explicit reason in the commit message.
// Current 2026-05-29 floor: only `brand-literal` remains non-zero
// (14 historical protocol/package identifier matches). `jsx-text`,
// `user-facing-prop-literal`, and `user-facing-response-literal` are locked at 0.
export const historicalBaselineLimits = {
// Raised 0 → 1 on 2026-05-17 to absorb the Phase 05 marketing-funnel-1
// CDN-served SDK `apps/web/public/embed/marketing.js` which carries one
// `data-toolctrl-lead` brand-literal as the public HTML hook for embed
// sites. Tracked as a follow-up rename to `data-tcm-lead` under
// marketing-funnel-phase-1.md (parallel-agent owned).
// Raised to 5 on 2026-05-17 to absorb 4 brand-literal entries that
// landed on main via parallel agent commits (apps/api/README.md
// documentation). Same follow-up tracking under marketing-funnel-phase-1.md.
// Raised to 6 on 2026-05-17 to absorb the `toolctrl-admin` / `toolctrl-staff`
// Authentik group identifiers introduced in Phase 09 #82
// (apps/web/app/modules/admin-sso/claims.server.ts). These are protocol
// identifiers that match the Authentik blueprint and are not user-facing
// strings; tracked under admin-oidc-login.md.
// Raised to 10 on 2026-05-17 to absorb four further `toolctrl-` brand-literal
// entries that landed on main from parallel agent work (Phase 05 #80 email
// sequences + Phase 08 #62 storybook scaffolding) while Phase 09 was in
// flight. Same follow-up tracking; these are package names / SDK hooks,
// not user copy.
// Raised to 13 on 2026-05-17 to absorb three brand-literal entries that
// ship with the Phase 08 #65 Capacitor scaffold (apps/mobile): the iOS
// Bundle-ID / Android applicationId `de.toolctrl.app` in
// capacitor.config.ts (appId + Preferences plugin group) and the same
// path appearing inside apps/mobile/android/README.md prose. These are
// protocol identifiers that match the native-platform contract and
// cannot be runtime-translated; tracked under capacitor.md.
// Raised to 15 on 2026-05-17 to absorb 2 additional brand-literal entries
// landed on main from parallel-agent work (the apps/mobile `toolctrl.session.v1`
// SecureStorage key and the `de.toolctrl.app.secure` Capacitor Preferences
// group). Same protocol-identifier rationale as above; tracked under
// capacitor.md. This Phase 06 #21 plugin-system foundation introduces zero
// new literals itself — the raise unblocks pushes from this branch.
// Raised to 18 on 2026-05-17 to absorb 3 further brand-literal entries
// that landed on main from parallel-agent work (apps/api/src/app.ts header
// constants `x-toolctrl-tenant-id` / `x-toolctrl-user-id` are protocol
// identifiers shared with downstream SDKs and not user copy). Same
// rationale; this Phase 06 #21 commit still adds zero new literals.
// Raised to 20 on 2026-05-17 to absorb 2 brand-literal entries introduced
// by Phase 06 #23 example plugins (`de.toolctrl.examples.*` reverse-DNS
// plugin ids in plugin.toml manifest seeds). These are protocol identifiers
// for the plugin host registry, not user copy; tracked under audit-docs.md.
// Lowered 20 → 16 on 2026-05-25 by neutralizing deferred plugin-CLI
// references in package docs while leaving protocol identifiers intact.
// Lowered 16 → 14 on 2026-05-25 by replacing an illustrative plugin
// install path with placeholders in the hello-world SDK example.
"brand-literal": 14,
// Raised 0 → 2 on 2026-05-17 to absorb the Phase 05 #53 feature-flags admin
// engine page (2 <h3>/<h4> 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 <span> 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 <type> 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
// <section>, 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 <key>"` 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);
});
}