/** * cli:derive-job-specs — derive.ts * * Pure parsing over every `use-case.md` of the module subtree. The execution * model is captured in UC prose (create-use-case keeps no schema field), so * the matcher is TOLERANT: `scheduled[day]`, `scheduled (day)`, * `scheduled — day`, `Exécution : scheduled, période day` all resolve. A * scheduled UC without a resolvable period is a warning, never a guess. */ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { basename, join } from 'node:path' import type { DeriveJobSpecsInput, DeriveJobSpecsReport, DerivedJobSpec, SchedulePeriod } from './types.js' // Case-insensitive segments (lowercase section segments are the corpus norm) // + BOTH separators tolerated (em-dash with optional spaces, ASCII dash with // required spaces — parse-ac's tolerance). The em-dash-only UPPERCASE form // silently derived zero jobs from canonically-authored lowercase UCs. const UC_HEADING_RE = /^###\s+(UC-[A-Za-z0-9_-]+?)(?:\s*—\s*|\s+-\s+)(.+?)\s*$/gm const SCHEDULED_RE = /\bscheduled\b[^a-zA-Z]{0,12}(hour|day|week|month|year|heure|jour|semaine|mois|an|année)\b/i const PERIOD_MAP: Record = { hour: 'hour', heure: 'hour', day: 'day', jour: 'day', week: 'week', semaine: 'week', month: 'month', mois: 'month', year: 'year', an: 'year', 'année': 'year', } /** Off-peak cron per period — 03:00 keeps the pass out of business hours. */ export const CRON_BY_PERIOD: Record = { hour: '0 * * * *', day: '0 3 * * *', week: '0 3 * * 1', month: '0 3 1 * *', year: '0 3 1 1 *', } function slugOf(title: string): string { return title .normalize('NFD').replace(/[̀-ͯ]/g, '') .replace(/[^A-Za-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .toLowerCase() .split('-').slice(0, 4).join('-') } function pascalOf(slug: string): string { return slug.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('') } /** PascalCase entities the UC block references that read as an emission * journal — the idempotence anchor (`AlertEmission`, `LicenceAlertEmission`, * `NotificationLog`…). Cross-checked against entité.md when readable. */ function emissionEntityOf(block: string, knownEntities: ReadonlySet): string | null { const candidates = [...block.matchAll(/\b([A-Z][a-z][A-Za-z0-9]*(?:Emission|Log|Journal))\b/g)].map(m => m[1]) for (const c of candidates) { if (knownEntities.size === 0 || knownEntities.has(c)) return c } return null } export function deriveJobsFromSource( source: string, appCode: string, moduleCode: string, knownEntities: ReadonlySet, warnings: string[], ): DerivedJobSpec[] { const out: DerivedJobSpec[] = [] const headings = [...source.matchAll(UC_HEADING_RE)] for (let i = 0; i < headings.length; i++) { const h = headings[i] const block = source.slice(h.index!, headings[i + 1]?.index ?? source.length) if (!/\bscheduled\b/i.test(block)) continue const m = SCHEDULED_RE.exec(block) if (!m) { warnings.push(`${h[1]}: declared 'scheduled' but no period resolves (hour/day/week/month/year) — name it, the cadence is data.`) continue } const period = PERIOD_MAP[m[1].toLowerCase()] const slug = slugOf(h[2]) const emission = emissionEntityOf(block, knownEntities) if (emission === null) { warnings.push( `${h[1]}: no emission entity resolves (a PascalCase *Emission/*Log/*Journal the UC names and entité.md models) — ` + `the idempotence contract has nowhere to write (ba.gap): model the emission entity, or the job replays blind.`, ) } out.push({ ucCode: h[1], title: h[2], period, jobId: `${appCode.toLowerCase()}-${moduleCode.toLowerCase()}-${slug}`, slug, methodName: `Run${pascalOf(slug)}Async`, cron: CRON_BY_PERIOD[period], emissionEntity: emission, }) } return out } export function deriveJobSpecs(spec: DeriveJobSpecsInput): DeriveJobSpecsReport { const warnings: string[] = [] const moduleCode = basename(spec.moduleRoot) // Known entities — best-effort from entité.md headings. const knownEntities = new Set() const entiteAbs = join(spec.moduleRoot, 'entité.md') if (existsSync(entiteAbs)) { for (const m of readFileSync(entiteAbs, 'utf-8').matchAll(/^###\s+ENT-\d+\s*—\s*(\w+)/gm)) knownEntities.add(m[1]) } // Every use-case.md of the module subtree (sections + resources). const jobs: DerivedJobSpec[] = [] const walk = (dir: string): void => { for (const name of readdirSync(dir)) { const abs = join(dir, name) let isDir = false try { isDir = statSync(abs).isDirectory() } catch { continue } if (isDir) { walk(abs); continue } if (name !== 'use-case.md') continue jobs.push(...deriveJobsFromSource(readFileSync(abs, 'utf-8'), spec.appCode, moduleCode, knownEntities, warnings)) } } if (existsSync(spec.moduleRoot)) walk(spec.moduleRoot) return { jobs, warnings } }