/** * cli:scaffold-extension-search — build-spec.ts * * Pure transformer: assembles an ExtensionSearchSpec from already-fetched BA * data (no DB / IPC). The caller (the develop pipeline) loads the menu sections * (route/label/icon), the permission paths, and the list screens that bind an * entity to a section, then calls `buildExtensionSearchSpec(...)` and drops the * result to a temp file for `scaffold-extension-search --spec-file `. * * The entity↔section binding comes from the SmartListView screens (pagespecs): * each list screen surfaces ONE entity in ONE section, which is exactly the set * a user expects to find by searching. */ import { pluralize } from '../../../../../lib/string-utils.js'; import type { ExtensionSearchSpec, RowScope, SearchEntity } from './types.js'; export interface SearchSectionInput { /** Section code (e.g. `liste`). */ code: string; moduleCode: string; /** Navigation application code (e.g. `todo`). */ appCode: string; label: string; icon?: string; /** Section route (e.g. `/todo/taches/liste`). Defaults to `/{app}/{module}/{section}`. */ route?: string; } export interface SearchListScreenInput { /** Entity type — simple (`Task`) or qualified (`Test.Domain.Entities.Task`). */ entityName: string; /** The section this list screen lives in. */ sectionCode: string; /** Entity is `ITenantEntity` (default true → explicit tenant filter). */ tenantScoped?: boolean; /** Optional row-level scope mirroring the list handler. */ rowScope?: RowScope; order?: number; } /** The entity's row-level data scope, as given to scaffold-entity/scaffold-data-scope. */ export interface EntityDataScopeInput { mode: 'own' | 'assigned' | 'own-assigned'; ownerProperty: string; assignedProperty: string; } export interface BuildExtensionSearchSpecInput { appCode: string; projectPath: string; contextType?: string; /** Namespace used to fully-qualify simple entity names (e.g. `Test.Domain.Entities`). */ entityNamespace?: string; sections: SearchSectionInput[]; /** The project's 4-segment permission paths (used to warn when a section `.read` is missing). */ permissions: string[]; listScreens: SearchListScreenInput[]; /** * Data scopes per SIMPLE entity name (`Task`, not qualified) — the same * `dataScope` values passed to scaffold-entity. When an entity has one and * its list screen carries no explicit `rowScope`, the search registration * DERIVES it: `bypassPermission = "{section-read}.all"`, * `ownerProperty` per mode — so search can NEVER reveal a row the scoped * list hides. An explicit `screen.rowScope` always wins. */ dataScopes?: Record; } export interface BuildExtensionSearchResult { spec: ExtensionSearchSpec; warnings: string[]; } export function buildExtensionSearchSpec(input: BuildExtensionSearchSpecInput): BuildExtensionSearchResult { const warnings: string[] = []; const permSet = new Set(input.permissions); const sectionByCode = new Map(input.sections.map((s) => [s.code, s])); const entities: SearchEntity[] = []; const seenKeys = new Set(); for (const screen of input.listScreens) { const section = sectionByCode.get(screen.sectionCode); if (!section) { warnings.push(`List screen for "${screen.entityName}" references unknown section "${screen.sectionCode}" — skipped.`); continue; } const simpleName = screen.entityName.split('.').pop()!; const permission = `${section.appCode}.${section.moduleCode}.${section.code}.read`; if (!permSet.has(permission)) { warnings.push(`Permission "${permission}" not found for "${screen.entityName}" — the category stays hidden until that ".read" is seeded.`); } let categoryKey = pluralize(simpleName).toLowerCase(); if (seenKeys.has(categoryKey)) { // Disambiguate a duplicate plural across modules by prefixing the module. categoryKey = `${section.moduleCode}-${categoryKey}`; } seenKeys.add(categoryKey); const baseRoute = (section.route ?? `/${section.appCode}/${section.moduleCode}/${section.code}`).replace(/\/$/, ''); const entityName = input.entityNamespace && !screen.entityName.includes('.') ? `${input.entityNamespace}.${screen.entityName}` : screen.entityName; // ── Row scope — derived from the entity's data scope when not explicit ── // A scoped entity registered WITHOUT RestrictTo would leak through search // every row its list hides. Deterministic mirror of the DataScopePolicy: // bypass = the sibling ".read.all"; property per mode. Explicit wins. let rowScope: RowScope | undefined = screen.rowScope; const ds = input.dataScopes?.[simpleName]; if (!rowScope && ds) { const ownerProperty = ds.mode === 'assigned' ? ds.assignedProperty : ds.ownerProperty; rowScope = { bypassPermission: `${permission}.all`, ownerProperty }; if (ds.mode === 'own-assigned') { warnings.push( `"${simpleName}": own-assigned scope — search RestrictTo mirrors the OWNER column only ` + `(${ds.ownerProperty}); assigned-only rows won't surface in search until the registration ` + `is hand-tuned (safe direction: search never reveals more than the list).`, ); } } entities.push({ entityName, categoryKey, label: section.label, icon: section.icon ?? 'Search', permission, route: `${baseRoute}/{id}`, tenantScoped: screen.tenantScoped ?? true, rowScope, order: screen.order ?? 100, }); } return { spec: { appCode: input.appCode, contextType: input.contextType ?? 'ExtensionsDbContext', projectPath: input.projectPath, entities, }, warnings, }; }