/** * cli:audit-ba — rules/code.ts (CODE-001..007 — 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). */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { join, relative } from 'node:path' 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 type { BaEntity } from '../../../../../lib/ba-entities.js' import type { ModuleModel } from '../corpus/model.js' import { finding, fold, moduleScope, type RuleDef } from './registry.js' import type { Finding } from '../types.js' const DIM = 'cross-ref-code' as const // --------------------------------------------------------------------------- // Domain scan (cached per projectRoot for the whole run) // --------------------------------------------------------------------------- export interface DomainScan { /** class name → first file (relative to projectRoot). */ classes: Map /** ToTable("prefix…") prefixes → file. */ tablePrefixes: Map /** Raw content by class name (person-link leg of CODE-007). */ classSources: Map filesScanned: number } const scanCache = new Map() export function scanDomain(projectRoot: string): DomainScan { const cached = scanCache.get(projectRoot) if (cached) return cached const scan: DomainScan = { classes: new Map(), tablePrefixes: new Map(), classSources: new Map(), filesScanned: 0 } const roots: string[] = [] const srcDir = join(projectRoot, 'src') try { for (const e of readdirSync(srcDir, { withFileTypes: true })) { if (e.isDirectory() && /\.Domain$/i.test(e.name)) roots.push(join(srcDir, e.name)) } } catch { /* no src/ — greenfield */ } const walk = (dir: string): void => { let entries try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return } for (const e of entries) { if (e.name === 'bin' || e.name === 'obj' || e.name === 'node_modules') continue const p = join(dir, e.name) if (e.isDirectory()) walk(p) else if (e.name.endsWith('.cs')) { let content: string try { content = readFileSync(p, 'utf8') } catch { continue } scan.filesScanned++ const rel = relative(projectRoot, p).replace(/\\/g, '/') for (const m of content.matchAll(/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)/g)) { if (!scan.classes.has(m[1]!)) { scan.classes.set(m[1]!, rel) scan.classSources.set(m[1]!, content) } } for (const m of content.matchAll(/ToTable\("([a-z]{2,5}_)/g)) { if (!scan.tablePrefixes.has(m[1]!)) scan.tablePrefixes.set(m[1]!, rel) } } } } for (const r of roots) walk(r) scanCache.set(projectRoot, scan) return scan } function domainOf(projectRoot: string | undefined): DomainScan | null { if (!projectRoot || !existsSync(projectRoot)) return null try { if (!statSync(projectRoot).isDirectory()) return null } catch { return null } return scanDomain(projectRoot) } // --------------------------------------------------------------------------- // 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 } export const CODE_RULES: RuleDef[] = [ { id: 'CODE-001', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module, projectRoot }) { const m = module! const scan = domainOf(projectRoot) const leg = scanLegFinding('CODE-001', m, scan) if (leg) return { findings: [leg] } const hits = m.entities .filter((e) => scan.classes.has(e.name)) .map((e) => `${e.name} déjà présent dans ${scan.classes.get(e.name)}`) return { findings: [ hits.length > 0 ? finding('CODE-001', DIM, 'warn', moduleScope(m), 'Entité(s) BA existant déjà en code — la phase dev doit ÉTENDRE la classe, pas la dupliquer (marquer l’extension dans le MCD).', hits) : finding('CODE-001', DIM, 'ok', moduleScope(m), `Aucune entité du module ne collisionne avec une classe Domain existante (${scan.filesScanned} fichier(s) scanné(s)).`), ], } }, }, { id: 'CODE-002', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module, projectRoot }) { const m = module! const scan = domainOf(projectRoot) const leg = scanLegFinding('CODE-002', m, scan) if (leg) return { findings: [leg] } 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 scan = domainOf(projectRoot) const leg = scanLegFinding('CODE-003', m, scan) if (leg) return { findings: [leg] } 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 scan = domainOf(projectRoot) const leg = scanLegFinding('CODE-004', m, scan) if (leg) return { findings: [leg] } 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 } }, }, ]