/** * allocate.test.ts — the next free code, per family and scope, never reused. */ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { allocateActor, allocateRule, allocateUseCase, familyRegex, sectionCodeOf } from '../allocate.js' import { loadScopeCorpus, type ScopeCorpus } from '../corpus.js' import { UC_PREFIX, writeChangeCorpus } from './fixture.js' let tmp: string let root: string const corpusOf = (section?: string): ScopeCorpus => loadScopeCorpus({ baRoot: root, app: 'CRM', module: 'PIPELINE', ...(section ? { section } : {}) }) beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'ba-change-alloc-')) root = join(tmp, 'ba') }) afterEach(() => rmSync(tmp, { recursive: true, force: true })) describe('familyRegex', () => { it('folds `_`/`-` and case in the prefix, anchors on 3 digits', () => { const re = familyRegex('UC-CRM-PIPELINE-EXCHANGE_HISTORY-') expect('UC-CRM-PIPELINE-exchange-history-007'.match(re)?.[0]).toBe('UC-CRM-PIPELINE-exchange-history-007') expect('UC-CRM-PIPELINE-EXCHANGE_HISTORY-012'.match(re)?.[0]).toBe('UC-CRM-PIPELINE-EXCHANGE_HISTORY-012') expect('UC-CRM-PIPELINE-EXCHANGE_HISTORY-0123'.match(re)).toBeNull() expect('UC-CRM-PIPELINE-EXCHANGE_HISTORY-SUB-001'.match(re)).toBeNull() }) it('sectionCodeOf uppercases and underscores', () => { expect(sectionCodeOf('exchange-history')).toBe('EXCHANGE_HISTORY') }) }) describe('allocateUseCase', () => { it('next = max(headings 3, near-miss 4, pagespec 9) + 1 = 010, sources tagged, observed casing kept', () => { writeChangeCorpus(root) const a = allocateUseCase(corpusOf('opportunites'), 'opportunites', []) expect(a.family).toBe('UC') expect(a.canonicalPrefix).toBe('UC-CRM-PIPELINE-OPPORTUNITES-') expect(a.observedPrefix).toBe(UC_PREFIX) expect(a.maxTaken).toBe(3) expect(a.next).toBe(`${UC_PREFIX}010`) const sources = new Set(a.reserved.map((r) => r.source)) expect(sources.has('near-miss-heading')).toBe(true) expect(sources.has('pagespec')).toBe(true) expect(sources.has('body-cite')).toBe(true) // the near-miss line is ALSO a body cite of the same doc — both provenances are kept expect(a.reserved.some((r) => r.number === 4 && r.source === 'near-miss-heading')).toBe(true) expect(a.reserved.some((r) => r.number === 9 && r.source === 'pagespec')).toBe(true) expect(a.limits).toContain('git-history-not-scanned') }) it('a sibling section never inflates the counter (clients → 002)', () => { writeChangeCorpus(root) const a = allocateUseCase(corpusOf('clients'), 'clients', []) expect(a.maxTaken).toBe(1) expect(a.next).toBe('UC-CRM-PIPELINE-clients-002') }) it('folds a `previousCodes=` alias into the family (PROSPECTS-020 → 021)', () => { writeChangeCorpus(root, { withAlias: true }) const a = allocateUseCase(corpusOf('opportunites'), 'opportunites', []) expect(a.next).toBe(`${UC_PREFIX}021`) const alias = a.reserved.find((r) => r.source === 'alias') expect(alias?.number).toBe(20) expect(alias?.where).toContain('previousCodes=prospects') }) it('honours the user reserve[] and notes a foreign one', () => { writeChangeCorpus(root, { withPagespecs: false }) const a = allocateUseCase(corpusOf('opportunites'), 'opportunites', [`${UC_PREFIX}042`, 'BR-099']) expect(a.next).toBe(`${UC_PREFIX}043`) expect(a.reserved.find((r) => r.source === 'spec-reserve')?.number).toBe(42) expect(a.limits.some((l) => l.startsWith('reserve-ignored: BR-099'))).toBe(true) }) it('without pagespecs the removed UC-009 is forgotten (honest limit) → 005', () => { writeChangeCorpus(root, { withPagespecs: false }) const a = allocateUseCase(corpusOf('opportunites'), 'opportunites', []) expect(a.next).toBe(`${UC_PREFIX}005`) }) it('a section with no use-case.md allocates 001 under the canonical prefix', () => { writeChangeCorpus(root) const empty = join(root, 'CRM', 'PIPELINE', 'devis') mkdirSync(empty, { recursive: true }) writeFileSync(join(empty, 'index.md'), '\n# devis\n', 'utf8') const a = allocateUseCase(corpusOf(), 'devis', []) expect(a.next).toBe('UC-CRM-PIPELINE-DEVIS-001') expect(a.observedPrefix).toBe('UC-CRM-PIPELINE-DEVIS-') }) }) describe('allocateRule', () => { it('is MODULE-wide: BR-003 in the section doc → next BR-004, even for the module doc', () => { writeChangeCorpus(root) const a = allocateRule(corpusOf(), []) expect(a.family).toBe('BR') expect(a.scopeKey).toBe('CRM/PIPELINE') expect(a.maxTaken).toBe(3) expect(a.next).toBe('BR-004') }) it('never matches a 4-segment `BR-APP-MOD-SEC-NNN` or a cross-app BR', () => { writeChangeCorpus(root) const a = allocateRule(corpusOf(), []) // HR/RECRUIT carries BR-001 too — another application, never in scope. expect(a.reserved.every((r) => !r.where.startsWith('HR/'))).toBe(true) expect(familyRegex('BR-').test('BR-CRM-PIPELINE-OPPORTUNITES-007')).toBe(false) }) it('reserves a near-miss `### BR-` heading and a pagespec cite', () => { writeChangeCorpus(root) const doc = join(root, 'CRM', 'PIPELINE', 'règles-métier.md') writeFileSync(doc, `${readFileSync(doc, 'utf8')}\n### BR-011 sans separateur\n- **Type** : validation\n`, 'utf8') const a = allocateRule(corpusOf(), []) expect(a.reserved.some((r) => r.number === 11 && r.source === 'near-miss-heading')).toBe(true) expect(a.next).toBe('BR-012') }) }) describe('allocateActor', () => { it('is PROJECT-wide: CRM 001/002 + HR 005 headings, 003 cited by the derived block → next 006', () => { writeChangeCorpus(root) const a = allocateActor(corpusOf(), []) expect(a.family).toBe('ACT') expect(a.scopeKey).toBe('project') expect(a.canonicalPrefix).toBe('BA-001-AC-') expect(a.maxTaken).toBe(5) expect(a.reserved.some((r) => r.number === 3 && r.source === 'body-cite' && r.where === 'CRM/PIPELINE/rbac.md')).toBe(true) expect(a.next).toBe('BA-001-AC-006') expect(a.limits.some((l) => l.startsWith('mixed-actor-seq'))).toBe(false) }) it('several analysis sequences → the first is used and `mixed-actor-seq` is said', () => { writeChangeCorpus(root) const hr = join(root, 'HR', 'acteur.md') writeFileSync(hr, `${readFileSync(hr, 'utf8')}\n### BA-002-AC-001 — Stagiaire\n- **Type** : internal\n`, 'utf8') const a = allocateActor(corpusOf(), []) expect(a.canonicalPrefix).toBe('BA-001-AC-') expect(a.limits.some((l) => l.startsWith('mixed-actor-seq: 001, 002'))).toBe(true) }) it('reserves a near-miss `### BA-` heading', () => { writeChangeCorpus(root) const crm = join(root, 'CRM', 'acteur.md') writeFileSync(crm, `${readFileSync(crm, 'utf8')}\n### BA-001-AC-009\n- **Type** : internal\n`, 'utf8') const a = allocateActor(corpusOf(), []) expect(a.reserved.some((r) => r.number === 9 && r.source === 'near-miss-heading')).toBe(true) expect(a.next).toBe('BA-001-AC-010') }) })