/** * cli:derive-change-impact — allocate.ts * * The next free code of a family, in its scope. The rule every owning SKILL * states in prose (« grep the headings for the highest taken number and * increment — never reuse a removed number ») made deterministic: * * next = max(highest HEADING number, highest RESERVED number) + 1 * * where a number is RESERVED when it is still carried by something the tree * remembers — a near-miss heading the parser rejects, a body cite in another * doc (a rule's `Cas d'usage liés`, a screen's `UC:` tag), a pagespec * (`linkedUseCases[]`, `linkedBusinessRules[]`), a `previousCodes=` alias of * the section (a rename /ba-reconcile-menu recorded), or the user's own * `reserve[]`. Every scan is ANCHORED on the full family prefix, matched * case-insensitively with `_`/`-` folded (CONV-001: real corpora write the * section segment in lowercase kebab), so a sibling section or another * module never inflates the counter. * * Honest limit, said in `limits[]`: the git history is not scanned — a number * removed AND scrubbed from every live doc can be reallocated; `reserve[]` * is the user's channel for that. */ import { ACTOR_HEADING_RE } from '../../../../lib/ba-actors.js' import { ENT_HEADING_RE, ENT_NEAR_MISS_RE } from '../../../../lib/ba-entities.js' import { RULE_HEADING_RE } from '../../../../lib/ba-rules-rows.js' import { SCREEN_HEADING_RE } from '../../../../lib/ba-screens.js' import { UC_HEADING_RE } from '../../../../lib/ba-use-cases.js' import { allModuleDocs, collectDocs, sectionUseCaseDoc, type ScopeCorpus } from './corpus.js' import type { Allocation, ReservedCode, ReservedSource } from './types.js' export const GIT_HISTORY_LIMIT = 'git-history-not-scanned' export function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } /** `exchange-history` → `EXCHANGE_HISTORY` (create-use-case/SKILL.md § UC code format). */ export function sectionCodeOf(folder: string): string { return folder.toUpperCase().replace(/-/g, '_') } /** * A family regex: the prefix, case-insensitive, every `_` or `-` inside it * matching either, then exactly three digits, word-bounded on both sides. * Capture 1 = the three digits. */ export function familyRegex(prefix: string): RegExp { const body = escapeRegex(prefix).replace(/[_-]/g, '[_-]') return new RegExp(`(?() const out: ReservedCode[] = [] for (const r of reserved) { const key = `${r.number}|${r.source}|${r.where}` if (seen.has(key)) continue seen.add(key) out.push(r) } return out.sort((a, b) => a.number - b.number || a.source.localeCompare(b.source) || a.where.localeCompare(b.where)) } function finish( family: Allocation['family'], scopeKey: string, canonicalPrefix: string, observedPrefix: string, headingNumbers: number[], reserved: ReservedCode[], limits: string[], ): Allocation { const maxTaken = headingNumbers.length > 0 ? Math.max(...headingNumbers) : 0 const deduped = dedupe(reserved) const maxReserved = deduped.length > 0 ? Math.max(...deduped.map((r) => r.number)) : 0 const nextNumber = Math.max(maxTaken, maxReserved) + 1 if (nextNumber > 999) limits.push('sequence-exhausted (999)') return { family, scopeKey, canonicalPrefix, observedPrefix, maxTaken, reserved: deduped, next: `${observedPrefix}${pad3(nextNumber)}`, limits, } } /** Keep the `reserve[]` entries that belong to this family (prefix match); note the others. */ function reserveOf(reserve: readonly string[], regexes: RegExp[], limits: string[]): ReservedCode[] { const out: ReservedCode[] = [] for (const code of reserve) { const hit = regexes.map((re) => scanNumbers(code, re, 'spec-reserve', 'spec.reserve')).find((r) => r.length > 0) if (hit && hit.length > 0) out.push(...hit) else limits.push(`reserve-ignored: ${code} (not in this family)`) } return out } /** * UC family — scoped to ONE section folder. `UC-{APP}-{MOD}-{SEC_CODE}-NNN`, * numbering restarts at 001 per section (create-use-case/SKILL.md). */ export function allocateUseCase(corpus: ScopeCorpus, sectionFolder: string, reserve: readonly string[]): Allocation { const { app, module } = corpus.scope const canonicalPrefix = `UC-${app.toUpperCase()}-${module.toUpperCase()}-${sectionCodeOf(sectionFolder)}-` const familyRe = familyRegex(canonicalPrefix) const limits: string[] = [GIT_HISTORY_LIMIT] const reserved: ReservedCode[] = [] const doc = sectionUseCaseDoc(corpus, sectionFolder) const headingNumbers: number[] = [] let observedPrefix = canonicalPrefix if (doc) { for (const uc of doc.parsed.ucs) { const hit = scanNumbers(uc.ucCode, familyRe, 'heading', doc.relPath) if (hit.length === 1) { headingNumbers.push(hit[0]!.number) if (observedPrefix === canonicalPrefix) observedPrefix = uc.ucCode.slice(0, -3) } } // Near-miss headings: a `### UC-…` line the parser rejects still holds its number. for (const line of doc.text.split(/\r?\n/)) { if (/^###\s+uc-/i.test(line) && !UC_HEADING_RE.test(line)) { reserved.push(...scanNumbers(line, familyRe, 'near-miss-heading', doc.relPath)) } } } // Body cites across every module doc (the section's own doc included — a // heading number lands here too, harmlessly: max wins). for (const d of allModuleDocs(corpus)) reserved.push(...scanNumbers(d.text, familyRe, 'body-cite', d.relPath)) for (const p of corpus.pagespecs ?? []) reserved.push(...scanNumbers(p.text, familyRe, 'pagespec', `pagespecs/${p.name}`)) // Aliases: a section renamed by /ba-reconcile-menu keeps its old codes reachable. const aliasRegexes: RegExp[] = [] const anchor = corpus.sectionAnchors.find((a) => a.folder.toLowerCase() === sectionFolder.toLowerCase()) for (const alias of anchor?.previousCodes ?? []) { const aliasRe = familyRegex(`UC-${app.toUpperCase()}-${module.toUpperCase()}-${sectionCodeOf(alias)}-`) aliasRegexes.push(aliasRe) for (const d of allModuleDocs(corpus)) reserved.push(...scanNumbers(d.text, aliasRe, 'alias', `previousCodes=${alias} · ${d.relPath}`)) for (const p of corpus.pagespecs ?? []) reserved.push(...scanNumbers(p.text, aliasRe, 'alias', `previousCodes=${alias} · pagespecs/${p.name}`)) } reserved.push(...reserveOf(reserve, [familyRe, ...aliasRegexes], limits)) return finish('UC', `${app}/${module}/${sectionFolder}`, canonicalPrefix, observedPrefix, headingNumbers, reserved, limits) } /** * BR family — MODULE-wide, deliberately stricter than the doc-scoped grammar: * `loadModuleRules` warns cross-doc duplicates and derive-rule-links reports * them `ambiguous-code`, so a number free in the target doc but taken in a * sibling doc is avoided too. */ export function allocateRule(corpus: ScopeCorpus, reserve: readonly string[]): Allocation { const { app, module } = corpus.scope const canonicalPrefix = 'BR-' const familyRe = familyRegex(canonicalPrefix) const limits: string[] = [GIT_HISTORY_LIMIT] const reserved: ReservedCode[] = [] const headingNumbers: number[] = [] for (const d of corpus.rulesDocs) { for (const r of d.rules) { const hit = scanNumbers(r.code, familyRe, 'heading', d.relPath) if (hit.length === 1) headingNumbers.push(hit[0]!.number) } for (const line of d.text.split(/\r?\n/)) { if (/^###\s+br-/i.test(line) && !RULE_HEADING_RE.test(line)) { reserved.push(...scanNumbers(line, familyRe, 'near-miss-heading', d.relPath)) } } } for (const d of allModuleDocs(corpus)) reserved.push(...scanNumbers(d.text, familyRe, 'body-cite', d.relPath)) for (const p of corpus.pagespecs ?? []) reserved.push(...scanNumbers(p.text, familyRe, 'pagespec', `pagespecs/${p.name}`)) reserved.push(...reserveOf(reserve, [familyRe], limits)) return finish('BR', `${app}/${module}`, canonicalPrefix, canonicalPrefix, headingNumbers, reserved, limits) } /** `BA-001-AC-007` → { seq: '001', number: 7 }, or null. */ const ACTOR_CODE_RE = /^BA-([A-Za-z0-9]+)-AC-(\d+)$/ /** * ACT family — PROJECT-wide: `BA-{analysisSeq}-AC-{NNN}`, one counter for * every application (create-actors/SKILL.md § Actor fields). The sequence is * the one the project already uses; several distinct sequences → the first * (sorted) is used and `limits` says `mixed-actor-seq` so the skill asks. * Numbers still cited by a `rbac.md` row or a UC actor field of ANY module * (an actor removed from acteur.md but not from the matrices) are reserved. */ export function allocateActor(corpus: ScopeCorpus, reserve: readonly string[]): Allocation { const limits: string[] = [GIT_HISTORY_LIMIT] const reserved: ReservedCode[] = [] const headingNumbers: number[] = [] const seqs = new Set() for (const a of corpus.actors) { for (const actor of a.actors) { const m = ACTOR_CODE_RE.exec(actor.code) if (!m) continue seqs.add(m[1]!) headingNumbers.push(Number(m[2])) } } const seqList = [...seqs].sort() const seq = seqList[0] ?? '001' if (seqList.length > 1) limits.push(`mixed-actor-seq: ${seqList.join(', ')} — using ${seq}; confirm with the user`) const canonicalPrefix = `BA-${seq}-AC-` const familyRe = familyRegex(canonicalPrefix) for (const a of corpus.actors) { for (const line of a.text.split(/\r?\n/)) { if (/^###\s+ba-/i.test(line) && !ACTOR_HEADING_RE.test(line)) { reserved.push(...scanNumbers(line, familyRe, 'near-miss-heading', `${a.app}/acteur.md`)) } } reserved.push(...scanNumbers(a.text, familyRe, 'body-cite', `${a.app}/acteur.md`)) } // Every rbac.md and use-case.md of the PROJECT still citing the actor. const scanWarnings: string[] = [] for (const fileName of ['rbac.md', 'use-case.md']) { for (const d of collectDocs(corpus.scope.baRoot, fileName, 5, scanWarnings)) { reserved.push(...scanNumbers(d.text, familyRe, 'body-cite', d.relPath)) } } reserved.push(...reserveOf(reserve, [familyRe], limits)) return finish('ACT', 'project', canonicalPrefix, canonicalPrefix, headingNumbers, reserved, limits) } /** ENT family — the module's `entité.md`: `ENT-NNN`. Cited by `screen.md` (`**Entité** : X (ENT-004)`). */ export function allocateEntity(corpus: ScopeCorpus, reserve: readonly string[]): Allocation { const { app, module } = corpus.scope const canonicalPrefix = 'ENT-' const familyRe = familyRegex(canonicalPrefix) const limits: string[] = [GIT_HISTORY_LIMIT] const reserved: ReservedCode[] = [] const headingNumbers: number[] = [] for (const e of corpus.entities?.entities ?? []) { const hit = scanNumbers(e.code, familyRe, 'heading', 'entité.md') if (hit.length === 1) headingNumbers.push(hit[0]!.number) } if (corpus.entityDoc) { for (const line of corpus.entityDoc.text.split(/\r?\n/)) { if (ENT_NEAR_MISS_RE.test(line) && !ENT_HEADING_RE.test(line)) { reserved.push(...scanNumbers(line, familyRe, 'near-miss-heading', 'entité.md')) } } } for (const d of allModuleDocs(corpus)) reserved.push(...scanNumbers(d.text, familyRe, 'body-cite', d.relPath)) for (const p of corpus.pagespecs ?? []) reserved.push(...scanNumbers(p.text, familyRe, 'pagespec', `pagespecs/${p.name}`)) reserved.push(...reserveOf(reserve, [familyRe], limits)) return finish('ENT', `${app}/${module}`, canonicalPrefix, canonicalPrefix, headingNumbers, reserved, limits) } /** SCR family — scoped to ONE section folder: `SCR-{APP}-{MOD}-{SEC_CODE}-NNN`, restart at 001 per section. */ export function allocateScreen(corpus: ScopeCorpus, sectionFolder: string, reserve: readonly string[]): Allocation { const { app, module } = corpus.scope const canonicalPrefix = `SCR-${app.toUpperCase()}-${module.toUpperCase()}-${sectionCodeOf(sectionFolder)}-` const familyRe = familyRegex(canonicalPrefix) const limits: string[] = [GIT_HISTORY_LIMIT] const reserved: ReservedCode[] = [] const headingNumbers: number[] = [] let observedPrefix = canonicalPrefix const sectionScreens = corpus.screens.filter((s) => s.section.toLowerCase() === sectionFolder.toLowerCase()) for (const s of sectionScreens) { const hit = scanNumbers(s.code, familyRe, 'heading', s.file) if (hit.length === 1) { headingNumbers.push(hit[0]!.number) if (observedPrefix === canonicalPrefix) observedPrefix = s.code.slice(0, -3) } } const doc = corpus.screenDocs.find((d) => d.depth === 1 && d.sectionFolder.toLowerCase() === sectionFolder.toLowerCase()) if (doc) { const headingRe = new RegExp(SCREEN_HEADING_RE.source) for (const line of doc.text.split(/\r?\n/)) { if (/^###\s+scr-/i.test(line) && !headingRe.test(line)) { reserved.push(...scanNumbers(line, familyRe, 'near-miss-heading', doc.relPath)) } } } for (const d of allModuleDocs(corpus)) reserved.push(...scanNumbers(d.text, familyRe, 'body-cite', d.relPath)) for (const p of corpus.pagespecs ?? []) reserved.push(...scanNumbers(p.text, familyRe, 'pagespec', `pagespecs/${p.name}`)) const aliasRegexes: RegExp[] = [] const anchor = corpus.sectionAnchors.find((a) => a.folder.toLowerCase() === sectionFolder.toLowerCase()) for (const alias of anchor?.previousCodes ?? []) { const aliasRe = familyRegex(`SCR-${app.toUpperCase()}-${module.toUpperCase()}-${sectionCodeOf(alias)}-`) aliasRegexes.push(aliasRe) for (const d of allModuleDocs(corpus)) reserved.push(...scanNumbers(d.text, aliasRe, 'alias', `previousCodes=${alias} · ${d.relPath}`)) for (const p of corpus.pagespecs ?? []) reserved.push(...scanNumbers(p.text, aliasRe, 'alias', `previousCodes=${alias} · pagespecs/${p.name}`)) } reserved.push(...reserveOf(reserve, [familyRe, ...aliasRegexes], limits)) return finish('SCR', `${app}/${module}/${sectionFolder}`, canonicalPrefix, observedPrefix, headingNumbers, reserved, limits) }