/** * cli:derive-related-tabs-data — derive.ts * * Pure core + thin filesystem wrapper. For each table/cards tab of the * entity's DETAIL pagespec, follow `targetScreen`'s on-disk truth — the * related entity's `.list.md` pagespec — and derive the embedded columns * (≤5, the relation FK excluded: in a vehicle's « Échéances » tab, a * `vehicleId` column would repeat the vehicle being looked at on every row), * the per-locale labels, the display field and the routing signals. The * fallback (no list pagespec, same module) is the related entity's first 4 * non-system attributes from entité.md. * * Outcome contract: * - cross-module target whose PRD is NOT on disk → tab OMITTED (fail-open: * the generator renders the single createdAt column, validate warns); * - target pagespec EXISTS but nothing derives → ERROR (success:false — the * orchestrator heals the pagespec instead of shipping the empty tab); * - `summary` tabs need no entry (count-only cartouche). */ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { basename, join } from 'node:path' import { extractFencedJson } from '../compute-page-diff/scan-pagespecs.js' import { parseRelatedTabs, type PageRelatedTab } from '../../../lib/page-spec-related-tabs.js' import { parseEntities, findEntity } from '../../../lib/ba-relations.js' // First code/label-ish column key — SSOT lib/display-field (shared with pickDisplayField). import { DISPLAYISH_RE } from '../../../lib/display-field.js' import type { DeriveRelatedTabsDataInput, DeriveRelatedTabsDataReport, RelatedTabColumnData, RelatedTabDataEntry, } from './types.js' const LOCALES = ['fr', 'en', 'it', 'de'] as const const SYSTEM_ATTRIBUTES = new Set(['id', 'createdat', 'updatedat', 'deletedat', 'tenantid']) function toCamelFirst(name: string): string { return name.charAt(0).toLowerCase() + name.slice(1) } interface ListPagespec { columns?: Array<{ key?: unknown; formatHint?: unknown; [k: string]: unknown }> actions?: Array<{ code?: unknown; permission?: unknown; [k: string]: unknown }> i18nKeys?: Record> } /** Derive ONE tab's data entry from its target list pagespec (pure). */ export function deriveTabEntry( tab: PageRelatedTab, listSpec: ListPagespec | null, entityFallbackFields: string[], ): { entry?: RelatedTabDataEntry; error?: string } { const relationCamel = toCamelFirst(tab.relationFk) let columns: RelatedTabColumnData[] = [] if (listSpec?.columns?.length) { columns = listSpec.columns .filter((c): c is { key: string; formatHint?: string } => typeof c.key === 'string' && c.key.length > 0) .filter(c => toCamelFirst(c.key) !== relationCamel) .slice(0, 5) .map(c => { const labels: Record = {} for (const loc of LOCALES) { const v = listSpec.i18nKeys?.[loc]?.[`list.columns.${c.key}`] if (typeof v === 'string' && v.length > 0) labels[loc] = v } return { key: toCamelFirst(c.key), ...(Object.keys(labels).length > 0 ? { labels } : {}), ...(typeof c.formatHint === 'string' && c.formatHint ? { formatHint: c.formatHint } : {}), } }) if (columns.length === 0) { return { error: `tab '${tab.key}': the target list pagespec exists but no column derives (all columns are the relation FK '${relationCamel}' or key-less) — fix its columns[]` } } } else { // No list pagespec (or no columns): entité.md fallback — first 4 // non-system attributes, relation FK excluded, humanize left to the // generator's label floor. columns = entityFallbackFields .filter(n => !SYSTEM_ATTRIBUTES.has(n.toLowerCase()) && toCamelFirst(n) !== relationCamel) .slice(0, 4) .map(n => ({ key: toCamelFirst(n) })) if (columns.length === 0) { return { error: `tab '${tab.key}': no target list pagespec and no entité.md attributes for '${tab.relatedEntity}' — nothing derives` } } } const displayField = (columns.find(c => DISPLAYISH_RE.test(c.key)) ?? columns[0]).key const createAction = listSpec?.actions?.find(a => a.code === 'create') const createPermission = typeof createAction?.permission === 'string' ? createAction.permission : undefined return { entry: { key: tab.key, columns, displayField, ...(createPermission !== undefined ? { createPermission } : {}), }, } } /** Case-insensitive directory lookup among the children of `parent`. */ function findChildDir(parent: string, name: string): string | null { if (!existsSync(parent)) return null const wanted = name.toLowerCase() let entries: string[] try { entries = readdirSync(parent) } catch { return null } for (const entry of entries) { const abs = join(parent, entry) try { if (statSync(abs).isDirectory() && entry.toLowerCase() === wanted) return abs } catch { /* ignore unreadable */ } } return null } /** * The BA directory of a tab's target module. * * A sibling module of the same application (`parc` → `/PARC`) — or, when the tab * names another application, that application's module (`//`). * Without the second case a cross-application target was looked up among the CURRENT * application's modules, never found, and the tab was omitted: its columns silently fell * back to `createdAt` on a page the user had fully specified. */ function resolveModuleRoot( moduleRoot: string, relatedModule: string, ownModule: string, relatedApp: string | undefined, ownApp: string, ): string | null { const foreignApp = relatedApp !== undefined && relatedApp.toLowerCase() !== ownApp.toLowerCase() if (!foreignApp && relatedModule.toLowerCase() === ownModule.toLowerCase()) return moduleRoot const appRoot = foreignApp ? findChildDir(join(moduleRoot, '..', '..'), relatedApp as string) : join(moduleRoot, '..') if (appRoot === null) return null return findChildDir(appRoot, relatedModule) } function readPagespecJson(root: string, file: string): ListPagespec | null { const abs = join(root, 'pagespecs', file) if (!existsSync(abs)) return null const json = extractFencedJson(readFileSync(abs, 'utf-8')) if (json === null) return null try { return JSON.parse(json) as ListPagespec } catch { return null } } export function deriveRelatedTabsData(spec: DeriveRelatedTabsDataInput): DeriveRelatedTabsDataReport { const report: DeriveRelatedTabsDataReport = { entity: spec.entity, relatedTabsData: [], omitted: [], errors: [], warnings: [], } const ownModule = basename(spec.moduleRoot) const ownApp = basename(join(spec.moduleRoot, '..')) const detailAbs = join(spec.moduleRoot, 'pagespecs', `${spec.entity}.detail.md`) if (!existsSync(detailAbs)) { report.warnings.push(`no ${spec.entity}.detail.md pagespec under ${spec.moduleRoot}/pagespecs — nothing to derive`) return report } const detailJson = extractFencedJson(readFileSync(detailAbs, 'utf-8')) let detailSpec: { relatedTabs?: unknown } = {} try { detailSpec = detailJson ? JSON.parse(detailJson) as { relatedTabs?: unknown } : {} } catch { report.errors.push(`${spec.entity}.detail.md: the fenced json block does not parse`) return report } const { tabs, rejected } = parseRelatedTabs(detailSpec.relatedTabs) for (const r of rejected) { report.errors.push(`relatedTabs[${r.index}] is malformed: ${r.issues.join('; ')}`) } for (const tab of tabs) { if (tab.displayMode === 'summary') continue const targetRoot = resolveModuleRoot(spec.moduleRoot, tab.relatedModule, ownModule, tab.relatedApp, ownApp) if (targetRoot === null) { const where = tab.relatedApp ? `${tab.relatedApp}/${tab.relatedModule}` : tab.relatedModule report.omitted.push({ key: tab.key, reason: `cross-module target '${where}' has no module directory on disk — entry omitted (generator fails open on createdAt, validate warns)`, }) continue } const listSpec = readPagespecJson(targetRoot, `${tab.relatedEntity}.list.md`) if (listSpec === null && targetRoot !== spec.moduleRoot) { report.omitted.push({ key: tab.key, reason: `cross-module target pagespec ${tab.relatedEntity}.list.md not on disk under ${tab.relatedApp ? `${tab.relatedApp}/` : ''}${tab.relatedModule} — entry omitted (generator fails open on createdAt, validate warns)`, }) continue } // Same-module fallback source: the related entity's entité.md attributes. let fallbackFields: string[] = [] if (listSpec === null || !listSpec.columns?.length) { const entiteAbs = join(targetRoot, 'entité.md') if (existsSync(entiteAbs)) { const graph = parseEntities(new Map([[basename(targetRoot), readFileSync(entiteAbs, 'utf-8')]])) report.warnings.push(...graph.warnings) const parsed = findEntity(graph, tab.relatedEntity) fallbackFields = parsed?.attributes.map(a => a.name) ?? [] } } const { entry, error } = deriveTabEntry(tab, listSpec, fallbackFields) if (error !== undefined) { report.errors.push(error) continue } const withSignals: RelatedTabDataEntry = { ...entry!, hasDetail: existsSync(join(targetRoot, 'pagespecs', `${tab.relatedEntity}.detail.md`)), hasCreateForm: existsSync(join(targetRoot, 'pagespecs', `${tab.relatedEntity}.form.md`)), } report.relatedTabsData.push(withSignals) } return report }