/** * cli:validate-page — execute.ts * * Eight blocking rules. Each rule is a standalone function consuming the * file content (and optional context: tsconfig paths, locale roots) and * returning Violation[]. The aggregator below runs all rules and assembles * the final report. * * Determinism: zero LLM calls, zero network. Pure regex + filesystem reads. */ import { existsSync, readFileSync } from 'node:fs' import { isAbsolute, join, relative, resolve, sep, dirname } from 'node:path' import type { ValidatePageInput, ValidatePageReport, Violation } from './types.js' import { LOCALES, extractTCalls, resolveI18nKey } from '../../../../../lib/i18n-keys.js' // 3-4 segments, app-less: `module.section[.resource].action` — the resource // grain is legal (aligned with lib/page-spec-actions.ts + related-tabs). const PERMISSION_KEY_REGEX = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*){2,3}$/ interface LocationContext { module: string | null entity: string | null view: string | null } interface RuleContext { /** Absolute path to the page file. */ pageFile: string /** Web root (parent of src/). */ projectPath: string /** Page content (utf-8). */ source: string /** Lines of the source (1-based access via `lines[i-1]`). */ lines: string[] loc: LocationContext /** tsconfig#paths map ('@' alias → absolute filesystem prefix). */ aliases: Record } // ─── Helpers ─────────────────────────────────────────────────────────────── function parseLocation(absPath: string, projectPath: string): LocationContext { const rel = relative(projectPath, absPath).split(sep).join('/') const m = rel.match( /^src\/pages\/[^/]+\/([^/]+)\/[^/]+\/([A-Z][A-Za-z0-9]+?)(List|Detail|Form|Dashboard|Create|Edit|New)Page\.tsx$/, ) if (!m) return { module: null, entity: null, view: null } return { module: m[1], entity: m[2], view: m[3].toLowerCase() } } function readTsconfigAliases(projectPath: string): Record { const candidates = ['tsconfig.json', 'tsconfig.app.json', 'tsconfig.base.json'] for (const file of candidates) { const abs = join(projectPath, file) if (!existsSync(abs)) continue try { const raw = readFileSync(abs, 'utf-8') // Strip JSON-with-comments const cleaned = raw .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/^\s*\/\/.*$/gm, '') .replace(/,\s*([}\]])/g, '$1') const parsed = JSON.parse(cleaned) const paths = parsed?.compilerOptions?.paths if (paths && typeof paths === 'object') { const out: Record = {} for (const [alias, targets] of Object.entries(paths)) { if (!Array.isArray(targets) || targets.length === 0) continue // alias is like '@/*'; target is like 'src/*'. const aliasPrefix = alias.replace(/\/\*$/, '/') const targetPrefix = String(targets[0]).replace(/\/\*$/, '/') out[aliasPrefix] = join(projectPath, targetPrefix) } if (Object.keys(out).length > 0) return out } } catch { // ignore — fall through to default } } // Default SmartStack alias. return { '@/': join(projectPath, 'src/') } } function resolveImport(spec: string, fromFile: string, ctx: RuleContext): string | null { // External package — skip. if (!spec.startsWith('.') && !spec.startsWith('@/') && !spec.startsWith('/')) return null let absBase: string if (spec.startsWith('.')) { absBase = resolve(dirname(fromFile), spec) } else if (spec.startsWith('@/')) { const aliasMatch = Object.entries(ctx.aliases).find(([prefix]) => spec.startsWith(prefix)) if (!aliasMatch) return null absBase = join(aliasMatch[1], spec.slice(aliasMatch[0].length)) } else { absBase = spec } const tries = [ absBase, `${absBase}.ts`, `${absBase}.tsx`, `${absBase}.js`, `${absBase}.jsx`, `${absBase}.json`, join(absBase, 'index.ts'), join(absBase, 'index.tsx'), join(absBase, 'index.js'), ] for (const candidate of tries) { if (existsSync(candidate)) return candidate } return null } function findLineNumber(source: string, needle: string, startFrom = 0): number | undefined { const idx = source.indexOf(needle, startFrom) if (idx < 0) return undefined let line = 1 for (let i = 0; i < idx; i++) { if (source[i] === '\n') line++ } return line } // ─── Rules ───────────────────────────────────────────────────────────────── function ruleImportsResolve(ctx: RuleContext): Violation[] { const out: Violation[] = [] const importRe = /^\s*import\s+(?:[^'"]+\sfrom\s+)?['"]([^'"]+)['"];?\s*$/gm let m: RegExpExecArray | null while ((m = importRe.exec(ctx.source)) !== null) { const spec = m[1] // External packages handled in resolveImport (returns null for non-relative non-aliased). if (!spec.startsWith('.') && !spec.startsWith('@/')) continue const resolved = resolveImport(spec, ctx.pageFile, ctx) if (resolved === null) { out.push({ rule: 'imports-resolve', severity: 'err', line: findLineNumber(ctx.source, m[0].trimStart()), message: `Import '${spec}' does not resolve to a file on disk (page: ${relative(ctx.projectPath, ctx.pageFile)}).`, suggestedFix: spec.startsWith('@/components/ui/PageTemplate') ? 'Re-run scaffold-layout — it writes src/components/ui/PageTemplate.tsx.' : spec.startsWith('@/components/auth/') || spec.startsWith('@/business/auth/') ? 'Run scaffold-frontend-auth (Phase 3.0, MANDATORY) — it emits src/components/auth/PermissionGuard.tsx and src/business/auth/useAuth.ts. NOTE its projectPath is the PROJECT ROOT + appCode (it derives web/{appCode}-web itself), unlike the other 3.0 scaffolds which take the web root.' : spec.startsWith('@/features/') ? 'Re-run scaffold-api-client for the corresponding entity to emit hooks/services/types.' : undefined, }) } } return out } function rulePageTemplateWrapping(ctx: RuleContext): Violation[] { const out: Violation[] = [] const importPattern = /import\s*\{\s*PageTemplate\s*\}\s*from\s*['"]@\/components\/ui\/PageTemplate['"]/ if (!importPattern.test(ctx.source)) { out.push({ rule: 'pagetemplate-wrapping', severity: 'err', message: `Page does not import PageTemplate from '@/components/ui/PageTemplate'. The page must use the canonical wrapper.`, suggestedFix: `Add: import { PageTemplate } from '@/components/ui/PageTemplate'`, }) } if (!/]/.test(ctx.source)) { out.push({ rule: 'pagetemplate-wrapping', severity: 'err', message: 'Page does not render . Wrap the page content in .', suggestedFix: 'Wrap the JSX root in .', }) } return out } function ruleUseParamsNullCheck(ctx: RuleContext): Violation[] { const out: Violation[] = [] const useParamsRe = /useParams<[^>]+>\s*\(\s*\)/g let m: RegExpExecArray | null while ((m = useParamsRe.exec(ctx.source)) !== null) { const lineIdx = (ctx.source.slice(0, m.index).match(/\n/g) ?? []).length const window = ctx.lines.slice(lineIdx, lineIdx + 9).join('\n') const guarded = /if\s*\(\s*!?\s*id\s*\)/.test(window) || /if\s*\(\s*id\s*===\s*undefined\s*\)/.test(window) if (!guarded) { out.push({ rule: 'useparams-null-check', severity: 'err', line: lineIdx + 1, message: `useParams<…>() is not null-checked within 8 lines. Accessing 'id' as non-null risks a runtime crash on the create route.`, suggestedFix: `Add: if (!id) return ; before any data hook.`, }) } } return out } function rulePermissionGuardOnMutations(ctx: RuleContext): Violation[] { const out: Violation[] = [] // The generator gates a write by wrapping the trigger BUTTON in , // while the mutateAsync call lives in a handler (e.g. handleDelete) or a hook // declaration in the component body — structurally decoupled from the JSX, and // emitted ABOVE the guard. A textual scan around the call site therefore can never // see the guard (it sits below), which made this rule fire on correctly-gated pages. // Judge the page as a whole instead: a page that wires at least one // is trusted (the backend [RequirePermission] is the // real boundary). Only a page with a mutation and NO permission gating at all is a // genuine violation. `[^>]*` spans newlines, so a multi-line guard tag still matches. if (/]*\bpermission\s*=/.test(ctx.source)) return out // Hook *declarations* (useDelete/useUpdate/useCreate) are never wrapped in a guard — // matching them was pure noise. Flag only an actual mutation INVOCATION left ungated. const m = /\b(mutateAsync|mutation\.mutate)\b/.exec(ctx.source) if (m) { const lineIdx = (ctx.source.slice(0, m.index).match(/\n/g) ?? []).length + 1 out.push({ rule: 'permissionguard-on-mutations', severity: 'err', line: lineIdx, message: `Mutation '${m[1]}' is present but the page wires no . The action is ungated in the UI.`, suggestedFix: `Wrap the action button (or surrounding JSX block) in .`, }) } return out } function ruleNoLocalUsePermissions(ctx: RuleContext): Violation[] { const out: Violation[] = [] const re = /(?:^|\n)\s*(function\s+usePermissions\s*\(|const\s+usePermissions\s*=)/g let m: RegExpExecArray | null while ((m = re.exec(ctx.source)) !== null) { const lineIdx = (ctx.source.slice(0, m.index).match(/\n/g) ?? []).length + 1 out.push({ rule: 'no-local-usepermissions', severity: 'err', line: lineIdx, message: 'Page declares a local usePermissions() stub. This bypasses the SmartStack permission system — every check defaults to true.', suggestedFix: 'Remove the local stub. Use from @/components/auth/PermissionGuard (emitted by scaffold-frontend-auth) instead.', }) } return out } function ruleI18nKeysResolve(ctx: RuleContext): Violation[] { if (!ctx.loc.module || !ctx.loc.entity) return [] const out: Violation[] = [] // Extraction + defaultValue skip live in lib/i18n-keys.ts (shared with the // module-wide DEV-UI-038 gate). `t('key', { defaultValue: … })` calls render // the inline default when the catalogue entry is absent, so a missing key is // NOT a gate failure (enum options, field placeholders, form.me, …). Default // namespace: the page's useTranslation('NS'), else the entity name. const calls = extractTCalls(ctx.source, ctx.loc.entity.toLowerCase()) .filter((c) => !c.hasDefaultValue) if (calls.length === 0) return [] // For each (namespace, locale) pair, load the JSON once and walk dotted keys. const loadedLocales = new Map | null>() function loadLocale(locale: string, namespace: string): Record | null { const cacheKey = `${locale}::${namespace}` if (loadedLocales.has(cacheKey)) return loadedLocales.get(cacheKey) ?? null // Canonical layout (scaffold-component writer): one JSON per module under // src/i18n/locales/{locale}/, the file named after the i18next namespace // (= the module via useTranslation, or an explicit `ns:` prefix such as // `common`). Keys are entity-nested inside (e.g. employee.list.title). const abs = join(ctx.projectPath, 'src', 'i18n', 'locales', locale, `${namespace}.json`) if (!existsSync(abs)) { loadedLocales.set(cacheKey, null) return null } try { const raw = JSON.parse(readFileSync(abs, 'utf-8')) as Record loadedLocales.set(cacheKey, raw) return raw } catch { loadedLocales.set(cacheKey, null) return null } } for (const call of calls) { if (!call.namespace) continue for (const locale of LOCALES) { const tree = loadLocale(locale, call.namespace) if (tree === null) { out.push({ rule: 'i18n-keys-resolve', severity: 'err', line: call.line, message: `i18n locale file missing: src/i18n/locales/${locale}/${call.namespace}.json (key '${call.key}' will render as the raw string).`, suggestedFix: `Re-run scaffold-component for entity '${ctx.loc.entity}' to emit the missing locale file.`, }) continue } const res = resolveI18nKey(tree, call.key) if (res === 'object') { out.push({ rule: 'i18n-keys-resolve', severity: 'err', line: call.line, message: `i18n key '${call.key}' resolves to an OBJECT (not a string) in src/i18n/locales/${locale}/${call.namespace}.json — the label/children collision; i18next renders the raw key.`, suggestedFix: `Move the children to a sibling branch (placeholders / options / actionParams) so '${call.key}' stays a string leaf.`, }) } else if (res === 'missing') { out.push({ rule: 'i18n-keys-resolve', severity: 'err', line: call.line, message: `i18n key '${call.key}' missing in src/i18n/locales/${locale}/${call.namespace}.json.`, suggestedFix: `Add a translation for '${call.key}' or remove the t() call.`, }) } } } return out } function ruleHookImportsExist(ctx: RuleContext): Violation[] { const out: Violation[] = [] const importRe = /import\s*\{\s*([^}]+)\s*\}\s*from\s*['"](@\/features\/[^'"]+)['"]/g let m: RegExpExecArray | null while ((m = importRe.exec(ctx.source)) !== null) { const names = m[1] .split(',') .map((n) => n.replace(/\s+as\s+\w+/, '').trim()) .filter((n) => n.length > 0 && !n.startsWith('type ')) if (names.length === 0) continue const spec = m[2] const resolved = resolveImport(spec, ctx.pageFile, ctx) if (!resolved) continue // already flagged by ruleImportsResolve let exportSrc: string try { exportSrc = readFileSync(resolved, 'utf-8') } catch { continue } for (const name of names) { const exportPattern = new RegExp( `export\\s+(?:async\\s+)?(?:function|const|class|interface|type)\\s+${name}\\b|export\\s*\\{[^}]*\\b${name}\\b`, ) if (!exportPattern.test(exportSrc)) { out.push({ rule: 'hook-imports-exist', severity: 'err', line: findLineNumber(ctx.source, m[0]), message: `Imported symbol '${name}' is not exported by ${spec} (resolves to ${relative(ctx.projectPath, resolved)}).`, suggestedFix: `Re-run scaffold-api-client for the parent entity. If the symbol is a dashboard hook, ensure hasDashboard:true on the entity in slices.frontend.`, }) } } } return out } function rulePermissionKeysNoAppCode(ctx: RuleContext): Violation[] { const out: Violation[] = [] const re = /permission\s*=\s*['"]([^'"]+)['"]/g let m: RegExpExecArray | null while ((m = re.exec(ctx.source)) !== null) { const value = m[1] const segments = value.split('.').length const lineIdx = (ctx.source.slice(0, m.index).match(/\n/g) ?? []).length + 1 if (!PERMISSION_KEY_REGEX.test(value)) { out.push({ rule: 'permission-keys-no-appcode', severity: 'err', line: lineIdx, message: segments === 5 ? `permission='${value}' carries an appCode prefix (5 segments). Use {module}.{section}[.{resource}].{action} only.` : `permission='${value}' does not match {module}.{section}[.{resource}].{action} (lowercase, 3-4 dot-segments).`, suggestedFix: segments === 5 ? `Drop the leading appCode: '${value.split('.').slice(1).join('.')}'.` : `Rewrite as '.
[.].' with lowercase segments and one of read/create/update/delete as action.`, }) continue } // 4 segments are legal ONLY for the resource grain (module.section.resource. // action). When the page's module is known from its path, a 4-seg value NOT // starting with it is the historical appCode-prefix bug in disguise. if (segments === 4 && ctx.loc.module && value.split('.')[0] !== ctx.loc.module) { out.push({ rule: 'permission-keys-no-appcode', severity: 'err', line: lineIdx, message: `permission='${value}' (4 segments) does not start with this page's module '${ctx.loc.module}' — it carries an appCode prefix, not a resource grain.`, suggestedFix: `Drop the leading appCode: '${value.split('.').slice(1).join('.')}'.`, }) } } return out } /** * Rule 9 — No hardcoded Tailwind color classes or hex values in className. * Generated pages must use SmartStack CSS variables (`var(--color-accent-*)`, * `var(--bg-*)`, etc.) — never Tailwind's built-in color palette (`bg-red-500`) * or inline hex (`bg-[#ff0000]`). */ function ruleCssHardcodedColors(ctx: RuleContext): Violation[] { const out: Violation[] = [] // Match className="..." and className={`...`} attribute values. const classNameRe = /className\s*=\s*(?:"([^"]+)"|{`([^`]+)`})/g // Tailwind named color classes: bg-red-500, text-blue-600, border-green-300, etc. const namedColorRe = /\b(?:bg|text|border|ring|outline|shadow|from|via|to|divide|placeholder|accent|caret|fill|stroke)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b/g // Hardcoded hex in arbitrary values: bg-[#ff0000], text-[#333] const hexArbitraryRe = /\b(?:bg|text|border|ring|fill|stroke)-\[#[0-9a-fA-F]{3,8}\]/g // dark: prefix (should use CSS vars that auto-adapt) const darkPrefixRe = /\bdark:/g let m: RegExpExecArray | null while ((m = classNameRe.exec(ctx.source)) !== null) { const value = m[1] ?? m[2] ?? '' const lineIdx = (ctx.source.slice(0, m.index).match(/\n/g) ?? []).length + 1 let colorMatch: RegExpExecArray | null namedColorRe.lastIndex = 0 while ((colorMatch = namedColorRe.exec(value)) !== null) { out.push({ rule: 'css-hardcoded-colors', severity: 'err', line: lineIdx, message: `Hardcoded Tailwind color '${colorMatch[0]}' — use a CSS variable token (e.g. bg-[var(--bg-card)], text-[var(--text-primary)]).`, suggestedFix: `Replace '${colorMatch[0]}' with the matching SmartStack CSS variable.`, }) } hexArbitraryRe.lastIndex = 0 while ((colorMatch = hexArbitraryRe.exec(value)) !== null) { out.push({ rule: 'css-hardcoded-colors', severity: 'err', line: lineIdx, message: `Hardcoded hex color '${colorMatch[0]}' — use a CSS variable token instead.`, suggestedFix: `Replace '${colorMatch[0]}' with the matching SmartStack CSS variable.`, }) } darkPrefixRe.lastIndex = 0 if (darkPrefixRe.test(value)) { out.push({ rule: 'css-hardcoded-colors', severity: 'err', line: lineIdx, message: `'dark:' prefix detected — SmartStack CSS variables handle dark mode automatically via .dark scope.`, suggestedFix: `Remove 'dark:' prefixed classes and use CSS variable tokens that auto-adapt.`, }) } } return out } /** * Rule 10 — Breadcrumbs present on every page that renders . * The breadcrumbs prop ensures the user can navigate back up the hierarchy. */ function ruleBreadcrumbPresent(ctx: RuleContext): Violation[] { const out: Violation[] = [] if (/]/.test(ctx.source) && !/breadcrumbs\s*=\s*\{/.test(ctx.source)) { out.push({ rule: 'breadcrumb-present', severity: 'err', message: ` is rendered without a breadcrumbs prop. Add breadcrumbs={[…]} for navigational context.`, suggestedFix: `Add: breadcrumbs={[{ label: t('breadcrumb.section'), href: routes.
.list() }, { label: t('…') }]}`, }) } return out } /** * Rule 11 — Tab navigation persists in URL via ?tab= search param. * Pages with tab-like patterns must use useSearchParams (or useTabNavigation) * to keep tab state in the URL for shareability and back-button support. */ function ruleTabUrlPersistence(ctx: RuleContext): Violation[] { const out: Violation[] = [] // Detect tab-like patterns: activeTab state + conditional rendering const hasTabState = /activeTab|currentTab|selectedTab/.test(ctx.source) const usesUrlSync = /useSearchParams|useTabNavigation/.test(ctx.source) if (hasTabState && !usesUrlSync) { const line = findLineNumber(ctx.source, 'activeTab') ?? findLineNumber(ctx.source, 'currentTab') ?? findLineNumber(ctx.source, 'selectedTab') out.push({ rule: 'tab-url-persistence', severity: 'err', line, message: `Tab state is managed in local React state only — tab selection is lost on page refresh or when sharing the URL.`, suggestedFix: `Use useSearchParams() or useTabNavigation() to persist the active tab in the URL (?tab=key).`, }) } return out } /** * Rule 12 — Every tab label rendered via t('…') has a matching i18n key. * When a page uses tabs, the tab labels must have translated keys. */ function ruleTabI18nKeys(ctx: RuleContext): Violation[] { if (!ctx.loc.module || !ctx.loc.entity) return [] const out: Violation[] = [] // Find tab button patterns with t() labels const tabPatterns = /switchTab\(['"]([^'"]+)['"]\)[\s\S]{0,200}?\{(t\(['"]([^'"]+)['"]\))/g let m: RegExpExecArray | null while ((m = tabPatterns.exec(ctx.source)) !== null) { const tKey = m[3] if (!tKey) continue // The key resolution is handled by ruleI18nKeysResolve — this rule just // checks that tab buttons use t() instead of hardcoded strings. // Hardcoded tab label detection: const lineIdx = (ctx.source.slice(0, m.index).match(/\n/g) ?? []).length + 1 if (!m[2].startsWith('t(')) { out.push({ rule: 'tab-i18n-keys', severity: 'err', line: lineIdx, message: `Tab button label is not translated — use t('detail.tabs.${m[1]}') instead of a hardcoded string.`, suggestedFix: `Replace the label with t('detail.tabs.${m[1]}').`, }) } } return out } function ruleCliGeneratedMarker(ctx: RuleContext): Violation[] { if (ctx.lines[0]?.startsWith('// @generated-by scaffold-component')) return [] return [ { rule: 'cli-generated-marker', severity: 'err' as const, message: 'Page file is missing the `// @generated-by scaffold-component` header. ' + 'This means it was hand-written instead of being produced by the deterministic CLI.', suggestedFix: 'Delete the file and re-run scaffold-component via Bash — do NOT write .tsx files by hand in Phase 3a.', }, ] } // ─── Entry point ─────────────────────────────────────────────────────────── export function execute(input: ValidatePageInput): ValidatePageReport { const projectPath = resolve(input.projectPath) const pageFile = isAbsolute(input.pageFile) ? resolve(input.pageFile) : resolve(join(projectPath, input.pageFile)) if (!existsSync(pageFile)) { return { pageFile, module: null, entity: null, view: null, violations: [ { rule: 'imports-resolve', severity: 'err', message: `pageFile does not exist on disk: ${pageFile}`, suggestedFix: 'Run scaffold-component with the corresponding {module, entity, section, views:[…]} spec to create the file.', }, ], } } const source = readFileSync(pageFile, 'utf-8') const lines = source.split(/\r?\n/) const loc = parseLocation(pageFile, projectPath) const aliases = readTsconfigAliases(projectPath) const ctx: RuleContext = { pageFile, projectPath, source, lines, loc, aliases } const violations: Violation[] = [ ...ruleCliGeneratedMarker(ctx), ...ruleImportsResolve(ctx), ...rulePageTemplateWrapping(ctx), ...ruleUseParamsNullCheck(ctx), ...rulePermissionGuardOnMutations(ctx), ...ruleNoLocalUsePermissions(ctx), ...ruleI18nKeysResolve(ctx), ...ruleHookImportsExist(ctx), ...rulePermissionKeysNoAppCode(ctx), ...ruleCssHardcodedColors(ctx), ...ruleBreadcrumbPresent(ctx), ...ruleTabUrlPersistence(ctx), ...ruleTabI18nKeys(ctx), ] return { pageFile, module: loc.module, entity: loc.entity, view: loc.view, violations, } }