/** * 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/{appLower}/{module}/[{parentSection}/]{section}/` * — the SAME naming scaffold-component emits (render/page-files.ts). 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 { pageDirFor, pageFileName, pageFileViewOf } from '../../../component/cli/scaffold-component/render/page-files.js' import { ScaffoldRoutesInputSchema, type ScaffoldRoutesInput, type ValidationResult, } from './types.js' /** * Page-existence cross-check — the SAME naming the scaffolder emits * (scaffold-component render/page-files.ts): `src/pages/{appLower}/{module}/ * [{parentSection}/]{section}/{PluralListPage|EntityDetailPage|EntityFormPage|…}.tsx`, * a `pageFilePaths[view]` override winning exactly as it does in generate.ts. * Before 2026-09 (audit B-WARN-13) this looked for `${entity}sListPage` under * `src/pages/{module}/…` — a plural and a directory the generator never used — * so it warned on EVERY canonical run and could never catch a real miss. */ function checkPagesExist(spec: ScaffoldRoutesInput): string[] { const warnings: string[] = [] for (const entity of spec.entities) { const dir = pageDirFor(spec.appCode, spec.module, entity.section, entity.parentSection) const seen = new Set() 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 fileView = pageFileViewOf(view) if (fileView === null) continue const override = entity.pageFilePaths?.[view] ?? (fileView === 'form' ? entity.pageFilePaths?.create ?? entity.pageFilePaths?.edit : undefined) const rel = override ? override.replace(/\\/g, '/') : `${dir}/${pageFileName(fileView, entity.name, entity.pluralName)}` if (seen.has(rel)) continue // create + edit share the form page seen.add(rel) if (!fs.existsSync(path.join(spec.projectPath, ...rel.split('/')))) { warnings.push( `Page missing for ${entity.name}.${view}: expected at ${rel} — 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), data: result.data } }