/** * cli:derive-related-tabs — derive.ts (mode=derive, pure logic). * * For each detail/edit SmartForm screen of the scoped app/module (NEVER a * create-mode form — no parent id exists yet), emit CANDIDATE related tabs * from the relation graph: * * - `incoming-1n` → a `*→1` relation targeting the screen's entity (the * related entity carries the FK back). `1→1` incoming is * included with a `summary` suggestion — and so is an INERT * `*→1` satellite (list target resolved, but no SmartForm * bound to it anywhere in the app's screens): with nothing * to create and no fiche to open, a count cartouche says * everything. The CLI emits displayMode only, never * `placement` — the renderer's derived default lands * `summary` in the band row above the strip (§43). * - `outgoing-n1` → the screen entity's OWN FK (`*→1`/`1→1` written on it). * `defaultInclude: false` — the form's lookup field already * covers the relation; a summary cartouche is the most a * 360 view should add. * - `nm-via` → the incoming FK belongs to a junction entity: surface the * FAR side as the candidate, `viaEntity` = the junction. * * `scope core` relations never produce candidates. Everything here is a * SUGGESTION for the /ba-create-screen intelligence — `displayModeReason` is * always filled so the skill (and the user) can override with judgment. */ import { pluralize, toKebabCase } from '../../../../lib/string-utils.js' import { findEntity, incomingOf, isJunction, normalizeModulePath, outgoingOf, type EntityRelation, type ParsedEntity, type RelationGraph, } from './relations.js' import { effectiveMode, hasFormScreen, resolveListTarget, type ParsedScreen } from './screens.js' import type { DeriveReport, DeriveRelatedTabsInput, PermissionResolution, RelatedTabCandidate, RelatedTabDisplayMode, ScreenTargetResolution, } from './types.js' export interface DeriveContext { spec: DeriveRelatedTabsInput graph: RelationGraph screens: ParsedScreen[] rbac: Set } const ADDRESS_LIKE_RE = /street|rue|zip|npa|postal|city|ville|pays|adresse/i const STATUS_NAME_RE = /status|statut|état|etat/i function camelFk(fk: string): string { return fk.charAt(0).toLowerCase() + fk.slice(1) } function matchesSection(screen: ParsedScreen, section?: string): boolean { if (!section) return true return screen.section === section || screen.section.startsWith(`${section}/`) } /** * Display-mode heuristic (SUGGESTION only — the reason is always filled): * 1→1 is handled by the caller (summary). Then: * - classification composant/lookup with ≤4 attributes, OR ≥2 address-like * attribute names → cards * - ≥5 attributes or a status enum attribute → table * - default table. NEVER 'report' (no rendering surface in v1). */ export function suggestDisplayMode(related: ParsedEntity | undefined): { mode: RelatedTabDisplayMode reason: string } { if (!related) { return { mode: 'table', reason: 'default table — related entity not found in entité.md' } } const attrs = related.attributes const addressLike = attrs.filter((a) => ADDRESS_LIKE_RE.test(a.name)).length const cls = (related.classification ?? '').toLowerCase() if ((cls.includes('composant') || cls.includes('lookup')) && attrs.length <= 4) { return { mode: 'cards', reason: `related entity is a ${related.classification} with ${attrs.length} attribute(s) (≤4) — card grid reads better than a sparse table`, } } if (addressLike >= 2) { return { mode: 'cards', reason: `${addressLike} address-like attribute names detected — card grid suits address-shaped records`, } } const statusEnum = attrs.find( (a) => /^enum/i.test(a.type) && STATUS_NAME_RE.test(a.name), ) if (statusEnum) { return { mode: 'table', reason: `related entity carries a status enum attribute (${statusEnum.name}) — table with a status column`, } } if (attrs.length >= 5) { return { mode: 'table', reason: `related entity has ${attrs.length} attributes (≥5) — table`, } } return { mode: 'table', reason: 'default display mode — table' } } /** Last path segment of a section (`clients/contrats` → `contrats`). */ function lastSectionSegment(section: string): string { const parts = section.split('/').filter(Boolean) return parts[parts.length - 1] ?? section } /** * Permission resolution order: * 1. the resolved target screen's own `Permission` bullet, verbatim; * 2. a `.read`-suffixed rbac.md permission whose section segment matches the * related section (target screen's section, else the kebab plural of the * related entity); * 3. unresolved. */ export function resolvePermission( targetScreen: ParsedScreen | undefined, relatedEntity: string, rbac: Set, ): PermissionResolution { if (targetScreen?.permission) { return { resolved: true, value: targetScreen.permission, source: 'target-screen' } } const sectionSegment = targetScreen ? lastSectionSegment(targetScreen.section) : toKebabCase(pluralize(relatedEntity)) const matches = [...rbac] .filter((p) => p.endsWith('.read') && p.split('.').includes(sectionSegment)) .sort() if (matches.length > 0) { return { resolved: true, value: matches[0], source: 'rbac' } } return { resolved: false } } /** Module label for a candidate: the spec module code when local, `APP/MOD` otherwise. */ function moduleLabel(entityModule: string, moduleKey: string, specModule: string): string { return normalizeModulePath(entityModule) === normalizeModulePath(moduleKey) ? specModule : entityModule } /** * Application CODE of a module label, kebab-cased — `SALES/ORDERS` → `sales`. A bare module * code carries no application, so the scoped one applies. Returns undefined when the * application IS the scoped one: `relatedApp` is only ever emitted when it says something * the reader could not already assume. */ function relatedAppOfLabel(moduleLabelOrPath: string, specApp: string): string | undefined { const parts = moduleLabelOrPath.split('/') if (parts.length < 2) return undefined const app = parts[0].toLowerCase() return app === specApp.toLowerCase() ? undefined : app } /** Module CODE (for target preference): `SALES/ORDERS` → `ORDERS`, `CLIENTS` → `CLIENTS`. */ function moduleCode(moduleLabelOrPath: string): string { const parts = moduleLabelOrPath.split('/') return parts[parts.length - 1] } function isAlreadyDeclared( forScreens: ParsedScreen[], candidate: { relatedEntity: string; relationFk: string; viaEntity?: string }, ): boolean { const fk = candidate.relationFk.toLowerCase() return forScreens.some((s) => s.declaredTabs.some( (t) => t.relationFk.toLowerCase() === fk && (t.relatedEntity === candidate.relatedEntity || (candidate.viaEntity !== undefined && t.relatedEntity === candidate.viaEntity)), ), ) } export function deriveCandidates(ctx: DeriveContext): DeriveReport { const { spec, graph, screens, rbac } = ctx const moduleKey = `${spec.app}/${spec.module}` const warnings: string[] = [] // --- 1. Scope the detail/edit SmartForm screens --- const formScreens = screens .filter( (s) => // App AND module. The screen set now spans every application, and a module code is // only unique within its own — without this the homonymous module of another // application would be derived as if it were ours (the BUG A shape). s.app.toUpperCase() === spec.app.toUpperCase() && s.module.toUpperCase() === spec.module.toUpperCase() && s.screenType === 'SmartForm' && effectiveMode(s) !== 'create' && matchesSection(s, spec.section) && s.entity !== undefined && (spec.entity === undefined || s.entity === spec.entity), ) .sort((a, b) => a.code.localeCompare(b.code)) // --- 2. Group by entity --- const byEntity = new Map() for (const s of formScreens) { const bucket = byEntity.get(s.entity!) ?? [] bucket.push(s) byEntity.set(s.entity!, bucket) } const candidates: RelatedTabCandidate[] = [] for (const [entityName, entityScreens] of [...byEntity.entries()].sort((a, b) => a[0].localeCompare(b[0]), )) { const entity = findEntity(graph, entityName, moduleKey) if (!entity) { warnings.push( `${entityName}: entity not found in entité.md (${moduleKey}) — no candidates derived (screens: ${entityScreens.map((s) => s.code).join(', ')})`, ) continue } const usedKeys = new Set() const forScreens = entityScreens.map((s) => s.code) const pushCandidate = (c: Omit & { fkForKey: string }): void => { const { fkForKey, ...rest } = c const base = toKebabCase(pluralize(rest.relatedEntity)) let key = base if (usedKeys.has(key)) key = `${base}-${toKebabCase(fkForKey.replace(/Id$/, ''))}` usedKeys.add(key) // Computed HERE, once, rather than at each of the three construction sites: all // three already resolved `relatedModule` through `moduleLabel`, which is the only // thing that knows whether the target sits outside the scoped application. const relatedApp = relatedAppOfLabel(rest.relatedModule, spec.app) candidates.push({ ...rest, ...(relatedApp ? { relatedApp } : {}), suggestedKey: key }) if (!rest.screenTarget.resolved) { warnings.push( `${rest.forEntity} → ${rest.relatedEntity} (FK ${rest.relationFk}): no SmartListView/SmartCard bound to ${rest.relatedEntity} found in ${spec.includeCrossApp ? 'the BA tree' : spec.app} — create the list screen first or drop the tab`, ) } } // --- 3a. Incoming *→1 / 1→1 relations (the related entity carries the FK) --- const incoming = incomingOf(graph, entityName, entity.module, spec.includeCrossModule).sort( (a, b) => a.sourceEntity.localeCompare(b.sourceEntity) || a.fk.localeCompare(b.fk), ) for (const rel of incoming) { const sourceEntity = findEntity(graph, rel.sourceEntity, rel.module) // Junction → surface the FAR side (nm-via), never the junction itself. if (sourceEntity && isJunction(sourceEntity)) { const others = sourceEntity.relations.filter( (r) => r.cardinality === '*→1' && r.scope !== 'core' && !(r.targetEntity === rel.targetEntity && r.fk === rel.fk), ) if (others.length > 0) { for (const other of others.sort((a, b) => a.targetEntity.localeCompare(b.targetEntity))) { const farModulePath = other.scope === 'cross-module' && other.scopeDetail ? other.scopeDetail : sourceEntity.module const far = findEntity(graph, other.targetEntity, farModulePath) const heur = suggestDisplayMode(far) const relatedModule = moduleLabel(farModulePath, moduleKey, spec.module) const target = resolveListTarget( screens, other.targetEntity, moduleCode(relatedModule), relatedAppOfLabel(relatedModule, spec.app) ?? spec.app, ) const { screen: targetScreen, ...screenTarget } = target pushCandidate({ forEntity: entityName, forScreens, relatedEntity: other.targetEntity, relatedModule, relationFk: camelFk(rel.fk), relationKind: 'nm-via', viaEntity: sourceEntity.name, fkForKey: rel.fk, suggestedDisplayMode: heur.mode, displayModeReason: `${heur.reason} — N:M via junction ${sourceEntity.name}`, defaultInclude: true, screenTarget: screenTarget as ScreenTargetResolution, permission: resolvePermission(targetScreen, other.targetEntity, rbac), alreadyDeclared: isAlreadyDeclared(entityScreens, { relatedEntity: other.targetEntity, relationFk: camelFk(rel.fk), viaEntity: sourceEntity.name, }), }) } continue } // degenerate junction (no non-core far side) → fall through to incoming-1n } const relatedModule = moduleLabel(rel.module, moduleKey, spec.module) const target = resolveListTarget( screens, rel.sourceEntity, moduleCode(relatedModule), relatedAppOfLabel(relatedModule, spec.app) ?? spec.app, ) const { screen: targetScreen, ...screenTarget } = target const oneToOne = rel.cardinality === '1→1' // Inert-satellite rule (*→1 collections): a satellite with NO SmartForm // anywhere in the scanned screen set — no create form, no detail/edit // fiche — has nothing to DO from the porteur's page; a count + link says // everything. Gated on target.resolved so an unscanned surface // (cross-app satellite, module not yet authored) NEVER reads as inert — // there the existing "create the list screen first" warning already // fires and the attribute heuristic keeps the call. The suggestion emits // displayMode only; the renderer's derived default lands summary in the // band row (the CLI never authors `placement`). const heur = oneToOne ? { mode: 'summary' as RelatedTabDisplayMode, reason: '1→1 relation — a single related record; summary cartouche', } : target.resolved && !hasFormScreen(screens, rel.sourceEntity) ? { mode: 'summary' as RelatedTabDisplayMode, reason: `satellite sans création ni fiche — no SmartForm is bound to ${rel.sourceEntity} in the app's screen.md set: ` + 'un compte et un lien disent tout ce que la fiche porteuse a besoin d\'en savoir', } : suggestDisplayMode(sourceEntity) pushCandidate({ forEntity: entityName, forScreens, relatedEntity: rel.sourceEntity, relatedModule, relationFk: camelFk(rel.fk), relationKind: 'incoming-1n', fkForKey: rel.fk, suggestedDisplayMode: heur.mode, displayModeReason: heur.reason, defaultInclude: true, screenTarget: screenTarget as ScreenTargetResolution, permission: resolvePermission(targetScreen, rel.sourceEntity, rbac), alreadyDeclared: isAlreadyDeclared(entityScreens, { relatedEntity: rel.sourceEntity, relationFk: camelFk(rel.fk), }), }) } // --- 3b. Outgoing FKs (the screen entity's own *→1 / 1→1) --- const outgoing = outgoingOf(graph, entityName, entity.module) .filter( (r: EntityRelation) => r.scope !== 'core' && (r.cardinality === '*→1' || r.cardinality === '1→1') && (spec.includeCrossModule || r.scope !== 'cross-module'), ) .sort((a, b) => a.targetEntity.localeCompare(b.targetEntity) || a.fk.localeCompare(b.fk)) for (const rel of outgoing) { const targetModulePath = rel.scope === 'cross-module' && rel.scopeDetail ? rel.scopeDetail : entity.module const relatedModule = moduleLabel(targetModulePath, moduleKey, spec.module) const target = resolveListTarget( screens, rel.targetEntity, moduleCode(relatedModule), relatedAppOfLabel(relatedModule, spec.app) ?? spec.app, ) const { screen: targetScreen, ...screenTarget } = target pushCandidate({ forEntity: entityName, forScreens, relatedEntity: rel.targetEntity, relatedModule, relationFk: camelFk(rel.fk), relationKind: 'outgoing-n1', fkForKey: rel.fk, suggestedDisplayMode: 'summary', displayModeReason: "the form's own lookup field already covers this relation — summary only, excluded by default", defaultInclude: false, screenTarget: screenTarget as ScreenTargetResolution, permission: resolvePermission(targetScreen, rel.targetEntity, rbac), alreadyDeclared: isAlreadyDeclared(entityScreens, { relatedEntity: rel.targetEntity, relationFk: camelFk(rel.fk), }), }) } } return { mode: 'derive', app: spec.app, module: spec.module, screens: formScreens.map((s) => s.code), candidates, totals: { screens: formScreens.length, entities: byEntity.size, candidates: candidates.length, resolvedTargets: candidates.filter((c) => c.screenTarget.resolved).length, resolvedPermissions: candidates.filter((c) => c.permission.resolved).length, alreadyDeclared: candidates.filter((c) => c.alreadyDeclared).length, }, warnings, } }