/** * cli:scaffold-routes — validate.ts * * Structural Zod validation + non-blocking filesystem cross-check: every * entity × view combination is expected to have a matching `.tsx` page under * `{projectPath}/src/pages/{module}/{section}/`. Missing pages are * reported as warnings so the caller (Frontend subagent) knows which pages to * scaffold BEFORE writing the aggregated registry. Never blocks — the CLI may * legitimately run before scaffold-component in some flows. */ import fs from 'node:fs' import path from 'node:path' import { validatePwaMetaV1 } from '../../../../../lib/pwa-meta.js' import { buildRegistryIndex } from '../../../../../lib/registry-index.js' import { ScaffoldRoutesInputSchema, type ScaffoldRoutesInput, type ValidationResult, } from './types.js' function pageNameFor(entity: string, view: string): string { // List uses plural (BudgetsListPage); detail/form stay singular. Aligns // with scaffold-component's emitted file names and the actual SmartStack // project layout. if (view === 'list') return `${entity}sListPage` if (view === 'detail') return `${entity}DetailPage` if (view === 'create' || view === 'edit') return `${entity}FormPage` return `${entity}${view.charAt(0).toUpperCase() + view.slice(1)}Page` } function checkPagesExist(spec: ScaffoldRoutesInput): string[] { const warnings: string[] = [] // Pages live at src/pages/{module}/{section}/ — no appLower prefix. // This matches scaffold-component's emit path exactly, so the registry // imports point at real files instead of phantom paths. const pagesBase = path.join(spec.projectPath, 'src', 'pages', spec.module) for (const entity of spec.entities) { const sectionDir = path.join(pagesBase, entity.section) for (const view of entity.views) { if (view === 'kanban') { // Legacy token: the board is a viewMode of the LIST page — no route, // no componentKey, no page of its own (generate ignores it too). warnings.push( `${entity.name}: legacy 'kanban' view token ignored — the board rides the list route ` + `(?view=kanban); no kanban route helper or registry key is emitted any more.`, ) continue } const pageName = pageNameFor(entity.name, view) const candidates = [ path.join(sectionDir, `${pageName}.tsx`), path.join(sectionDir, `${pageName}.ts`), path.join(sectionDir, pageName, 'index.tsx'), path.join(sectionDir, pageName, 'index.ts'), ] if (!candidates.some((c) => fs.existsSync(c))) { warnings.push( `Page missing for ${entity.name}.${view}: expected at ${path.relative( spec.projectPath, candidates[0], )} — run scaffold-component first, otherwise the registry will reference a phantom import (Vite fails at launch on those).`, ) } } } return warnings } /** * PWA meta coherence — BLOCKING. `validatePwaMetaV1` rejects the combinations * the generators cannot honour (support 'full', offline on desktop-only), and * a `pwaByView` key that names a view the entity doesn't emit would silently * drop the intended metadata. */ function checkPwaMeta(spec: ScaffoldRoutesInput): string[] { const errors: string[] = [] if (spec.defaultPwa) { const err = validatePwaMetaV1(spec.defaultPwa) if (err) errors.push(`[defaultPwa] ${err}`) } for (const entity of spec.entities) { if (entity.pwa) { const err = validatePwaMetaV1(entity.pwa) if (err) errors.push(`[${entity.name}.pwa] ${err}`) } for (const [view, meta] of Object.entries(entity.pwaByView ?? {})) { if (!entity.views.includes(view as (typeof entity.views)[number])) { errors.push( `[${entity.name}.pwaByView.${view}] names a view the entity does not emit (views: ${entity.views.join(', ')}) — the metadata would be silently dropped.`, ) } const err = validatePwaMetaV1(meta) if (err) errors.push(`[${entity.name}.pwaByView.${view}] ${err}`) } } return errors } /** * Registry layout gate — BLOCKING. On an app whose componentRegistry.generated.ts * registers pages of its OWN (legacy MCP-era monolith, or a mixed half-migrated * state), emitting a per-module registry would create key collisions with the * monolith AND arm the destructive re-aggregation the aggregator now refuses. * No spec escape hatch: the sanctioned path (split-component-registry, then * re-run) leaves the layout 'per-module', where this gate never fires. */ function checkRegistryLayout(spec: ScaffoldRoutesInput): string[] { const index = buildRegistryIndex(spec.projectPath) if (index.layout !== 'legacy-monolith' && index.layout !== 'mixed') return [] const inlineKeys = index.aggregate?.registrations.length ?? 0 return [ `scaffold-routes: src/extensions/componentRegistry.generated.ts registers ${inlineKeys} componentKey(s) of its ` + `own (${index.layout === 'legacy-monolith' ? 'legacy MCP-era monolithic' : 'mixed'} layout). Emitting a ` + `per-module registry on top would create colliding registrations the aggregator refuses. Migrate first with ` + `split-component-registry (skills/development/frontend/routes/cli/split-component-registry), then re-run ` + `scaffold-routes.`, ] } export function validate(raw: unknown): ValidationResult { const result = ScaffoldRoutesInputSchema.safeParse(raw) if (!result.success) { return { valid: false, errors: result.error.issues.map((i) => `[${i.path.join('.')}] ${i.message}`), warnings: [], } } const pwaErrors = checkPwaMeta(result.data) if (pwaErrors.length > 0) { return { valid: false, errors: pwaErrors, warnings: [] } } const layoutErrors = checkRegistryLayout(result.data) if (layoutErrors.length > 0) { return { valid: false, errors: layoutErrors, warnings: [] } } return { valid: true, errors: [], warnings: checkPagesExist(result.data) } }