/** * cli:derive-nav-resources — derive.ts * * Pure derivation: pagespec route identities → one nav resource per distinct * (routeParent, routeFamily). Deterministic — same pagespecs, same output. * * Contract mirrors scaffold-routes (Phase 3b): a satellite's URL is * `/{app}/{module}/{routeParent}/{routeFamily}` and its list componentKey is * `{app}.{module}.{routeParent}.{routeFamily}` — the seeded resource must * carry exactly those values or the seeded route resolves nothing. */ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs' import { join, basename } from 'node:path' import { extractFencedJson } from '../compute-page-diff/scan-pagespecs.js' import { capitalize } from '../../../lib/string-utils.js' import type { DeriveNavResourcesInput, DeriveNavResourcesReport, DerivedNavResource, PagespecRouteIdentity, RouteFamilyCollision, } from './types.js' /** `vehicle-types` → `Vehicle types` (label fallback when no fr title). */ export function humanizeSlug(slug: string): string { const words = slug.split(/[-_]+/).filter(Boolean) if (words.length === 0) return slug return capitalize(words.join(' ')) } /** Read + parse every `/pagespecs/*.md` into route identities. */ export function scanRouteIdentities(moduleRoot: string): { specs: PagespecRouteIdentity[] warnings: string[] } { const dir = join(moduleRoot, 'pagespecs') const specs: PagespecRouteIdentity[] = [] const warnings: string[] = [] if (!existsSync(dir)) { warnings.push(`no pagespecs/ directory under ${moduleRoot}`) return { specs, warnings } } for (const name of readdirSync(dir).sort()) { if (!name.endsWith('.md')) continue const full = join(dir, name) try { if (!statSync(full).isFile()) continue } catch { continue } let content: string try { content = readFileSync(full, 'utf8') } catch (err) { warnings.push(`${name}: cannot read file: ${(err as Error).message}`) continue } const json = extractFencedJson(content) if (json === null) { warnings.push(`${name}: no fenced \`\`\`json block — skipped`) continue } let parsed: Record try { parsed = JSON.parse(json) as Record } catch (err) { warnings.push(`${name}: invalid JSON: ${(err as Error).message} — skipped`) continue } const i18n = parsed.i18nKeys as Record> | undefined specs.push({ key: basename(name, '.md'), appCode: typeof parsed.appCode === 'string' ? parsed.appCode : undefined, module: typeof parsed.module === 'string' ? parsed.module : undefined, section: typeof parsed.section === 'string' ? parsed.section : undefined, entity: typeof parsed.entity === 'string' ? parsed.entity : undefined, view: typeof parsed.view === 'string' ? parsed.view : undefined, routeFamily: typeof parsed.routeFamily === 'string' ? parsed.routeFamily : undefined, routeParent: typeof parsed.routeParent === 'string' ? parsed.routeParent : undefined, frLeaves: i18n && typeof i18n.fr === 'object' ? i18n.fr : undefined, }) } return { specs, warnings } } /** Pure grouping — exported for tests. */ export function deriveResources(specs: PagespecRouteIdentity[]): DeriveNavResourcesReport { const warnings: string[] = [] const appCodes = new Set(specs.map((s) => s.appCode).filter((v): v is string => !!v)) const modules = new Set(specs.map((s) => s.module).filter((v): v is string => !!v)) if (appCodes.size > 1) warnings.push(`inconsistent appCode across pagespecs: ${[...appCodes].sort().join(', ')}`) if (modules.size > 1) warnings.push(`inconsistent module across pagespecs: ${[...modules].sort().join(', ')}`) const appCode = appCodes.size >= 1 ? [...appCodes][0] : null const module = modules.size >= 1 ? [...modules][0] : null interface Group { sectionCode: string family: string label?: string fallbackLabel?: string hasList: boolean memberKeys: string[] /** Distinct entities claiming this (routeParent, routeFamily) — >1 is an ambiguity. */ entities: Set } const groups = new Map() let satellites = 0 for (const s of specs) { // Half-declared identity is the exact mis-routing bug PRD-108 audits — // surface it here too, because a half-declared satellite would silently // stay unroutable. if (!!s.routeFamily !== !!s.routeParent) { warnings.push(`${s.key}: has ${s.routeFamily ? 'routeFamily' : 'routeParent'} without its twin — fix the pagespec (PRD-108)`) continue } if (!s.routeFamily || !s.routeParent) continue satellites++ // Separator must be a character no kebab slug can contain — an embedded // control byte made git classify this whole file as BINARY (no reviewable // diff, no 3-way merge). const gKey = `${s.routeParent}|${s.routeFamily}` let g = groups.get(gKey) if (!g) { g = { sectionCode: s.routeParent, family: s.routeFamily, hasList: false, memberKeys: [], entities: new Set() } groups.set(gKey, g) } g.memberKeys.push(s.key) if (s.entity) g.entities.add(s.entity) if (s.view === 'list') { g.hasList = true const listTitle = s.frLeaves?.['list.title'] if (listTitle) g.label = listTitle } if (!g.fallbackLabel) { const anyTitle = s.frLeaves ? Object.entries(s.frLeaves).find(([k]) => k.endsWith('.title'))?.[1] : undefined if (anyTitle) g.fallbackLabel = anyTitle } if (s.section && s.section !== s.routeParent) { warnings.push(`${s.key}: section "${s.section}" ≠ routeParent "${s.routeParent}" — routeParent must name the hosting menu section`) } } const resources: DerivedNavResource[] = [] const collisions: RouteFamilyCollision[] = [] const sorted = [...groups.values()].sort( (a, b) => a.sectionCode.localeCompare(b.sectionCode) || a.family.localeCompare(b.family), ) const orderPerSection = new Map() for (const g of sorted) { if (!g.hasList) { warnings.push(`routeFamily "${g.family}" (section "${g.sectionCode}") has no list pagespec — seeded anyway so its views stay reachable`) } // TWO entities claiming one (routeParent, routeFamily) collapse onto ONE // route: one [NavRoute] serves one controller, so the loser's lookups and // pages silently resolve against the winner. The section-slug ambiguity // guard (derive-fk-specs) does not see this shape — the family IS the // disambiguator, so a duplicated family is the same bug one level down. if (g.entities.size > 1) { const claimants = [...g.entities].sort() collisions.push({ sectionCode: g.sectionCode, family: g.family, entities: claimants }) warnings.push( `BLOCKING — routeFamily "${g.family}" (section "${g.sectionCode}") is claimed by ${claimants.join(', ')}: ` + 'one route family serves ONE entity. Give each satellite a distinct routeFamily in its pagespecs (PRD-108).', ) } const displayOrder = (orderPerSection.get(g.sectionCode) ?? 0) + 1 orderPerSection.set(g.sectionCode, displayOrder) resources.push({ code: g.family, label: g.label ?? g.fallbackLabel ?? humanizeSlug(g.family), sectionCode: g.sectionCode, route: `/${appCode ?? ''}/${module ?? ''}/${g.sectionCode}/${g.family}`, componentKey: `${appCode ?? ''}.${module ?? ''}.${g.sectionCode}.${g.family}`, displayOrder, }) } if ((appCode === null || module === null) && resources.length > 0) { warnings.push('appCode/module missing from every pagespec — routes/componentKeys are incomplete, fix the pagespecs') } return { appCode, module, resources, collisions, totals: { pagespecs: specs.length, satellites, resources: resources.length, collisions: collisions.length }, warnings, } } export function deriveNavResources(input: DeriveNavResourcesInput): DeriveNavResourcesReport { const { specs, warnings } = scanRouteIdentities(input.moduleRoot) const report = deriveResources(specs) report.warnings = [...warnings, ...report.warnings] return report }