/** * lib/ba-list-split.ts — THE top-level splitter for BA markdown lists. * * The reference syntax of the BA docs puts commas INSIDE parentheses — * `code (text, requis)`, `fullName (text, tri)`, `vehicleId (lookup, entity * Vehicle)` — so splitting a list on every comma double-counts every such * entry and truncates the fragments it cites. That is the DemoGestionFlotte * signalement (2026-09-02): SCR-017/SCR-018 false positives on screens that * follow the documented grammar to the letter. * * Contract: a separator only separates at parenthesis depth 0, outside any * `«…»` or backtick span. Every list parser of the BA tree splits through * here — never through a bare `split(',')`. */ function countChar(s: string, c: string): number { let n = 0 for (const ch of s) if (ch === c) n++ return n } /** * Split `value` on `separators` (a set of single characters, default `,;`) * at top level only: parenthesis depth 0, outside `«…»` and `` `…` `` spans. * An orphan `)` clamps to depth 0 (a malformed doc must not swallow the rest * of the line). Returns trimmed segments, empties included — callers filter. * * Malformed-input guard — fail NOISY, never silently under-count: a protection * whose delimiters do not balance over the whole value (odd backtick count, * `«` ≠ `»`, a `(` never closed) is DISABLED for that value, so the split * degrades to the old bare comma split. Otherwise a single typo'd `«` would * fold 12 fields into one entry and mute the very audits this splitter feeds * (SCR-017/018 count on it). */ export function splitTopLevel(value: string, separators: string = ',;'): string[] { const useBacktick = countChar(value, '`') % 2 === 0 const useGuillemets = countChar(value, '«') === countChar(value, '»') let probe = 0 for (const ch of value) { if (ch === '(') probe++ else if (ch === ')') probe = Math.max(0, probe - 1) } const useParens = probe === 0 const out: string[] = [] let depth = 0 let inGuillemets = false let inBacktick = false let cur = '' for (const ch of value) { if (useBacktick && ch === '`' && !inGuillemets) inBacktick = !inBacktick else if (useGuillemets && !inBacktick && ch === '«') inGuillemets = true else if (useGuillemets && !inBacktick && ch === '»') inGuillemets = false else if (useParens && !inBacktick && !inGuillemets) { if (ch === '(') depth++ else if (ch === ')') depth = Math.max(0, depth - 1) } if (depth === 0 && !inGuillemets && !inBacktick && separators.includes(ch)) { out.push(cur) cur = '' } else { cur += ch } } out.push(cur) return out.map((v) => v.trim()) } /** * The BA list normalization every consumer shared piecemeal: top-level split, * trim, strip ONE trailing `.`, drop empties and the `—` placeholder. * Replaces audit-ba screen-blocks' splitList, ba-use-cases' pushSplit and * ba-entities' traceability split. */ export function splitBaList(value: string): string[] { return splitTopLevel(value) .map((v) => v.replace(/\.$/, '').trim()) .filter((v) => v !== '' && v !== '—') } /** * Enum values from an entité.md `Contraintes` cell: only the top-level * segments carrying a `/` are value runs — `draft/submitted/approved, requis` * yields [draft, submitted, approved], never the phantom `approved, requis`. * Multi-word values survive (`Durée / Kilométrage / Sans récurrence`). * * No `/` anywhere (off-grammar cell, e.g. a comma-authored enum * `brouillon, actif, clos, requis`): fall back to the top-level segments * rather than [] — an empty result silently DISENGAGES the audit legs keyed * on enumValues (xd.ts guards on `size > 0`), where the old bare split kept * them noisily engaged. Fail noisy, never fail open. */ export function enumValuesOf(constraints: string): string[] { const segments = splitTopLevel(constraints) const runs = segments.filter((seg) => seg.includes('/')) return (runs.length > 0 ? runs.flatMap((seg) => seg.split('/')) : segments) .map((v) => v.trim()) .filter((v) => v !== '' && v !== '—') }