/** * cli:derive-change-impact — existing.ts * * « Does this item already exist? » — the check that keeps a change request * from re-creating a use case or a rule under a new code. Exact = same * normalized title (accents folded, punctuation collapsed); similar = token * Jaccard ≥ 0.6 (the same family of heuristics as reconcile-menu/detect.ts, * re-implemented here because cross-skill imports do not survive the * installer's flatten). Cross-application look-alikes are REPORTED in their * own bucket, never written — scope regimes (UC-023 / BR-012). * * On op=modify the same module resolves the targeted item by code. */ import { slugifyRoleCode } from '../../../../lib/string-utils.js' import { ownerRulesDoc, sectionUseCaseDoc, type ScopeCorpus } from './corpus.js' import type { ExistingMatch, ExistingReport } from './types.js' export const SIMILAR_THRESHOLD = 0.6 /** Mirror of reconcile-menu/detect.ts normalizeTitle (lowercase, NFD-strip, * non-alnum → space) — minus the trailing parenthetical a UC heading carries * (`Créer une opportunité (user-goal)`): the Cockburn level is not the title. */ export function normalizeTitle(title: string): string { return title .replace(/\s*\([^()]*\)\s*$/, '') .toLowerCase() .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/[^a-z0-9]+/g, ' ') .trim() } const STOP_WORDS = new Set(['le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'd', 'l', 'et', 'ou', 'a', 'au', 'aux', 'the', 'an', 'of', 'to', 'for', 'and', 'or', 'in', 'on']) export function titleTokens(title: string): Set { return new Set( normalizeTitle(title) .split(' ') .filter((t) => t.length > 0 && !STOP_WORDS.has(t)), ) } export function jaccard(a: Set, b: Set): number { if (a.size === 0 || b.size === 0) return 0 let inter = 0 for (const t of a) if (b.has(t)) inter++ const union = a.size + b.size - inter return union === 0 ? 0 : inter / union } interface Candidate { code: string title: string file: string } function classify( candidates: Candidate[], title: string | undefined, excludeCode: string | undefined, ): { exact: ExistingMatch[]; similar: ExistingMatch[] } { const exact: ExistingMatch[] = [] const similar: ExistingMatch[] = [] if (!title) return { exact, similar } const wanted = normalizeTitle(title) const wantedTokens = titleTokens(title) for (const c of candidates) { if (excludeCode && c.code === excludeCode) continue if (normalizeTitle(c.title) === wanted) { exact.push({ code: c.code, title: c.title, file: c.file, reason: 'same-title' }) continue } const score = jaccard(wantedTokens, titleTokens(c.title)) if (score >= SIMILAR_THRESHOLD) { similar.push({ code: c.code, title: c.title, file: c.file, reason: 'similar-title', score: Number(score.toFixed(2)) }) } } return { exact, similar } } const relFile = (corpus: ScopeCorpus, relPath: string): string => `${corpus.scope.app}/${corpus.scope.module}/${relPath}` /** Use cases of the module: the section doc first (exact + similar), sibling sections (similar), other apps (crossApp). */ export function existingUseCases( corpus: ScopeCorpus, sectionFolder: string, target: { title?: string; code?: string }, op: 'add' | 'modify', ): ExistingReport { const doc = sectionUseCaseDoc(corpus, sectionFolder) const own: Candidate[] = (doc?.parsed.ucs ?? []).map((u) => ({ code: u.ucCode, title: u.title, file: relFile(corpus, doc!.relPath) })) const siblings: Candidate[] = corpus.useCaseDocs .filter((d) => d !== doc) .flatMap((d) => d.parsed.ucs.map((u) => ({ code: u.ucCode, title: u.title, file: relFile(corpus, d.relPath) }))) const others: Candidate[] = corpus.crossAppUseCases.flatMap((x) => x.ucs) const excludeCode = op === 'modify' ? target.code : undefined const ownMatch = classify(own, target.title, excludeCode) const siblingMatch = classify(siblings, target.title, excludeCode) const crossMatch = classify(others, target.title, undefined) const report: ExistingReport = { count: own.length, exact: ownMatch.exact, similar: [...ownMatch.similar, ...siblingMatch.exact.map((m) => ({ ...m, reason: 'similar-title' as const, score: 1 })), ...siblingMatch.similar], crossApp: [...crossMatch.exact, ...crossMatch.similar], } if (op === 'modify' && target.code) { const hit = own.find((c) => c.code.toLowerCase() === target.code!.toLowerCase()) if (hit) report.resolved = { code: hit.code, title: hit.title, file: hit.file, reason: 'same-title' } } return report } /** Rules of the module: the owner doc (exact + similar + same error code), sibling docs (similar), other apps (crossApp). */ export function existingRules( corpus: ScopeCorpus, sectionFolder: string | undefined, target: { title?: string; code?: string; errorCode?: string }, op: 'add' | 'modify', ): ExistingReport { const ownerDoc = ownerRulesDoc(corpus, sectionFolder).doc const own: Candidate[] = (ownerDoc?.rules ?? []).map((r) => ({ code: r.code, title: r.title, file: relFile(corpus, ownerDoc!.relPath) })) const siblings: Candidate[] = corpus.rulesDocs .filter((d) => d !== ownerDoc) .flatMap((d) => d.rules.map((r) => ({ code: r.code, title: r.title, file: relFile(corpus, d.relPath) }))) const others: Candidate[] = corpus.crossAppRules.flatMap((x) => x.rules) const excludeCode = op === 'modify' ? target.code : undefined const ownMatch = classify(own, target.title, excludeCode) const siblingMatch = classify(siblings, target.title, excludeCode) const crossMatch = classify(others, target.title, undefined) const exact = [...ownMatch.exact] if (target.errorCode) { const wanted = target.errorCode.trim().toLowerCase() for (const d of corpus.rulesDocs) { for (const r of d.rules) { if (excludeCode && r.code === excludeCode) continue if (r.errorCode && r.errorCode.trim().toLowerCase() === wanted) { exact.push({ code: r.code, title: r.title, file: relFile(corpus, d.relPath), reason: 'same-error-code' }) } } } } const report: ExistingReport = { count: own.length, exact, similar: [...ownMatch.similar, ...siblingMatch.exact.map((m) => ({ ...m, reason: 'similar-title' as const, score: 1 })), ...siblingMatch.similar], crossApp: [...crossMatch.exact, ...crossMatch.similar], } if (op === 'modify' && target.code) { // A BR code is doc-scoped: the OWNER doc is searched first, then the module. const hit = own.find((c) => c.code.toLowerCase() === target.code!.toLowerCase()) ?? siblings.find((c) => c.code.toLowerCase() === target.code!.toLowerCase()) if (hit) report.resolved = { code: hit.code, title: hit.title, file: hit.file, reason: 'same-title' } } return report } /** Portée cell → canonical vocabulary value (RBAC-007), or null when unknown. Empty = `all`. */ export function canonicalPortee(raw: string | undefined): string | null { const v = (raw ?? '').trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') if (v === '') return 'all' if (v === 'all' || v === 'toutes') return 'all' if (v === 'own' || v === 'les siennes') return 'own' if (v === 'assigned' || v === 'attribuees') return 'assigned' if (v === 'team' || v === 'equipe') return 'team' if (v.startsWith('custom') || v.startsWith('personnalisee')) return 'custom' return null } /** * Actors are PROJECT-scoped: an actor with the same label (or the same seeded * role code — `slugifyRoleCode`, one actor = one role) in ANY application is * the SAME actor → exact, with the app it lives in; the change is then a * `Périmètre` line, never a second code. `crossApp` stays empty by design. */ export function existingActors( corpus: ScopeCorpus, target: { title?: string; code?: string }, op: 'add' | 'modify', ): ExistingReport { const own = corpus.actors.find((a) => a.app.toLowerCase() === corpus.scope.app.toLowerCase()) const exact: ExistingMatch[] = [] const similar: ExistingMatch[] = [] const excludeCode = op === 'modify' ? target.code : undefined if (target.title) { const wanted = normalizeTitle(target.title) const wantedSlug = slugifyRoleCode(target.title) const wantedTokens = titleTokens(target.title) for (const a of corpus.actors) { for (const actor of a.actors) { if (excludeCode && actor.code === excludeCode) continue const file = `${a.app}/acteur.md` if (normalizeTitle(actor.label) === wanted) { exact.push({ code: actor.code, title: actor.label, file, reason: 'same-title' }) } else if (wantedSlug !== '' && slugifyRoleCode(actor.label) === wantedSlug) { exact.push({ code: actor.code, title: actor.label, file, reason: 'same-name' }) } else { const score = jaccard(wantedTokens, titleTokens(actor.label)) if (score >= SIMILAR_THRESHOLD) { similar.push({ code: actor.code, title: actor.label, file, reason: 'similar-title', score: Number(score.toFixed(2)) }) } } } } } const report: ExistingReport = { count: own?.actors.length ?? 0, exact, similar, crossApp: [] } if (op === 'modify' && target.code) { const hit = (own?.actors ?? []).find((a) => a.code.toLowerCase() === target.code!.toLowerCase()) if (hit) report.resolved = { code: hit.code, title: hit.label, file: `${corpus.scope.app}/acteur.md`, reason: 'same-title' } } return report } /** Human rows of the module matrix: same (actor, path, portée) → exact; same (actor, path) → same-actor-path. */ export function existingPermissions( corpus: ScopeCorpus, target: { actor?: string; permissionPath?: string; portee?: string }, op: 'add' | 'modify', ): ExistingReport { const exact: ExistingMatch[] = [] const similar: ExistingMatch[] = [] const file = `${corpus.scope.app}/${corpus.scope.module}/rbac.md` const actor = target.actor?.trim().toLowerCase() const path = target.permissionPath?.trim().toLowerCase() const portee = canonicalPortee(target.portee) const rowMatchesActor = (r: { actorCode: string; actorLabel?: string }): boolean => actor !== undefined && (r.actorCode.toLowerCase() === actor || (r.actorLabel ?? '').toLowerCase() === actor) let resolved: ExistingMatch | undefined if (actor && path) { for (const r of corpus.rbac.rows) { if (!rowMatchesActor(r) || r.path.toLowerCase() !== path) continue const title = `${r.actorCode} · ${r.path} · ${r.portee || 'toutes'}` const samePortee = portee !== null && canonicalPortee(r.portee) === portee if (op === 'modify') { resolved = { title, file, reason: 'same-actor-path' } } else if (samePortee) { exact.push({ title, file, reason: 'same-tuple' }) } else { similar.push({ title, file, reason: 'same-actor-path' }) } } } const report: ExistingReport = { count: corpus.rbac.rows.length, exact, similar, crossApp: [] } if (resolved) report.resolved = resolved return report } const foldName = (s: string): string => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[_\s-]/g, '') /** Attributes of ONE entity of the module: same folded name → exact same-name. `count` = the entity's attributes. */ export function existingAttributes( corpus: ScopeCorpus, entityName: string | undefined, attributeName: string | undefined, op: 'add' | 'modify', ): ExistingReport { const file = `${corpus.scope.app}/${corpus.scope.module}/entité.md` const entity = (corpus.entities?.entities ?? []).find((e) => e.name.toLowerCase() === (entityName ?? '').toLowerCase()) const exact: ExistingMatch[] = [] const similar: ExistingMatch[] = [] let resolved: ExistingMatch | undefined if (entity && attributeName) { const wanted = foldName(attributeName) for (const a of entity.attributes) { if (foldName(a.name) === wanted) { const m: ExistingMatch = { title: `${entity.name}.${a.name} (${a.type})`, file, reason: 'same-name' } if (op === 'modify') resolved = m else exact.push(m) } else if (foldName(a.name).includes(wanted) || wanted.includes(foldName(a.name))) { similar.push({ title: `${entity.name}.${a.name} (${a.type})`, file, reason: 'similar-title' }) } } } const report: ExistingReport = { count: entity?.attributes.length ?? 0, exact, similar, crossApp: [] } if (resolved) report.resolved = resolved return report } /** Entities of the module: same name (case/accents-insensitive) → exact same-name; token overlap → similar. */ export function existingEntities(corpus: ScopeCorpus, target: { title?: string; code?: string }, op: 'add' | 'modify'): ExistingReport { const file = `${corpus.scope.app}/${corpus.scope.module}/entité.md` const entities = corpus.entities?.entities ?? [] const exact: ExistingMatch[] = [] const similar: ExistingMatch[] = [] const excludeCode = op === 'modify' ? target.code : undefined if (target.title) { const wanted = foldName(target.title) const wantedTokens = titleTokens(target.title.replace(/([a-z0-9])([A-Z])/g, '$1 $2')) for (const e of entities) { if (excludeCode && e.code === excludeCode) continue if (foldName(e.name) === wanted) { exact.push({ code: e.code, title: e.name, file, reason: 'same-name' }) } else { const score = jaccard(wantedTokens, titleTokens(e.name.replace(/([a-z0-9])([A-Z])/g, '$1 $2'))) if (score >= SIMILAR_THRESHOLD) similar.push({ code: e.code, title: e.name, file, reason: 'similar-title', score: Number(score.toFixed(2)) }) } } } const report: ExistingReport = { count: entities.length, exact, similar, crossApp: [] } if (op === 'modify' && target.code) { const hit = entities.find((e) => e.code.toLowerCase() === target.code!.toLowerCase() || e.name.toLowerCase() === target.code!.toLowerCase()) if (hit) report.resolved = { code: hit.code, title: hit.name, file, reason: 'same-name' } } return report } /** Screens of ONE section: same title → exact; same (entity, type, effective mode) → same-surface; count = section screens. */ export function existingScreens( corpus: ScopeCorpus, sectionFolder: string, target: { title?: string; code?: string; entity?: string; screenType?: string; mode?: string }, op: 'add' | 'modify', ): ExistingReport { const screens = corpus.screens.filter((s) => s.section.toLowerCase() === sectionFolder.toLowerCase()) const exact: ExistingMatch[] = [] const similar: ExistingMatch[] = [] const excludeCode = op === 'modify' ? target.code : undefined const wantedTitle = target.title ? normalizeTitle(target.title) : undefined const wantedTokens = target.title ? titleTokens(target.title) : undefined const wantedMode = (target.mode ?? 'edit').toLowerCase() for (const s of screens) { if (excludeCode && s.code === excludeCode) continue const file = s.file if (wantedTitle && normalizeTitle(s.title) === wantedTitle) { exact.push({ code: s.code, title: s.title, file, reason: 'same-title' }) continue } if ( target.entity && target.screenType && s.entity === target.entity && s.screenType.toLowerCase() === target.screenType.toLowerCase() && (s.screenType !== 'SmartForm' || (s.mode ?? 'edit').toLowerCase() === wantedMode) ) { exact.push({ code: s.code, title: s.title, file, reason: 'same-surface' }) continue } if (wantedTokens) { const score = jaccard(wantedTokens, titleTokens(s.title)) if (score >= SIMILAR_THRESHOLD) similar.push({ code: s.code, title: s.title, file, reason: 'similar-title', score: Number(score.toFixed(2)) }) } } const report: ExistingReport = { count: screens.length, exact, similar, crossApp: [] } if (op === 'modify' && target.code) { const hit = screens.find((s) => s.code.toLowerCase() === target.code!.toLowerCase()) if (hit) report.resolved = { code: hit.code, title: hit.title, file: hit.file, reason: 'same-title' } } return report }