/** * cli:derive-change-impact — trace.ts * * The UPSTREAM TRACE of a data-model change: `/ba-create-data-model` refuses * an entity or an attribute nobody upstream names (« the set of entity * sources is CLOSED »: use-case.md + règles-métier.md of THIS module). The * same search, deterministic: every form of the term (PascalCase, the words * it splits into, kebab, snake), whole-word, accents folded — the DM-025 * approach (audit-ba rules/dm.ts) — over the module's UC and rules docs. * * A miss BLOCKS the change with the route « write the UC / the rule first »; * the playbook says the honest limit: a synonym the search cannot see blocks * wrongly — the answer is then the missing UC/rule, which is the door * create-data-model wants anyway. */ import type { ScopeCorpus } from './corpus.js' import type { TraceHit, TraceReport } from './types.js' export function fold(s: string): string { return s .normalize('NFD') .replace(/[̀-ͯ]/g, '') .toLowerCase() } /** `DueDate` → [`duedate`, `due date`, `due-date`, `due_date`]; `Amount` → [`amount`]. */ export function termForms(term: string): string[] { const raw = term.trim() if (raw === '') return [] const words = raw .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/[_-]+/g, ' ') .split(/\s+/) .filter(Boolean) .map(fold) const forms = new Set([fold(raw)]) if (words.length > 1) { forms.add(words.join(' ')) forms.add(words.join('-')) forms.add(words.join('_')) forms.add(words.join('')) } // A foreign key `ProspectId` IS the relation to « prospect » — the upstream // names the concept, never the column (DM-013 semantics). The stem's forms // count; `Id` alone never does. if (/Id$/.test(raw) && raw.length > 2) { for (const f of termForms(raw.slice(0, -2))) forms.add(f) } return [...forms].filter((f) => f.length >= 2 && f !== 'id') } const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') /** Whole-word, accent-folded search of every form of `term` in the module's UC + rules docs. */ export function traceTerm(corpus: ScopeCorpus, term: string): TraceReport { const forms = termForms(term) const hits: TraceHit[] = [] const docs = [...corpus.useCaseDocs, ...corpus.rulesDocs] if (forms.length === 0) return { searched: false, found: false, hits, docsScanned: docs.length } const res = forms.map((f) => new RegExp(`(^|[^a-z0-9_])${escapeRe(f)}([^a-z0-9_]|$)`)) for (const d of docs) { const lines = d.text.split(/\r?\n/) for (let i = 0; i < lines.length; i++) { const folded = fold(lines[i]!) if (res.some((re) => re.test(folded))) { hits.push({ file: `${corpus.scope.app}/${corpus.scope.module}/${d.relPath}`, line: i + 1, excerpt: lines[i]!.trim().slice(0, 160) }) if (hits.length >= 20) return { searched: true, found: true, hits, docsScanned: docs.length } } } } return { searched: true, found: hits.length > 0, hits, docsScanned: docs.length } }