/** * cli:derive-related-tabs — check.ts (mode=validate, pure logic). * * Validates the PREREQUISITES of the DECLARED « Onglet lié » bullets over the * scoped app/module. Violations are DATA — a successful run exits 0 even when * violations exist; enforcement belongs to the audits (SCR-009/014 on * screen.md, PRD-103..105 on the pagespecs). * * Rules: * RTV-001 err relatedEntity exists in entité.md (module scope, or any * module when includeCrossModule) * RTV-002 err a Relations entry exists ON the related entity pointing back * to the screen's entity with that FK * RTV-003 err the tab's screenTarget resolves to an existing * SmartListView/SmartCard bound to relatedEntity * RTV-004 warn declared permission exists verbatim in rbac.md * RTV-005 err related tabs declared on a `mode: create` form * RTV-006 err duplicate tab keys within one screen * RTV-007 err detail/edit SmartForm whose entity has ≥1 incoming 1:N * relation, ZERO declared tabs AND no `Sans onglets liés` * marker (marker present → ok) * RTV-008 err the declared tab, projected to the canonical shape, passes * PageRelatedTabSchema * RTV-009 warn/err the SHARED tab bar stays within budget at authoring * time: inner « Onglet » bullets + « Onglet lié » bullets whose * affichage keeps them in the strip (summary → band cartouche, * excluded — lib placementForDisplayMode). > 7 warn, > 9 err * (lib/detail-tab-strip thresholds). The remedy is NEVER * dropping a tab (PRD-103): switch inert satellites to * `affichage summary` and/or regroup fields with « Section ». * Detail-mode screens with a create-form sibling are counted as * the unified fiche (field term 0) — a later `direct` opt-out * under-counts here and is caught by RTV-108 (fail-open safe). * * With `pagespecs: true`, ALSO cross-checks screen.md ↔ pagespec * `relatedTabs[]` per detail/edit view (RTV-101 missing / RTV-102 extra / * RTV-103 mismatch / RTV-104 schema rejection, all `source: 'pagespec'`), * plus the ROUTING contract of each pagespec tab: * RTV-105 err the tab's route family resolves — `relatedRouteFamily` must * match the target list pagespec's `routeFamily ?? section`, * and a sub-view target (several list pagespecs sharing one * menu section) MUST carry a `routeFamily` (the AtlasHub * mis-routing guard: without it every tab navigates to the * porteur's family) * RTV-106 err `withCreate: true` while the related entity has no create * form pagespec (`.form.md`) * RTV-107 err `withRowOpen: true` while the related entity has no detail * pagespec (`.detail.md`) * RTV-108 warn/err the RENDERED tab bar of every `.detail.md` * pagespec stays within budget (lib/detail-tab-strip * resolveDetailStrip — unified fiche honoured, band cartouches * excluded, the synthetic « info » trigger counted). > 7 warn, * > 9 err. Authoritative leg of the budget (PRD-133); runs on * the pagespec map directly, so a screen-less pagespec is * still budgeted. Same remedy discipline as RTV-009. */ import { readdirSync, readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' import { pluralize, toKebabCase } from '../../../../lib/string-utils.js' import { findEntity, incomingOf, type ParsedEntity, type RelationGraph, } from './relations.js' import { effectiveMode, resolveListTarget, type DeclaredRelatedTab, type ParsedScreen, } from './screens.js' import { PageRelatedTabSchema, placementForDisplayMode, type DeriveRelatedTabsInput, type PageRelatedTab, type RelatedTabViolation, type ValidateReport, } from './types.js' import { DETAIL_TAB_BUDGET_ERR, DETAIL_TAB_BUDGET_WARN, entityViewsFromPagespecFilenames, resolveDetailStrip, } from '../../../../lib/detail-tab-strip.js' export interface CheckContext { spec: DeriveRelatedTabsInput graph: RelationGraph screens: ParsedScreen[] rbac: Set /** * Pagespec contents (`..md` → content) when spec.pagespecs. * `null` = the pagespecs/ directory is missing (→ warning, not a crash); * `undefined` = pagespec cross-check not requested. */ pagespecs?: Map | null /** * Pagespecs of ANOTHER (app, module) of the same BA tree, or null when that module has * none on disk. Supplied by the CLI (which owns the filesystem); absent in unit tests, * where it degrades exactly like a missing directory. * * Without it the routing checks could only ever look inside the CURRENT module, so a * tab pointing anywhere else had its target "not found" and RTV-105/106/107 stood down * — the tabs most likely to mis-route were the ones nobody checked. */ pagespecsOf?: (app: string, module: string) => Map | null } function matchesSection(screen: ParsedScreen, section?: string): boolean { if (!section) return true return screen.section === section || screen.section.startsWith(`${section}/`) } function lastSectionSegment(section: string): string { const parts = section.split('/').filter(Boolean) return parts[parts.length - 1] ?? section } /** Extract the pagespec's fenced ```json block (same idiom as compute-page-diff). */ export function extractFencedJson(md: string): string | null { const m = md.match(/```json\s*\r?\n([\s\S]*?)\r?\n```/) return m ? m[1] : null } export function checkDeclaredTabs(ctx: CheckContext): ValidateReport { const { spec, graph, screens, rbac } = ctx const moduleKey = `${spec.app}/${spec.module}` const warnings: string[] = [] const violations: RelatedTabViolation[] = [] let ok = 0 const violate = (v: Omit & { source?: 'screen' | 'pagespec' }): void => { violations.push({ source: 'screen', ...v }) } // --- Scope: every SmartForm of the app/module (create included, for RTV-005) --- const formScreens = screens .filter( (s) => // App AND module: the screen set spans every application now, and a module code is // unique only inside its own (two applications legitimately ship `configuration`). s.app.toUpperCase() === spec.app.toUpperCase() && s.module.toUpperCase() === spec.module.toUpperCase() && s.screenType === 'SmartForm' && matchesSection(s, spec.section) && (spec.entity === undefined || s.entity === spec.entity), ) .sort((a, b) => a.code.localeCompare(b.code)) for (const screen of formScreens) { const mode = effectiveMode(screen) // --- RTV-005: tabs on a create form --- if (mode === 'create') { if (screen.declaredTabs.length > 0) { violate({ rule: 'RTV-005', severity: 'err', screenCode: screen.code, file: screen.file, message: `${screen.declaredTabs.length} related tab(s) declared on a \`mode: create\` form — forbidden, no parent id exists yet. Move them to the detail/edit screen.`, }) } else { ok++ } continue // per-tab prerequisites are meaningless on a create form } // --- RTV-006: duplicate tab keys within one screen --- if (screen.declaredTabs.length > 0) { const byKey = new Map() for (const tab of screen.declaredTabs) { byKey.set(tab.key, [...(byKey.get(tab.key) ?? []), tab]) } let dupes = 0 for (const [key, tabs] of byKey) { if (tabs.length > 1) { dupes++ violate({ rule: 'RTV-006', severity: 'err', screenCode: screen.code, tabKey: key, file: screen.file, message: `duplicate tab key "${key}" (${tabs.length} « Onglet lié » bullets share the label-derived key) — labels must be distinct within a screen`, }) } } if (dupes === 0) ok++ } // --- Per-tab prerequisites --- for (const tab of screen.declaredTabs) { const base = { screenCode: screen.code, tabKey: tab.key, file: screen.file } // RTV-001: related entity exists const related = spec.includeCrossModule ? findEntity(graph, tab.relatedEntity, moduleKey) : graph.entities.find( (e) => e.name === tab.relatedEntity && e.module.toUpperCase() === moduleKey.toUpperCase(), ) if (!related) { violate({ rule: 'RTV-001', severity: 'err', ...base, message: `related entity ${tab.relatedEntity} not found in entité.md${spec.includeCrossModule ? ' (any module)' : ` (${moduleKey})`} — model it first (/ba-create-data-model)`, }) } else { ok++ // RTV-002: back-pointing FK exists on the related entity const backRelation = related.relations.find( (r) => r.targetEntity === screen.entity && r.fk.toLowerCase() === tab.relationFk.toLowerCase(), ) if (!backRelation) { violate({ rule: 'RTV-002', severity: 'err', ...base, message: `no Relations entry on ${tab.relatedEntity} pointing back to ${screen.entity ?? '(no entity)'} with FK ${tab.relationFk} — add \`${tab.relatedEntity} *→1 ${screen.entity} — FK ${pascalFirst(tab.relationFk)}, scope same-module, onDelete restrict\` to entité.md`, }) } else { ok++ } } // RTV-003: screen target resolves to a list screen bound to relatedEntity const rtv003 = checkTargetResolution(tab, related, screens, spec) if (rtv003) { violate({ rule: 'RTV-003', severity: 'err', ...base, message: rtv003 }) } else { ok++ } // RTV-004: declared permission exists verbatim in rbac.md if (tab.permission !== undefined) { if (!rbac.has(tab.permission)) { violate({ rule: 'RTV-004', severity: 'warn', ...base, message: `permission \`${tab.permission}\` not found verbatim in any ${spec.includeCrossApp ? '*' : spec.app}/*/rbac.md — declare it (/ba-create-rbac) or fix the tab`, }) } else { ok++ } } // RTV-008: canonical-shape projection passes PageRelatedTabSchema const shape = canonicalShape(tab, related, screens, spec) const parsed = PageRelatedTabSchema.safeParse(shape) if (!parsed.success) { const issue = parsed.error.issues[0] violate({ rule: 'RTV-008', severity: 'err', ...base, message: `canonical relatedTab shape rejected by PageRelatedTabSchema — ${issue.path.join('.') || 'root'}: ${issue.message}`, }) } else { ok++ } } // --- RTV-007: detail/edit form with incoming 1:N but no tabs and no marker --- if (screen.entity) { const entity = findEntity(graph, screen.entity, moduleKey) const incoming = entity ? incomingOf(graph, screen.entity, entity.module, spec.includeCrossModule).filter( (r) => r.cardinality === '*→1', ) : [] if (incoming.length >= 1 && screen.declaredTabs.length === 0) { if (screen.noRelatedTabsReason !== undefined) { ok++ // justified — the marker is the documented escape } else { violate({ rule: 'RTV-007', severity: 'err', screenCode: screen.code, file: screen.file, message: `${screen.entity} has ${incoming.length} incoming 1:N relation(s) (${incoming.map((r) => `${r.sourceEntity}.${r.fk}`).join(', ')}) but the ${mode} form declares NO related tab — add « Onglet lié » bullets or justify with \`- **Sans onglets liés** : \``, }) } } else if (incoming.length >= 1) { ok++ } } // --- RTV-009: the SHARED tab bar stays within budget (authoring time) --- // Inner « Onglet » bullets and « Onglet lié » bullets render on ONE // TabStrip; band-bound bullets (affichage summary, unless a pagespec // opts them back — lib placementForDisplayMode) occupy no trigger. // Unified approximation: a detail-mode screen whose entity also has a // create SmartForm sibling renders its field tabs as section cards // (field term 0) — a later `direct` opt-out under-counts HERE and is // caught authoritatively by RTV-108 (fail-open in the safe direction). { const stripRelated = screen.declaredTabs.filter( (t) => placementForDisplayMode(t.displayMode) === 'tab', ).length const hasCreateSibling = screen.entity !== undefined && screens.some( (s) => s !== screen && s.screenType === 'SmartForm' && s.entity === screen.entity && effectiveMode(s) === 'create', ) const fieldTerm = mode === 'detail' && hasCreateSibling ? 0 : screen.fieldTabLabels.length const n = fieldTerm + stripRelated if (n > DETAIL_TAB_BUDGET_WARN) { violate({ rule: 'RTV-009', severity: n > DETAIL_TAB_BUDGET_ERR ? 'err' : 'warn', screenCode: screen.code, file: screen.file, message: `${n} onglets sur une seule barre (${fieldTerm} « Onglet » + ${stripRelated} « Onglet lié » hors cartouches) — ` + `inner and related tabs share the tab bar (> ${n > DETAIL_TAB_BUDGET_ERR ? DETAIL_TAB_BUDGET_ERR : DETAIL_TAB_BUDGET_WARN}). ` + `Do NOT drop authored tabs (PRD-103): switch inert satellites to \`affichage summary\` ` + `(they render as band cartouches above the strip, not tabs) and/or regroup own fields ` + `with « Section « … » » bullets (they read above the bar on the unified fiche).`, }) } else { ok++ } } } // --- Pagespec cross-check --- if (ctx.pagespecs !== undefined) { if (ctx.pagespecs === null) { warnings.push( `pagespecs: true but ${spec.app}/${spec.module}/pagespecs/ does not exist — cross-check skipped (run /ba-create-prd first)`, ) } else { ok += crossCheckPagespecs({ pagespecs: ctx.pagespecs, formScreens, violations, warnings, spec, graph, screens, pagespecsOf: ctx.pagespecsOf, }) } } const err = violations.filter((v) => v.severity === 'err').length const warn = violations.filter((v) => v.severity === 'warn').length return { mode: 'validate', app: spec.app, module: spec.module, screens: formScreens.map((s) => s.code), violations, summary: { err, warn, ok }, warnings, } } function pascalFirst(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1) } /** RTV-003 core: returns the violation message, or null when the target resolves. */ function checkTargetResolution( tab: DeclaredRelatedTab, related: ParsedEntity | undefined, screens: ParsedScreen[], spec: DeriveRelatedTabsInput, ): string | null { if (tab.targetScreen) { const target = screens.find((s) => s.code === tab.targetScreen) if (!target) { return `target screen ${tab.targetScreen} not found in any ${spec.includeCrossApp ? 'BA tree' : spec.app} screen.md` } if (target.screenType !== 'SmartListView' && target.screenType !== 'SmartCard') { return `target screen ${tab.targetScreen} is a ${target.screenType} — a related tab must target a SmartListView or SmartCard` } if (target.entity !== tab.relatedEntity) { return `target screen ${tab.targetScreen} is bound to ${target.entity ?? '(no entity)'}, not ${tab.relatedEntity}` } return null } // No explicit target — it must at least be resolvable. const { module: preferredModule, app: preferredApp } = targetPreference(related, spec) const resolution = resolveListTarget(screens, tab.relatedEntity, preferredModule, preferredApp) if (!resolution.resolved) { const scope = spec.includeCrossApp ? 'the BA tree' : spec.app return `no target screen declared and no SmartListView/SmartCard bound to ${tab.relatedEntity} exists in ${scope} — create the list screen or point the tab at one` } return null } /** * Where to look FIRST for a tab's target list screen: the related entity's own * application and module, read off the `APP/MODULE` path the entity carries. Falls back * to the scoped app/module when the entity itself did not resolve. One helper so the * RTV-003 resolution and the canonical projection can never prefer different screens — * a divergence there would mean validating one target and generating another. */ function targetPreference( related: ParsedEntity | undefined, spec: DeriveRelatedTabsInput, ): { module: string; app: string } { if (!related) return { module: spec.module, app: spec.app } const parts = related.module.split('/') return { module: parts[parts.length - 1], app: parts.length >= 2 ? parts[0] : spec.app, } } /** Project a declared bullet to the canonical pagespec relatedTab shape (RTV-008). */ export function canonicalShape( tab: DeclaredRelatedTab, related: ParsedEntity | undefined, screens: ParsedScreen[], spec: DeriveRelatedTabsInput, ): Record { const { module: preferredModule, app: preferredApp } = targetPreference(related, spec) const resolution = tab.targetScreen ? { screen: screens.find((s) => s.code === tab.targetScreen) } : resolveListTarget(screens, tab.relatedEntity, preferredModule, preferredApp) const targetScreen = resolution.screen const relatedModule = (targetScreen?.module ?? spec.module).toLowerCase() const relatedSection = targetScreen ? lastSectionSegment(targetScreen.section) : toKebabCase(pluralize(tab.relatedEntity)) const shape: Record = { key: tab.key, displayMode: tab.displayMode, relatedEntity: tab.relatedEntity, relationFk: tab.relationFk, relatedModule, relatedSection, } // The application is emitted ONLY when it is not the page's own — the shape of every // pre-existing tab is then untouched, and `relatedApp` says something the reader could // not otherwise assume. Read off the RESOLVED target screen, never off the bullet. // Ground truth first (where the resolved target screen actually lives), the authored // `app` token as the fallback for a target the screen registry could not resolve. const targetApp = targetScreen?.app?.toLowerCase() ?? tab.relatedApp?.toLowerCase() if (targetApp && targetApp !== spec.app.toLowerCase()) shape.relatedApp = targetApp if (tab.targetScreen !== undefined) shape.targetScreen = tab.targetScreen if (tab.permission !== undefined) shape.permission = tab.permission return shape } /** * Route identity of a list pagespec (`.list.md`): the menu `section` * plus the optional `routeFamily` (sub-view pattern). Indexed once per * cross-check run so the per-tab RTV-105 resolution is O(1). */ interface ListPagespecRoute { file: string section?: string routeFamily?: string } function indexListPagespecs(pagespecs: Map): { byEntity: Map listCountBySection: Map } { const byEntity = new Map() const listCountBySection = new Map() for (const [filename, content] of pagespecs) { const m = /^([A-Z][A-Za-z0-9]*)\.list\.md$/.exec(filename) if (!m) continue const json = extractFencedJson(content) if (json === null) continue let parsed: unknown try { parsed = JSON.parse(json) } catch { continue // malformed JSON is reported by the per-screen cross-check } const obj = parsed && typeof parsed === 'object' ? (parsed as Record) : {} const route: ListPagespecRoute = { file: filename, section: typeof obj.section === 'string' ? obj.section : undefined, routeFamily: typeof obj.routeFamily === 'string' ? obj.routeFamily : undefined, } byEntity.set(m[1], route) if (route.section) { listCountBySection.set(route.section, (listCountBySection.get(route.section) ?? 0) + 1) } } return { byEntity, listCountBySection } } /** * screen.md ↔ pagespec relatedTabs[] cross-check per detail/edit view. * Returns the number of passed evaluations (for the ok counter). */ function crossCheckPagespecs(args: { pagespecs: Map formScreens: ParsedScreen[] violations: RelatedTabViolation[] warnings: string[] spec: DeriveRelatedTabsInput graph: RelationGraph screens: ParsedScreen[] pagespecsOf?: (app: string, module: string) => Map | null }): number { const { pagespecs, formScreens, violations, warnings, spec, graph, screens, pagespecsOf } = args let ok = 0 const ownListIndex = indexListPagespecs(pagespecs) const ownApp = spec.app.toLowerCase() const ownModule = spec.module.toLowerCase() /** * Where a tab's target actually lives, from the ground truth rather than from the tab: * the related entity's own `APP/MODULE` path, falling back to the resolved list screen. * Undefined when neither knows — the tab is then simply not held to RTV-109. */ const targetAppOf = (relatedEntity: string): string | undefined => { const entity = graph.entities.find((e) => e.name === relatedEntity || e.code === relatedEntity) const fromEntity = entity?.module.split('/') if (fromEntity && fromEntity.length >= 2) return fromEntity[0].toLowerCase() const screen = screens.find( (sc) => (sc.screenType === 'SmartListView' || sc.screenType === 'SmartCard') && sc.entity === relatedEntity, ) return screen?.app?.toLowerCase() } /** * The list-pagespec index a tab's routing must be resolved against: this module's own * when the tab stays home, the TARGET module's otherwise. Memoized — a page with six * tabs pointing at the same module must not read that directory six times. */ const indexCache = new Map | null>() const indexFor = (app: string, module: string): ReturnType | null => { if (app === ownApp && module === ownModule) return ownListIndex const key = `${app}/${module}` if (!indexCache.has(key)) { const loaded = pagespecsOf?.(app, module) ?? null indexCache.set(key, loaded ? indexListPagespecs(loaded) : null) } return indexCache.get(key) ?? null } /** The pagespec FILE SET a tab's create/detail surfaces must be looked up in. */ const filesFor = (app: string, module: string): Map | null => { if (app === ownApp && module === ownModule) return pagespecs return pagespecsOf?.(app, module) ?? null } for (const screen of formScreens) { const mode = effectiveMode(screen) if (mode === 'create' || !screen.entity) continue const filename = `${screen.entity}.${mode}.md` const content = pagespecs.get(filename) if (content === undefined) { warnings.push(`${filename}: pagespec not found — cross-check of ${screen.code} skipped`) continue } const json = extractFencedJson(content) if (json === null) { warnings.push(`${filename}: no fenced \`\`\`json block — cross-check of ${screen.code} skipped`) continue } let parsed: unknown try { parsed = JSON.parse(json) } catch (err) { warnings.push(`${filename}: invalid JSON (${(err as Error).message}) — cross-check of ${screen.code} skipped`) continue } const obj = parsed && typeof parsed === 'object' ? (parsed as Record) : {} const config = obj.config && typeof obj.config === 'object' ? (obj.config as Record) : {} const rawTabs = Array.isArray(obj.relatedTabs) ? (obj.relatedTabs as unknown[]) : Array.isArray(config.relatedTabs) ? (config.relatedTabs as unknown[]) : [] // Parse pagespec tabs; schema rejections are violations (RTV-104). const psTabs = new Map() rawTabs.forEach((raw, index) => { const result = PageRelatedTabSchema.safeParse(raw) if (!result.success) { const issue = result.error.issues[0] const key = raw && typeof raw === 'object' && typeof (raw as { key?: unknown }).key === 'string' ? String((raw as { key: string }).key) : `#${index}` violations.push({ rule: 'RTV-104', severity: 'err', screenCode: screen.code, tabKey: key, file: filename, message: `pagespec relatedTabs[${index}] rejected by PageRelatedTabSchema — ${issue.path.join('.') || 'root'}: ${issue.message}`, source: 'pagespec', }) return } ok++ const tab = result.data as PageRelatedTab psTabs.set(tab.key, tab) const base = { screenCode: screen.code, tabKey: tab.key, file: filename, source: 'pagespec' as const } // --- RTV-109: a target outside this application must SAY so --- // The generator builds `@/features/{app}/{module}/…` and // `@/extensions/{app}-{module}Routes` from `relatedApp ?? the page's own app`. // Left unsaid, a foreign target yields paths that do not exist and the page does // not compile — so this is a build-time break, not a cosmetic omission. const actualApp = targetAppOf(tab.relatedEntity) const declaredApp = tab.relatedApp?.toLowerCase() ?? ownApp if (actualApp && actualApp !== ownApp && declaredApp !== actualApp) { violations.push({ rule: 'RTV-109', severity: 'err', ...base, message: tab.relatedApp === undefined ? `${tab.relatedEntity} lives in application "${actualApp}", not "${ownApp}" — the tab must carry \`"relatedApp": "${actualApp}"\`, else the generated hook and routes imports point at \`@/features/${ownApp}/${tab.relatedModule}\`, a path that does not exist` : `relatedApp "${tab.relatedApp}" contradicts where ${tab.relatedEntity} lives ("${actualApp}") — copy the target's application verbatim`, }) } else if (actualApp) { ok++ } // Which module's pagespecs answer for this tab: its own when it stays home, the // TARGET's otherwise. Cross-module and cross-application tabs are held to the same // routing checks as everyone else — they used to be exempt purely because nobody // had loaded the directory that could have answered. const tabApp = tab.relatedApp?.toLowerCase() ?? actualApp ?? ownApp const tabModule = tab.relatedModule.toLowerCase() const isForeign = tabApp !== ownApp || tabModule !== ownModule const listIndex = indexFor(tabApp, tabModule) const tabFiles = filesFor(tabApp, tabModule) // --- RTV-105: route-family resolution (the sub-view mis-routing guard) --- const targetRoute = listIndex ? listIndex.byEntity.get(tab.relatedEntity) : undefined if (!listIndex || !targetRoute) { warnings.push( listIndex === null ? `${filename} · tab "${tab.key}": module ${tabApp}/${tabModule} has no pagespecs/ directory in the BA tree — route family unresolvable (RTV-105 skipped)` : `${filename} · tab "${tab.key}": no ${tab.relatedEntity}.list.md pagespec in ${isForeign ? `${tabApp}/${tabModule}` : 'this module'} — route family unresolvable (RTV-105 skipped)`, ) } else { const expectedFamily = targetRoute.routeFamily ?? targetRoute.section const subViewSiblings = targetRoute.section ? (listIndex.listCountBySection.get(targetRoute.section) ?? 0) : 0 if (targetRoute.routeFamily === undefined && subViewSiblings >= 2) { violations.push({ rule: 'RTV-105', severity: 'err', ...base, message: `sub-view pattern: ${subViewSiblings} list pagespecs share section "${targetRoute.section}" but ${targetRoute.file} carries no \`routeFamily\` — every satellite must declare its route family (the slug scaffold-routes keys the *Routes.ts family with), else this tab navigates to the porteur's pages`, }) } else if (tab.relatedRouteFamily !== undefined && tab.relatedRouteFamily !== expectedFamily) { violations.push({ rule: 'RTV-105', severity: 'err', ...base, message: `relatedRouteFamily "${tab.relatedRouteFamily}" ≠ the target's route family "${expectedFamily}" (${targetRoute.file} routeFamily ?? section) — copy it verbatim, never re-derive`, }) } else if (tab.relatedRouteFamily === undefined && expectedFamily !== undefined && expectedFamily !== tab.relatedSection) { violations.push({ rule: 'RTV-105', severity: 'err', ...base, message: `tab must carry \`"relatedRouteFamily": "${expectedFamily}"\` — the target's route family (${targetRoute.file}) differs from relatedSection "${tab.relatedSection}", so the legacy fallback would navigate to the WRONG family`, }) } else { ok++ } } // --- RTV-106 / RTV-107: withCreate / withRowOpen honesty --- // Resolved in the TARGET module. A null file set means that module is not authored // on disk at all — nothing can be concluded, so neither rule fires (the RTV-105 // warning above already says the target could not be reached). const where = isForeign ? ` in ${tabApp}/${tabModule}` : '' if (tab.withCreate === true && tabFiles && !tabFiles.has(`${tab.relatedEntity}.form.md`)) { violations.push({ rule: 'RTV-106', severity: 'err', ...base, message: `withCreate: true but no ${tab.relatedEntity}.form.md pagespec exists${where} — no create form is generatable for this entity (audit journal / business-flow satellite?): author \`"withCreate": false\``, }) } if (tab.withRowOpen === true && tabFiles && !tabFiles.has(`${tab.relatedEntity}.detail.md`)) { violations.push({ rule: 'RTV-107', severity: 'err', ...base, message: `withRowOpen: true but no ${tab.relatedEntity}.detail.md pagespec exists${where} — rows would navigate to a missing detail surface: author \`"withRowOpen": false\` (inert rows)`, }) } }) const screenKeys = new Set(screen.declaredTabs.map((t) => t.key)) // RTV-101: declared in screen.md, absent from the pagespec. for (const tab of screen.declaredTabs) { const ps = psTabs.get(tab.key) if (!ps) { violations.push({ rule: 'RTV-101', severity: 'err', screenCode: screen.code, tabKey: tab.key, file: filename, message: `tab "${tab.key}" declared in screen.md but missing from the pagespec relatedTabs[] — re-run /ba-create-prd for this page`, source: 'pagespec', }) continue } // RTV-103: fk / target / displayMode mismatch. const diffs: string[] = [] if (ps.relationFk !== tab.relationFk) { diffs.push(`relationFk screen.md "${tab.relationFk}" ≠ pagespec "${ps.relationFk}"`) } if (tab.targetScreen && ps.targetScreen !== tab.targetScreen) { diffs.push(`targetScreen screen.md "${tab.targetScreen}" ≠ pagespec "${ps.targetScreen ?? '(none)'}"`) } if (ps.displayMode !== tab.displayMode) { diffs.push(`displayMode screen.md "${tab.displayMode}" ≠ pagespec "${ps.displayMode}"`) } if (diffs.length > 0) { violations.push({ rule: 'RTV-103', severity: 'err', screenCode: screen.code, tabKey: tab.key, file: filename, message: `tab "${tab.key}" drifted between screen.md and the pagespec: ${diffs.join(' ; ')}`, source: 'pagespec', }) } else { ok++ } } // RTV-102: in the pagespec, absent from screen.md. for (const key of psTabs.keys()) { if (!screenKeys.has(key)) { violations.push({ rule: 'RTV-102', severity: 'err', screenCode: screen.code, tabKey: key, file: filename, message: `pagespec tab "${key}" has no matching « Onglet lié » bullet in screen.md — screen.md is the BA authority, align it or drop the pagespec tab`, source: 'pagespec', }) } } } // --- RTV-108: the RENDERED tab bar of every detail pagespec stays within --- // budget (the authoritative leg — PRD-133). Runs on the pagespec map // DIRECTLY (a screen-less pagespec is still budgeted); the strip resolution // is lib/detail-tab-strip's, the exact mirror of scaffold-component: // unified fiche → field-tab term 0, band cartouches excluded, the synthetic // « info » trigger counted, Zod-rejected entries out (RTV-104's mass). const entityViews = entityViewsFromPagespecFilenames(pagespecs.keys()) for (const [filename, content] of [...pagespecs].sort(([a], [b]) => a.localeCompare(b))) { const nameMatch = /^([A-Z][A-Za-z0-9]*)\.detail\.md$/.exec(filename) if (!nameMatch) continue const json = extractFencedJson(content) if (json === null) continue // already warned by the per-screen cross-check let parsed: unknown try { parsed = JSON.parse(json) } catch { continue // idem } const obj = parsed && typeof parsed === 'object' ? (parsed as Record) : {} const config = obj.config && typeof obj.config === 'object' ? (obj.config as Record) : {} const block: Record = { ...obj, tabs: Array.isArray(obj.tabs) ? obj.tabs : config.tabs, relatedTabs: Array.isArray(obj.relatedTabs) ? obj.relatedTabs : config.relatedTabs, } const strip = resolveDetailStrip(block, entityViews.get(nameMatch[1])) if (strip.stripTotal > DETAIL_TAB_BUDGET_WARN) { const screenCode = formScreens.find((s) => s.entity === nameMatch[1] && effectiveMode(s) !== 'create')?.code ?? nameMatch[1] violations.push({ rule: 'RTV-108', severity: strip.stripTotal > DETAIL_TAB_BUDGET_ERR ? 'err' : 'warn', screenCode, file: filename, message: `the rendered tab bar carries ${strip.stripTotal} triggers (${strip.fieldTabCount} field tab(s)` + `${strip.syntheticInfoTab ? ' + the synthetic « info » tab' : ''} + ${strip.stripRelatedCount} related tab(s)` + `${strip.bandCount > 0 ? `; ${strip.bandCount} band cartouche(s) excluded` : ''}) — beyond ` + `${strip.stripTotal > DETAIL_TAB_BUDGET_ERR ? DETAIL_TAB_BUDGET_ERR : DETAIL_TAB_BUDGET_WARN} the 360 fiche is unreadable. ` + `Do NOT remove authored tabs (PRD-103): switch inert satellites to band cartouches ` + `(\`"placement": "band"\`, the default for \`"displayMode": "summary"\` — count + view-all above the strip) ` + `and/or regroup own fields as \`sections[]\` (on the unified fiche they read above the bar, ` + `freeing the whole strip for the 360).`, source: 'pagespec', }) } else { ok++ } } return ok } // --------------------------------------------------------------------------- // IO loader // --------------------------------------------------------------------------- /** * Read `/pagespecs/*.md` → `Map`. * Returns `null` when the pagespecs/ directory is missing (→ warning upstream). */ export function loadPagespecs(moduleDir: string): Map | null { const dir = join(moduleDir, 'pagespecs') if (!existsSync(dir)) return null const out = new Map() let entries: string[] try { entries = readdirSync(dir) } catch { return out } for (const name of entries.sort()) { if (!name.endsWith('.md')) continue try { out.set(name, readFileSync(join(dir, name), 'utf8')) } catch { // unreadable pagespec — the cross-check will warn per screen } } return out }