/** * cli:audit-ba — rules/code.ts (CODE-001..009 — module scope). * Mirrors business-analyse/audit-cross-ref-code/SKILL.md. CODE-001..004 need * the generated-app source (projectRoot); greenfield → trivial pass, said out * loud. CODE-005..007 run ALWAYS (static catalogues, no scan — a client * project is never greenfield relative to SmartStack Core); CODE-008 runs * always too — its BA leg (the owner's entité.md) needs no scan, the code leg * joins when projectRoot is given. * * The Domain scan lives in `lib/domain-scan.ts` (shared with * create-data-model/derive-extension-request): every class carries its OWNER — * the `Entities///` folder scaffold-entity wrote it to. Ownership * is at the APPLICATION grain (doctrine 2026-10-03): a module's own classes and * its application's classes are never a collision; the other applications are. */ import { PERSON_IDENTITY_ATTRIBUTES, matchCoreEntity, matchPersonTrigger, matchReservedCoreName, } from '../../../../../lib/core-catalog.js' import { matchPlatformHrEntity } from '../../../../../lib/platform-catalog.js' import { matchCapabilityTrigger } from '../../../../../lib/capability-catalog.js' import { domainOf, extraAttributes, sameSegment, type DomainScan } from '../../../../../lib/domain-scan.js' import { EXTENSION_REQUESTS_FILE, STATUS_LABELS, expectedAccessRequests, expectedExtensionRequests, reconcileExtensionRequests, renderExtensionRequestTarget, type BaExtensionRequestsDoc, type ExtensionRequestExpectation, } from '../../../../../lib/ba-extension-requests.js' import type { BaEntity } from '../../../../../lib/ba-entities.js' import { loadScreens, type ParsedScreen } from '../../../../../lib/ba-screens.js' import type { CorpusModel, ModuleModel } from '../corpus/model.js' import { finding, fold, moduleScope, type RuleDef } from './registry.js' import type { Finding } from '../types.js' export { scanDomain, type DomainScan } from '../../../../../lib/domain-scan.js' const DIM = 'cross-ref-code' as const // --------------------------------------------------------------------------- // Rules // --------------------------------------------------------------------------- /** * FAIL-CLOSED distinction: projectRoot ABSENT ≠ greenfield. Without the root * we cannot claim the project has no code — the scan legs surface a warn, not * a green pass. With the root given and zero Domain files, greenfield is a * verified fact (trivial pass, said out loud). */ function scanLegFinding(id: string, m: ModuleModel, scan: DomainScan | null): Finding | null { if (scan === null) { return finding(id, DIM, 'warn', moduleScope(m), 'Code non scanné (projectRoot absent) — relancer avec `projectRoot` pour la jambe code ; seul le versant catalogue (CODE-005..007) a été vérifié.') } if (scan.filesScanned === 0) { return finding(id, DIM, 'ok', moduleScope(m), 'Projet greenfield côté code client (aucune classe Domain trouvée) — vérification trivialement passée ; les règles catalogue (CODE-005..007) tournent toujours.') } return null } /** The scan to read, OR the leg finding that replaces the rule — a discriminated pair so the caller's `scan` is typed non-null. */ function scanOrLeg(id: string, m: ModuleModel, projectRoot: string | undefined): { leg: Finding; scan: null } | { leg: null; scan: DomainScan } { const scan = domainOf(projectRoot) const leg = scanLegFinding(id, m, scan) if (leg !== null || scan === null) return { leg: leg ?? scanLegFinding(id, m, null)!, scan: null } return { leg: null, scan } } export const CODE_RULES: RuleDef[] = [ { id: 'CODE-001', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module, projectRoot }) { const m = module! const got = scanOrLeg('CODE-001', m, projectRoot) if (got.leg) return { findings: [got.leg] } const scan = got.scan // Four cases per (entity, class file) — the folder is the act of ownership // (`Entities///`, lib/app-classification.domainEntitiesDir): // own module → the entity's OWN generated class, regenerated by /ba-develop // own app, other mod → DUPLICATE: one declaring module per application (err — the // Domain namespace is flat, two homonymous classes do not compile) // other application → reuse it, never redeclare; extra columns = extension request // no owner → flat legacy layout / hand-written code — indeterminable const own: string[] = [] const intra: string[] = [] const foreign: string[] = [] const flat: string[] = [] for (const e of m.entities) { const owners = scan.owners.get(e.name) if (owners === undefined) continue for (const o of owners) { if (o.app === undefined) { flat.push(`${e.name} — ${o.file}`) } else if (!sameSegment(o.app, m.app)) { const extra = extraAttributes(e.attributes.map((a) => a.name), o.source) foreign.push( `${e.name} — ${o.file} (application ${o.app}${o.module !== undefined ? `/${o.module}` : ''})` + (extra.length > 0 ? ` ; attributs en plus déclarés ici : ${extra.join(', ')}` : ' ; aucun attribut en plus — pure réutilisation'), ) } else if (o.module !== undefined && !sameSegment(o.module, m.module)) { intra.push(`${e.name} — ${o.file} (module ${o.module} de la même application)`) } else { own.push(`${e.name} — ${o.file}`) } } } const findings: Finding[] = [] if (intra.length > 0) { findings.push( finding('CODE-001', DIM, 'err', moduleScope(m), 'Doublon intra-application — une entité est déclarée dans UN seul module de l’application, les autres la référencent (relation `scope cross-module (APP/MOD)`) ; le namespace Domain est plat, deux classes homonymes ne compilent pas. Si l’entité a changé de module, l’ancien fichier est un fantôme (DEV-DOM-006).', intra), ) } if (foreign.length > 0) { findings.push( finding('CODE-001', DIM, 'warn', moduleScope(m), 'Entité(s) déjà portée(s) par une AUTRE application — la réutiliser (relation `scope cross-module (APP/MOD)`), jamais la redéclarer ; des colonnes en plus passent par une demande d’extension (`/demande-extension.md`, CODE-008).', foreign), ) } if (flat.length > 0) { findings.push( finding('CODE-001', DIM, 'warn', moduleScope(m), 'Classe(s) homonyme(s) hors du layout `Entities///` — propriétaire indéterminable (layout plat ou code pré-existant) ; relancer /ba-develop, qui déplace ses propres classes sous leur dossier, puis ré-auditer.', flat), ) } if (findings.length === 0) { findings.push( finding( 'CODE-001', DIM, 'ok', moduleScope(m), own.length > 0 ? `Aucune collision — ${own.length} classe(s) sont celles de l’application elle-même (régénérées par /ba-develop), aucune homonyme dans une autre application (${scan.filesScanned} fichier(s) scanné(s)).` : `Aucune entité du module ne collisionne avec une classe Domain existante (${scan.filesScanned} fichier(s) scanné(s)).`, own, ), ) } return { findings } }, }, { id: 'CODE-002', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module, projectRoot }) { const m = module! const got = scanOrLeg('CODE-002', m, projectRoot) if (got.leg) return { findings: [got.leg] } const scan = got.scan const moduleNames = new Set(m.entities.map((e) => e.name)) const resolved: string[] = [] for (const e of m.entities) { for (const r of e.relations) { if (moduleNames.has(r.targetEntity)) continue const file = scan.classes.get(r.targetEntity) if (file) resolved.push(`${e.name} → ${r.targetEntity} (${file})`) } } return { findings: [ finding( 'CODE-002', DIM, 'ok', moduleScope(m), resolved.length > 0 ? `${resolved.length} référence(s) FK résolvant vers du code existant (cross-références légitimes).` : 'Aucune FK hors module ne pointe vers une classe de code existante.', resolved, ), ], } }, }, { id: 'CODE-003', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module, projectRoot }) { const m = module! const got = scanOrLeg('CODE-003', m, projectRoot) if (got.leg) return { findings: [got.leg] } const scan = got.scan const baNames = new Set(model.graph.entities.map((e) => e.name)) const broken: string[] = [] for (const e of m.entities) { for (const r of e.relations) { if (r.scope === 'core') continue // ships in the NuGet, invisible to the scan if (baNames.has(r.targetEntity)) continue if (scan.classes.has(r.targetEntity)) continue if (matchCoreEntity(r.targetEntity)) continue broken.push(`${e.name} → ${r.targetEntity}`) } } return { findings: [ broken.length > 0 ? finding('CODE-003', DIM, 'err', moduleScope(m), 'Cible(s) FK introuvable(s) en BA ET en code — une FK pendante bloque la génération entité/migration.', broken) : finding('CODE-003', DIM, 'ok', moduleScope(m), 'Toutes les cibles FK résolvent (BA, code existant ou Core).'), ], } }, }, { id: 'CODE-004', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module, projectRoot }) { const m = module! const got = scanOrLeg('CODE-004', m, projectRoot) if (got.leg) return { findings: [got.leg] } const scan = got.scan const hits: string[] = [] const baOwnClasses = new Set(m.entities.filter((e) => scan.classes.has(e.name)).map((e) => e.name)) for (const e of m.entities) { if (!e.tablePrefix) continue const file = scan.tablePrefixes.get(e.tablePrefix) // The entity's OWN generated configuration legitimately uses the prefix. if (file && !baOwnClasses.has(e.name)) hits.push(`« ${e.tablePrefix} » (${e.name}) déjà utilisé dans ${file}`) } return { findings: [ hits.length > 0 ? finding('CODE-004', DIM, 'warn', moduleScope(m), 'Préfixe(s) de table déjà utilisés par le code existant — risque de collision au niveau base.', hits) : finding('CODE-004', DIM, 'ok', moduleScope(m), 'Aucune collision de préfixe de table avec le code existant.'), ], } }, }, { id: 'CODE-005', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const errs: string[] = [] const warns: string[] = [] for (const e of m.entities) { const core = matchCoreEntity(e.name) if (core) errs.push(`${e.name} → Core ${core.name} (${core.qualifiedTable})`) const reserved = matchReservedCoreName(e.name) if (reserved) errs.push(`${e.name} → réservé Core ${reserved.name} — utiliser ${reserved.useInstead}`) const hr = matchPlatformHrEntity(e.name) if (hr) warns.push(`${e.name} → concept HR plateforme ${hr.name} (${hr.table}) — étendre l'app hr ou passer par time-entry-refs, ne pas forker`) } const findings: Finding[] = [] if (errs.length > 0) findings.push(finding('CODE-005', DIM, 'err', moduleScope(m), 'Collision avec le catalogue Core (la table pré-existe dans le NuGet — référencer, jamais recréer).', errs, 'DM-018')) if (warns.length > 0) findings.push(finding('CODE-005', DIM, 'warn', moduleScope(m), 'Recouvrement avec le module HR plateforme.', warns)) if (findings.length === 0) findings.push(finding('CODE-005', DIM, 'ok', moduleScope(m), 'Aucune collision Core / réservé / HR plateforme.')) return { findings } }, }, { id: 'CODE-006', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const warns: string[] = [] const oks: string[] = [] for (const e of m.entities) { const cap = matchCapabilityTrigger(e.name) if (!cap) continue if (cap.key === 'file-storage') { const hasStoredName = e.attributes.some((a) => /stored/i.test(a.name)) const hasContentType = e.attributes.some((a) => /contenttype/i.test(a.name)) const hasSize = e.attributes.some((a) => /size|taille/i.test(a.name)) if (hasStoredName && hasContentType && hasSize) { oks.push(`${e.name} — conforme au pattern IFileStorageService (métadonnées, intention délibérée)`) } else { warns.push(`${e.name} relève du stockage fichiers : garder l'entité comme MÉTADONNÉES (FileName, StoredFileName, ContentType, FileSizeBytes + FK parent), octets via IFileStorageService`) } } else { warns.push(`${e.name} ré-implémente la capability plateforme « ${cap.label} » — utiliser le seam du socle (${cap.key})`) } } const findings: Finding[] = [] if (warns.length > 0) findings.push(finding('CODE-006', DIM, 'warn', moduleScope(m), 'Capability plateforme ré-implémentée ou pattern incomplet.', warns)) if (findings.length === 0) { findings.push( finding('CODE-006', DIM, 'ok', moduleScope(m), oks.length > 0 ? oks.join(' ; ') : 'Aucune ré-implémentation de capability plateforme.'), ) } else if (oks.length > 0) { findings.push(finding('CODE-006', DIM, 'ok', moduleScope(m), oks.join(' ; '))) } return { findings } }, }, { id: 'CODE-007', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module, projectRoot }) { const m = module! const scan = domainOf(projectRoot) const errs: string[] = [] const oks: string[] = [] for (const e of m.entities) { const identityCount = PERSON_IDENTITY_ATTRIBUTES.filter((n) => e.attributes.some((a) => fold(a.name) === fold(n))).length const personSignal = matchPersonTrigger(e.name) !== undefined || identityCount >= 2 if (!personSignal) continue const personLine = e.person if (personLine === null) { errs.push(`${e.name} : entité personne sans déclaration **Personne** (silence = err — décision jamais posée)`) continue } if (/^none\b/i.test(personLine.trim())) { if (/d[eé]cision client/i.test(personLine)) oks.push(`${e.name} — décorrélation sanctionnée (« none — décision client »)`) else errs.push(`${e.name} : **Personne** : none sans « décision client : » — l'override doit être tracé`) continue } const userRelation = e.relations.some((r) => r.scope === 'core' && fold(r.targetEntity) === 'user') if (!userRelation) { errs.push(`${e.name} : **Personne** ${personLine.split('—')[0]?.trim()} sans la relation *→1 User — FK UserId, scope core (auth_Users)`) continue } // Code leg — only when the backend exists AND the class was generated. if (scan && scan.filesScanned > 0 && scan.classSources.has(e.name)) { const src = scan.classSources.get(e.name)! if (!/\bGuid\??\s+UserId\b/.test(src)) { errs.push(`${e.name} : la modélisation est bonne mais la classe générée n'a pas la jambe UserId — re-scaffolder l'entité avec la relation scope core`) continue } oks.push(`${e.name} — Personne ${personLine.split('—')[0]?.trim()}, FK UserId → auth_Users présent en code`) } else { oks.push(`${e.name} — Personne ${personLine.split('—')[0]?.trim()}, relation User scope core déclarée`) } } const findings: Finding[] = [] // relatedTo, not dedupOf: this leg is STRICTER than DM-018c (silence = err, // untraced `none` = err, code leg) — a different predicate, not a mirror. if (errs.length > 0) findings.push(finding('CODE-007', DIM, 'err', moduleScope(m), 'Entité(s) personne sans extension auth_Users (miroir fail-closed de DM-018c).', errs, undefined, { relatedTo: 'DM-018' })) if (oks.length > 0 || errs.length === 0) { findings.push(finding('CODE-007', DIM, 'ok', moduleScope(m), oks.length > 0 ? oks.join(' ; ') : 'Aucune entité personne à vérifier.')) } return { findings } }, }, { // CODE-008 — the extension request (doctrine 2026-10-03). A module that // declares, on an entity ANOTHER application owns, attributes that // application's class/MCD does not carry, asks for them through // `/demande-extension.md`. err = the ask is not written (or a pending // block lacks the newer columns); warn = a request of the application is // pending/refused — the Studio owns the status and development is frozen. // The BA leg (the owner's entité.md) runs without projectRoot; the code // leg (the owner's generated class) joins it when the app exists. id: 'CODE-008', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module, projectRoot }) { const m = module! const scan = domainOf(projectRoot) const foreign = [...(model.testDataUniverse ?? new Map()).values()].map((u) => ({ modulePath: u.modulePath, entities: u.entities })) const expected = expectedExtensionRequests({ app: m.app, module: m.module, entities: m.entities, foreign, scan }) const doc: BaExtensionRequestsDoc = model.apps.find((a) => a.app === m.app)?.extensionRequests ?? { exists: false, relPath: null, app: null, requests: [], headingsSeen: 0, warnings: [] } const rec = reconcileExtensionRequests(expected, doc) const file = `${m.app}/${EXTENSION_REQUESTS_FILE}` const describe = (x: ExtensionRequestExpectation): string => `${x.entityCode} ${x.entity} → ${renderExtensionRequestTarget(x.target)} : ${x.attributes.map((a) => a.name).join(', ')}` + (x.classFile !== null ? ` (classe ${x.classFile})` : ' (propriétaire non développé — son entité.md)') const findings: Finding[] = [] const due = [ ...rec.missing.map((x) => `${describe(x)} — AUCUN bloc REQ ne la porte`), ...rec.stale.map((s) => `${describe(s.expectation)} — ${s.request.code} (en attente) ne porte pas : ${s.missingAttributes.join(', ')}`), ] if (due.length > 0) { findings.push( finding('CODE-008', DIM, 'err', moduleScope(m), 'Colonnes déclarées sur l’entité d’une AUTRE application sans demande d’extension — les projeter dans `/demande-extension.md` (derive-extension-request, mode write) ; le Studio instruit la demande et écrit le Statut.', due, undefined, { file, relatedTo: 'CODE-001' }), ) } // The ACCESS requests (nature accès) are CODE-009's — one GELÉ per nature, never two for one block. const blocking = [...rec.pending, ...rec.refused].filter((r) => r.nature === 'extension') if (blocking.length > 0) { findings.push( finding( 'CODE-008', DIM, 'warn', moduleScope(m), 'Développement de l’application GELÉ — demande(s) d’extension en attente ou refusée(s) (statut détenu par le Studio ; preflight-develop-plan saute tous les modules de l’application).', blocking.map((r) => `${r.code} ${r.entity} → ${r.targetRaw} (${STATUS_LABELS[r.status]}) — demandeurs : ${r.requesters.join(', ') || '—'}`), undefined, { file }, ), ) } if (findings.length === 0) { const note = scan === null ? ' — jambe code non scannée (projectRoot absent), jambe BA seule' : '' findings.push( finding( 'CODE-008', DIM, 'ok', moduleScope(m), expected.length === 0 ? `Aucune colonne demandée à une autre application (${doc.requests.length} bloc(s) REQ dans l’app)${note}.` : `${rec.covered.length} demande(s) d’extension couverte(s) (${rec.validated.length} validée(s))${note}.`, rec.covered.map((c) => `${c.request.code} ${c.request.entity} → ${c.request.targetRaw} (${STATUS_LABELS[c.request.status]})`), undefined, { file }, ), ) } return { findings } }, }, { // CODE-009 — the ACCESS request (data sensitivity doctrine 2026-10-03). A // module of application A that REACHES a ≥ confidentiel entity of // application B — a `scope cross-module (B/MOD)` relation, a 360 related // tab onto it, a 4-segment permission row on its section — asks the // owner's consent through `/demande-extension.md`, nature `accès` // (no column). err = the ask is not written; warn = pending/refused (the // application's development is frozen, like an extension). BA leg only — // the sensitivity lives in the owner's entité.md, never in the code. id: 'CODE-009', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const foreign = [...(model.testDataUniverse ?? new Map()).values()].map((u) => ({ modulePath: u.modulePath, entities: u.entities })) const expected = expectedAccessRequests({ app: m.app, module: m.module, entities: m.entities, screens: m.screens, rbacRows: m.rbacRows, foreign, foreignScreensOf: foreignScreensResolver(model), }) const doc: BaExtensionRequestsDoc = model.apps.find((a) => a.app === m.app)?.extensionRequests ?? { exists: false, relPath: null, app: null, requests: [], headingsSeen: 0, warnings: [] } const rec = reconcileExtensionRequests(expected, doc) const file = `${m.app}/${EXTENSION_REQUESTS_FILE}` const describe = (x: ExtensionRequestExpectation): string => `${x.entityCode} ${x.entity} → ${renderExtensionRequestTarget(x.target)} (${x.sensitivity ?? 'sensible'}) : ${x.reason}` const findings: Finding[] = [] if (rec.missing.length > 0) { findings.push( finding('CODE-009', DIM, 'err', moduleScope(m), 'Accès à une entité sensible d’une AUTRE application sans demande d’accès — la projeter dans `/demande-extension.md` (derive-extension-request, mode write : bloc REQ nature « accès », sans colonne) ; le propriétaire consent, le Studio écrit le Statut.', rec.missing.map((x) => `${describe(x)} — AUCUN bloc REQ (nature accès) ne la porte`), undefined, { file, relatedTo: 'DM-034' }), ) } const blocking = [...rec.pending, ...rec.refused].filter((r) => r.nature === 'acces') if (blocking.length > 0) { findings.push( finding( 'CODE-009', DIM, 'warn', moduleScope(m), 'Développement de l’application GELÉ — demande(s) d’ACCÈS à une entité sensible en attente ou refusée(s) (statut détenu par le Studio ; preflight-develop-plan saute tous les modules de l’application).', blocking.map((r) => `${r.code} ${r.entity} → ${r.targetRaw} (${STATUS_LABELS[r.status]}${r.sensitivityRaw ? ` — ${r.sensitivityRaw}` : ''}) — demandeurs : ${r.requesters.join(', ') || '—'}`), undefined, { file }, ), ) } if (findings.length === 0) { findings.push( finding( 'CODE-009', DIM, 'ok', moduleScope(m), expected.length === 0 ? 'Aucune entité ≥ confidentiel d’une autre application n’est atteinte par ce module.' : `${rec.covered.length} demande(s) d’accès couverte(s) (${rec.covered.filter((c) => c.request.status === 'validated').length} validée(s)).`, rec.covered.map((c) => `${c.request.code} ${c.request.entity} → ${c.request.targetRaw} (${STATUS_LABELS[c.request.status]})`), undefined, { file }, ), ) } return { findings } }, }, ] // --------------------------------------------------------------------------- // CODE-009 helper — the owner application's screens (list-screen rung of the // permission trigger): the corpus when the app is loaded, else the disk — a // module-scoped run must render the SAME verdict as a project-wide one (the // RBAC-008 invariant, lib/remediation). // --------------------------------------------------------------------------- function foreignScreensResolver(model: CorpusModel): (appFolder: string) => readonly ParsedScreen[] { const cache = new Map() return (appFolder) => { if (!cache.has(appFolder)) { const loaded = model.apps.find((a) => sameSegment(a.app, appFolder)) cache.set(appFolder, loaded ? loaded.screens : loadScreens(model.baRoot, appFolder).screens) } return cache.get(appFolder)! } }