/** * audit-dev-actions-alignment / audit.ts * * Cross-references three sources of truth for custom action URLs: * - pagespec.actions[].endpoint (BA contract) * - Controller.cs [HttpVerb("…")] (backend reality) * - Service.ts api.(URL, …) (frontend reality) * * Emits one finding per drift. The CLI is read-only — `apply.ts` (separate) * does the rewriting. */ import path from 'node:path' import { findFiles, readText, fileExists } from '../../../../lib/fs.js' import { STANDARD_CRUD_CODES, defaultEndpoint, expectedControllerRoute, serviceMethodNameFromEndpoint, } from '../../../../lib/page-spec-actions.js' import { toPascalCase } from '../../../../lib/string-utils.js' import type { ActionAlignmentArgs, AlignmentFinding, AlignmentReport, ControllerEndpointEntry, PageActionEntry, ServiceCallEntry, } from './types.js' // ─── Public entry ───────────────────────────────────────────────────────── export async function audit(args: ActionAlignmentArgs): Promise { const pageActions = args.modulePath ? await loadPageActions(path.resolve(args.modulePath)) : await discoverPageActions(path.resolve(args.projectPath)) const controllerEndpoints = await loadControllerEndpoints(path.resolve(args.backendPath)) const serviceCalls = await loadServiceCalls(path.resolve(args.projectPath)) const componentHooks = await loadPageComponentHooks(path.resolve(args.projectPath)) const findings = crossReference(pageActions, controllerEndpoints, serviceCalls, componentHooks) const counts = { err: 0, warn: 0, ok: 0 } for (const f of findings) counts[f.severity]++ const entities = new Set() for (const a of pageActions) entities.add(a.entity) for (const e of controllerEndpoints) entities.add(e.entity) for (const s of serviceCalls) entities.add(s.entity) const markdown = renderMarkdown({ counts, findings, inventory: { pageActions: pageActions.length, controllerEndpoints: controllerEndpoints.length, serviceCalls: serviceCalls.length, entitiesCovered: Array.from(entities).sort(), }, args, }) return { counts, inventory: { pageActions: pageActions.length, controllerEndpoints: controllerEndpoints.length, serviceCalls: serviceCalls.length, entitiesCovered: Array.from(entities).sort(), }, findings, markdown, } } // ─── Pagespec loader ────────────────────────────────────────────────────── const PAGESPEC_FILENAME_RE = /([A-Z][A-Za-z0-9]*)\.([a-z][a-z-]*)\.md$/ /** Load every `pagespecs/*.md` directly under modulePath. */ export async function loadPageActions(modulePath: string): Promise { const out: PageActionEntry[] = [] const files = await findFiles('pagespecs/*.md', { cwd: modulePath }) for (const abs of files) { const filename = path.basename(abs) const m = PAGESPEC_FILENAME_RE.exec(filename) if (!m) continue const entity = m[1] const source = await readText(abs) const entries = parsePagespecActions(source, entity, filename) out.push(...entries) } return out } /** Best-effort discovery: scan up to 3 levels of `.smartstack/ba/**` under projectPath. */ export async function discoverPageActions(projectPath: string): Promise { const out: PageActionEntry[] = [] const files = await findFiles('.smartstack/ba/**/pagespecs/*.md', { cwd: projectPath }) for (const abs of files) { const filename = path.basename(abs) const m = PAGESPEC_FILENAME_RE.exec(filename) if (!m) continue const entity = m[1] const source = await readText(abs) out.push(...parsePagespecActions(source, entity, filename)) } return out } /** Extract the fenced ```json block of a pagespec and pull its `actions[]`. */ export function parsePagespecActions( source: string, entity: string, filename: string, ): PageActionEntry[] { const blocks: string[] = [] // The dotAll flag is broadly supported; use [\s\S] to stay safe for older engines too. const re = /```json\s*\n([\s\S]*?)\n```/g let m: RegExpExecArray | null while ((m = re.exec(source)) !== null) blocks.push(m[1]) if (blocks.length === 0) return [] const entries: PageActionEntry[] = [] for (const block of blocks) { let parsed: unknown try { parsed = JSON.parse(block) } catch { continue // malformed JSON — PRD-001′ will flag it; we just skip here } if (!isPageSpec(parsed)) continue const ps = parsed as PageSpecShape const actions = Array.isArray(ps.actions) ? ps.actions : [] for (const action of actions) { if (!isAction(action)) continue const kind = action.kind ?? 'api' if (kind !== 'api') continue if (STANDARD_CRUD_CODES.has(action.code)) continue const endpoint = action.endpoint ?? defaultEndpoint(action.code) const httpMethod = (action.httpMethod ?? 'POST').toUpperCase() as PageActionEntry['httpMethod'] const scope = action.scope if (!isValidScope(scope)) continue entries.push({ file: filename, entity, module: (ps.module ?? '').toLowerCase(), section: (ps.section ?? '').toLowerCase(), code: action.code, endpoint, httpMethod, scope, ucReference: action.ucReference, }) } } return entries } // ─── Controller loader (mirrors audit-dev-frontend audit.ts:678) ────────── const C_HTTP_VERBS = ['Get', 'Post', 'Put', 'Delete', 'Patch'] const CONTROLLER_FILE_RE = /([A-Z][A-Za-z0-9]*)Controller\.cs$/ const CONTROLLER_SERVICE_FIELD_RE = /\bI([A-Z][A-Za-z0-9]*)Service\b/ export async function loadControllerEndpoints(backendPath: string): Promise { const out: ControllerEndpointEntry[] = [] const files = await findFiles('**/*Controller.cs', { cwd: backendPath }) for (const abs of files) { const filename = path.basename(abs) const fileMatch = CONTROLLER_FILE_RE.exec(filename) if (!fileMatch) continue const source = await readText(abs) if (!source) continue // Prefer the `IService` field/parameter pattern over the filename // because pluralised controller filenames (`TypesAffaireController.cs` for // entity `TypeAffaire`) lose information that the singularization heuristic // cannot reliably recover. const serviceMatch = CONTROLLER_SERVICE_FIELD_RE.exec(source) const entity = serviceMatch ? serviceMatch[1] : singularize(fileMatch[1]) const rel = path.relative(backendPath, abs).replace(/\\/g, '/') const methodRe = new RegExp( `\\[Http(${C_HTTP_VERBS.join('|')})(?:\\(\\s*['"]([^'"]*)['"]\\s*\\))?\\s*\\]`, 'g', ) let m: RegExpExecArray | null while ((m = methodRe.exec(source)) !== null) { const verb = m[1].toLowerCase() const inlineRoute = (m[2] ?? '').trim() const routePath = normaliseControllerRoute(inlineRoute) out.push({ verb, routePath, file: rel, entity }) } } return out } function normaliseControllerRoute(route: string): string { return route .replace(/^\/+|\/+$/g, '') .replace(/\{(\w+)(?::[^}]+)?\}/g, '{$1}') } // ─── Service loader (mirrors audit-dev-frontend audit.ts:726) ───────────── const SERVICE_FILE_RE = /([a-z][A-Za-z0-9]*)Service\.ts$/ export async function loadServiceCalls(projectPath: string): Promise { const out: ServiceCallEntry[] = [] // Loose glob — `**/*Service.ts` mirrors the controller pattern. Without the // structural prefix `src/features/**/services/` the audit picks up service // files regardless of layout (handy for legacy projects whose services live // under `src/services/foo/fooService.ts` instead of `src/features/...`). const found = new Set() for (const pat of ['**/*Service.ts']) { for (const f of await findFiles(pat, { cwd: projectPath })) { const rel = path.relative(projectPath, f).replace(/\\/g, '/') // Skip non-service TS that happen to end with "Service.ts" — keep only files // under a src/ tree to exclude test fixtures, scripts, dist, … if (!rel.startsWith('src/')) continue found.add(rel) } } for (const rel of found) { const abs = path.join(projectPath, rel) const filename = path.basename(rel) const fileMatch = SERVICE_FILE_RE.exec(filename) const entity = fileMatch ? fileMatch[1].charAt(0).toUpperCase() + fileMatch[1].slice(1) : '?' const source = await readText(abs) if (!source) continue // `api` is the DEFAULT client object the generator emits (`import { api } from // '@atlashub/smartstack'` → `api.get(...)`); `apiClient`/`axios`/`client`/`http` // cover hand-written / axios-mode services. Omitting `api` made every generated // service call invisible → false "frontend is not wired" findings. const callRe = /\b(?:apiClient|api|axios|client|http)\s*\.\s*(get|post|put|delete|patch)\s*(?:<[^>]*>)?\s*\(\s*([`'"])([^`'"]+)\2/g let m: RegExpExecArray | null while ((m = callRe.exec(source)) !== null) { const verb = m[1].toLowerCase() const rawUrl = m[3] const urlPath = normaliseServiceUrl(rawUrl) out.push({ verb, urlPath, file: rel, entity }) } } return out } function normaliseServiceUrl(url: string): string { // Drop the leading path-prefix variable — `${API_PATH}` / `${INTEGRATION_PATH}`, or // the sub-resource helper `${API_PATH(parentId)}` the generator emits for nested // routes. The optional `(…)` is the key fix: the old `[A-Z_][A-Z0-9_]*` pattern // choked on the `(` and let the call-form prefix fall through to the id-collapse // below, mangling `${API_PATH(parentId)}/list` into `{id}/list` (a phantom mismatch). // Then collapse id-shaped interpolations (`${id}`, `${item.id}`, `${budgetId}`, …) // to the canonical `{id}` token used by controller route matching. return url .replace(/^\$\{[A-Z_][A-Z0-9_]*(?:\([^})]*\))?\}\/?/, '') .replace(/\$\{[^}]*\b[iI]d\b[^}]*\}/g, '{id}') .replace(/\$\{[^}]+\}/g, '{id}') // last-resort fallback (anything left is treated as an id) .replace(/^\/+|\/+$/g, '') .replace(/^api\//, '') } /** * Tolerant route extraction from a service URL — used by the matcher. * * Service URLs come in two flavours after normalisation: * - With explicit prefix: `referentiels/types-affaire/sync-from-proconcept` * - With stripped `${API_PATH}` prefix: `sync-from-proconcept` * * We can't know a priori which the caller produced, so we accept BOTH by * checking whether the URL ENDS with the expected controller route. The * audit logic uses this via `urlEndsWithRoute(call.urlPath, expectedRoute)` * which is more permissive than the strict-equality match the controller * side uses. * * urlEndsWithRoute('referentiels/types-affaire/sync-from-proconcept', 'sync-from-proconcept') === true * urlEndsWithRoute('sync-from-proconcept', 'sync-from-proconcept') === true * urlEndsWithRoute('referentiels/types-affaire/sync-from-pce', 'sync-from-proconcept') === false * urlEndsWithRoute('referentiels/types-affaire/{id}/archive', '{id}/archive') === true */ function urlEndsWithRoute(urlPath: string, route: string): boolean { if (urlPath === route) return true return urlPath.endsWith('/' + route) } // ─── Page-component loader (the button/hook wiring source) ──────────────── const PAGE_FILE_RE = /^([A-Z][A-Za-z0-9]*?)(List|Detail|Form|Dashboard|Kanban|Card)Page\.tsx$/ const HOOK_REF_RE = /\buse[A-Z][A-Za-z0-9]*/g /** * Index every `Page.tsx` under `src/` by entity → the set of * `use…` hook identifiers it references. scaffold-component imports * `use` ONLY when it actually renders the action button * and wires its `onClick` to the mutation, so the presence of that hook name in * a page is a reliable proxy for "the button is implemented". A kind:api action * whose hook appears on NO page = a button that was dropped (the silent gap * behind "page actions are not implemented"). */ export async function loadPageComponentHooks(projectPath: string): Promise>> { const byEntity = new Map>() const files = await findFiles('**/*Page.tsx', { cwd: projectPath }) for (const abs of files) { const rel = path.relative(projectPath, abs).replace(/\\/g, '/') if (!rel.startsWith('src/')) continue const m = PAGE_FILE_RE.exec(path.basename(rel)) if (!m) continue const entity = m[1] const source = await readText(abs) if (!source) continue let bucket = byEntity.get(entity) if (!bucket) { bucket = new Set() byEntity.set(entity, bucket) } for (const h of source.match(HOOK_REF_RE) ?? []) bucket.add(h) } return byEntity } // ─── Cross-reference logic ─────────────────────────────────────────────── const CRUD_PATH_RE = /^(?:[a-z][a-z0-9-]*\/)?\{id\}?$|^[a-z][a-z0-9-]*$/ export function crossReference( pageActions: PageActionEntry[], controllerEndpoints: ControllerEndpointEntry[], serviceCalls: ServiceCallEntry[], componentHooksByEntity: Map> = new Map(), ): AlignmentFinding[] { const findings: AlignmentFinding[] = [] // Group endpoints + calls by entity to scope comparisons. const endpointsByEntity = groupBy(controllerEndpoints, e => e.entity) const callsByEntity = groupBy(serviceCalls, c => c.entity) const actionsByEntity = groupBy(pageActions, a => a.entity) for (const [entity, actions] of actionsByEntity.entries()) { const eps = endpointsByEntity.get(entity) ?? [] const calls = callsByEntity.get(entity) ?? [] // Track service calls already associated to a pagespec action so the // dead-call pass doesn't re-flag them. Same for controller endpoints. const flaggedCalls = new Set() const flaggedEndpoints = new Set() for (const action of actions) { const expectedRoute = expectedControllerRoute(action.scope, action.endpoint) const expectedVerb = action.httpMethod.toLowerCase() // ─── Pagespec ↔ Controller alignment ─── const matchingController = eps.find( e => e.verb === expectedVerb && e.routePath === expectedRoute, ) if (matchingController) { flaggedEndpoints.add(matchingController) } else { // Did we find ANY non-CRUD endpoint on the controller carrying a similar // SUFFIX (sync-from-* vs sync-from-pce)? Surface that as a drift, otherwise // emit "no candidate" — different rule shape. const candidate = eps.find( e => e.verb === expectedVerb && !isCrudPath(e.routePath) && sameScope(e.routePath, action.scope) && similar(stripScope(e.routePath, action.scope), action.endpoint), ) if (candidate) { flaggedEndpoints.add(candidate) findings.push(driftPagespecVsController(action, candidate)) } else { findings.push(noControllerMatch(action)) } } // ─── Pagespec ↔ Service alignment ─── // Service URLs may keep the `//` prefix or not — that // depends on whether the file uses an `${API_PATH}` template literal // (audit strips that) or a hard-coded literal (audit keeps it). We // accept BOTH by matching on the URL suffix. const matchingService = calls.find( c => c.verb === expectedVerb && urlEndsWithRoute(c.urlPath, expectedRoute), ) if (matchingService) { flaggedCalls.add(matchingService) } else { const candidate = calls.find(c => { // Anything ending with a non-CRUD path whose suffix shares tokens // with the expected endpoint is treated as a near-miss. if (isCrudUrlPath(c.urlPath)) return false const lastSegment = c.urlPath.split('/').filter(Boolean).pop() ?? '' return similar(lastSegment, action.endpoint) }) if (candidate) { flaggedCalls.add(candidate) if (candidate.verb !== expectedVerb) { findings.push(driftVerbMismatch(action, candidate)) } else { findings.push(driftPagespecVsService(action, candidate)) } } else { findings.push(noServiceMatch(action)) } } // ─── Pagespec ↔ Component (button + hook) alignment ─── // The service/hook may exist while the BUTTON was never rendered — that is // exactly "the action is not implemented on the page". scaffold-component // imports the hook only when it wires the button, so a missing hook // reference on every *Page.tsx is the fail-closed signal. const expectedHook = `use${toPascalCase(action.code)}${entity}` const wiredHooks = componentHooksByEntity.get(entity) if (!wiredHooks || !wiredHooks.has(expectedHook)) { findings.push(noComponentWiring(action, entity, expectedHook)) } } // ─── Dead service calls (no pagespec, no controller backing) ─── for (const call of calls) { if (isCrudUrlPath(call.urlPath)) continue if (flaggedCalls.has(call)) continue // already surfaced as a drift above const inControllers = eps.some( e => e.verb === call.verb && urlEndsWithRoute(call.urlPath, e.routePath), ) const inPagespecs = actions.some( a => a.httpMethod.toLowerCase() === call.verb && urlEndsWithRoute(call.urlPath, expectedControllerRoute(a.scope, a.endpoint)), ) if (!inControllers && !inPagespecs) { findings.push(deadServiceCall(call)) } } // ─── Dead controller endpoints ─── for (const ep of eps) { if (isCrudPath(ep.routePath)) continue if (flaggedEndpoints.has(ep)) continue // already surfaced as a drift above const inPagespecs = actions.some( a => a.httpMethod.toLowerCase() === ep.verb && expectedControllerRoute(a.scope, a.endpoint) === ep.routePath, ) const inServices = calls.some( c => c.verb === ep.verb && urlEndsWithRoute(c.urlPath, ep.routePath), ) if (!inPagespecs && !inServices) { findings.push(deadControllerEndpoint(ep)) } } } if (findings.length === 0) { findings.push({ code: 'ACTION-DRIFT-OK', severity: 'ok', entity: '*', message: 'No drift detected — every custom action is aligned across pagespec / controller / service.', suggestion: 'Re-run after the next pagespec or controller change.', }) } return findings } // ─── Finding constructors ──────────────────────────────────────────────── function driftPagespecVsController( action: PageActionEntry, controller: ControllerEndpointEntry, ): AlignmentFinding { const expectedRoute = expectedControllerRoute(action.scope, action.endpoint) return { code: 'ACTION-DRIFT-001', severity: 'err', entity: action.entity, message: `Pagespec endpoint "${action.endpoint}" (scope=${action.scope}, ${action.httpMethod}) does not match ` + `controller route "${controller.routePath}" on ${controller.file}.`, suggestion: 'Either update the pagespec `endpoint` to match the controller, OR rename the controller route ' + 'to match the pagespec. The pagespec is the contract — pick which side should win and run ' + '`--mode apply --source-of-truth=`.', detail: { pageActionEndpoint: action.endpoint, controllerRoute: controller.routePath, pagespecFile: action.file, controllerFile: controller.file, verb: action.httpMethod, scope: action.scope, }, } } function driftPagespecVsService( action: PageActionEntry, call: ServiceCallEntry, ): AlignmentFinding { return { code: 'ACTION-DRIFT-002', severity: 'err', entity: action.entity, message: `Pagespec endpoint "${action.endpoint}" but service ${call.file} calls "${call.urlPath}" — ` + `they MUST be identical. This is the "POST /sync-from-pce → 405" drift pattern.`, suggestion: 'Re-run scaffold-api-client for this entity after re-applying the pagespec — the service ' + 'should never hardcode a URL that diverges from the pagespec. Or, if the BA was wrong, update ' + 'the pagespec to match the service.', detail: { pageActionEndpoint: action.endpoint, serviceUrl: call.urlPath, pagespecFile: action.file, serviceFile: call.file, verb: action.httpMethod, scope: action.scope, }, } } function driftVerbMismatch(action: PageActionEntry, call: ServiceCallEntry): AlignmentFinding { return { code: 'ACTION-DRIFT-005', severity: 'err', entity: action.entity, message: `Service ${call.file} uses ${call.verb.toUpperCase()} on "${call.urlPath}" but the pagespec ` + `(or matching controller) declares ${action.httpMethod} — verb mismatch produces 405. ` + 'This is the "GET /impact vs POST /analyze-impact" drift pattern.', suggestion: 'Pick the verb declared in the pagespec and re-run scaffold-api-client. If the BA actually wants ' + 'a different verb, update the pagespec `httpMethod` first.', detail: { pageActionEndpoint: action.endpoint, serviceUrl: call.urlPath, pagespecFile: action.file, serviceFile: call.file, verb: action.httpMethod, scope: action.scope, }, } } function noControllerMatch(action: PageActionEntry): AlignmentFinding { const expectedRoute = expectedControllerRoute(action.scope, action.endpoint) return { code: 'ACTION-DRIFT-001', severity: 'err', entity: action.entity, message: `Pagespec "${action.code}" expects ${action.httpMethod} ${expectedRoute} but no controller method ` + 'matches. The endpoint is missing entirely from the backend.', suggestion: 'Re-run Phase 2 of `/ba-develop`. The orchestrator will derive `customActions[]` from the ' + 'pagespec and forward them to scaffold-controller, which emits the route attribute.', detail: { pageActionEndpoint: action.endpoint, pagespecFile: action.file, verb: action.httpMethod, scope: action.scope, }, } } function noServiceMatch(action: PageActionEntry): AlignmentFinding { return { code: 'ACTION-DRIFT-002', severity: 'err', entity: action.entity, message: `Pagespec "${action.code}" expects a service method "${serviceMethodNameFromEndpoint(action.endpoint)}" ` + `on ${entityServiceFile(action.entity)} but no axios call matches. The frontend is not wired.`, suggestion: 'Re-run Phase 3a of `/ba-develop` — scaffold-api-client emits the service method + React Query hook ' + 'from the pagespec contract.', detail: { pageActionEndpoint: action.endpoint, pagespecFile: action.file, verb: action.httpMethod, scope: action.scope, }, } } function noComponentWiring(action: PageActionEntry, entity: string, hook: string): AlignmentFinding { const page = action.scope === 'row' ? `${entity} list/detail page` : `${entity} list page` return { code: 'ACTION-DRIFT-006', severity: 'err', entity, message: `Pagespec "${action.code}" expects the ${hook}() hook wired to a button on the ${page} ` + `(scope=${action.scope}), but no ${entity}*Page.tsx references it. The action button is not ` + 'implemented on the frontend.', suggestion: 'Re-run Phase 3a of `/ba-develop` — scaffold-component renders the action button and wires its ' + 'onClick to the mutation from `pageSpec.actions[]`. Make sure the WHOLE pageSpec (with actions[]) ' + 'is passed to scaffold-component; an omitted pageSpec drops every custom button silently.', detail: { pageActionEndpoint: action.endpoint, pagespecFile: action.file, verb: action.httpMethod, scope: action.scope, }, } } function deadServiceCall(call: ServiceCallEntry): AlignmentFinding { return { code: 'ACTION-DRIFT-003', severity: 'warn', entity: call.entity, message: `Service ${call.file} calls ${call.verb.toUpperCase()} /api/.../${call.urlPath} but no pagespec ` + 'declares it AND no controller exposes it. This is a dead call (will 404 at runtime).', suggestion: 'Either declare the action in the pagespec (then re-run `/ba-develop`) or remove the orphan axios call.', detail: { serviceUrl: call.urlPath, serviceFile: call.file, verb: call.verb.toUpperCase() }, } } function deadControllerEndpoint(ep: ControllerEndpointEntry): AlignmentFinding { return { code: 'ACTION-DRIFT-004', severity: 'warn', entity: ep.entity, message: `Controller ${ep.file} exposes ${ep.verb.toUpperCase()} ${ep.routePath} but no pagespec references it ` + 'AND no service calls it. This is a dead endpoint.', suggestion: 'Either declare the action in the pagespec (then re-run `/ba-develop`) so the frontend wires up, ' + 'or delete the orphan controller method.', detail: { controllerRoute: ep.routePath, controllerFile: ep.file, verb: ep.verb.toUpperCase() }, } } // ─── Helpers ───────────────────────────────────────────────────────────── interface PageSpecShape { module?: string section?: string entity?: string actions?: unknown[] } interface PageActionShape { code: string kind?: 'api' | 'navigate' scope: 'row' | 'bulk' | 'header' endpoint?: string httpMethod?: string ucReference?: string } function isPageSpec(value: unknown): value is PageSpecShape { return typeof value === 'object' && value !== null && 'entity' in (value as object) } function isAction(value: unknown): value is PageActionShape { if (typeof value !== 'object' || value === null) return false const v = value as { code?: unknown; scope?: unknown } return typeof v.code === 'string' && typeof v.scope === 'string' } function isValidScope(scope: string): scope is PageActionEntry['scope'] { return scope === 'row' || scope === 'bulk' || scope === 'header' } function groupBy(items: T[], keyFn: (item: T) => K): Map { const out = new Map() for (const item of items) { const k = keyFn(item) const bucket = out.get(k) if (bucket) bucket.push(item) else out.set(k, [item]) } return out } function singularize(plural: string): string { // Very rough — controllers are named Plural (Budgets, Contacts, TypesAffaire). // We strip trailing 's' for canonical CRUD plurals; complex cases stay as-is. if (plural.endsWith('ies')) return plural.slice(0, -3) + 'y' if (plural.endsWith('s') && !plural.endsWith('ss')) return plural.slice(0, -1) return plural } function isCrudPath(routePath: string): boolean { return routePath === '' || routePath === '{id}' } function sameScope(routePath: string, scope: PageActionEntry['scope']): boolean { if (scope === 'row') return routePath.startsWith('{id}/') if (scope === 'bulk') return routePath.startsWith('bulk/') return !routePath.startsWith('{id}/') && !routePath.startsWith('bulk/') } function stripScope(routePath: string, scope: PageActionEntry['scope']): string { if (scope === 'row') return routePath.replace(/^\{id\}\//, '') if (scope === 'bulk') return routePath.replace(/^bulk\//, '') return routePath } /** * Detect a CRUD-only service URL (no custom action segment). * * isCrudUrlPath('') → true (root) * isCrudUrlPath('referentiels/types-affaire') → true (GET all) * isCrudUrlPath('referentiels/types-affaire/{id}') → true (GET by id) * isCrudUrlPath('referentiels/types-affaire/sync-from-…') → false * isCrudUrlPath('sync-from-proconcept') → false * * The heuristic: the URL is CRUD if its final segment is either empty (root) * or `{id}` (resource by id). Anything else is a custom path that the audit * must track. */ function isCrudUrlPath(urlPath: string): boolean { if (urlPath === '') return true const segs = urlPath.split('/').filter(Boolean) const last = segs[segs.length - 1] ?? '' // `/` (2 segments, neither matches a verb) — CRUD GET all / POST. if (segs.length === 2 && !/^\{id\}$/.test(last)) return true // `//{id}` — CRUD GET/PUT/DELETE by id. if (last === '{id}') return true return false } function entityServiceFile(entity: string): string { return `${entity.charAt(0).toLowerCase()}${entity.slice(1)}Service.ts` } /** Loose similarity: do the two endpoints share at least one kebab token? */ function similar(a: string, b: string): boolean { if (!a || !b) return false const ta = new Set(a.split('-')) const tb = b.split('-') return tb.some(t => ta.has(t)) } // ─── Markdown renderer ─────────────────────────────────────────────────── interface RenderContext { counts: AlignmentReport['counts'] inventory: AlignmentReport['inventory'] findings: AlignmentFinding[] args: ActionAlignmentArgs } function renderMarkdown(ctx: RenderContext): string { const { counts, inventory, findings, args } = ctx const verdict = counts.err > 0 ? '❌ FAIL' : counts.warn > 0 ? '⚠️ WARN' : '✅ PASS' const date = new Date().toISOString().split('T')[0] const groups = groupBy(findings, f => f.code) const sections: string[] = [] const ordered: Array<[AlignmentFinding['code'], string]> = [ ['ACTION-DRIFT-001', 'Pagespec ↔ Controller drift (err)'], ['ACTION-DRIFT-002', 'Pagespec ↔ Service drift (err — endpoint drift)'], ['ACTION-DRIFT-005', 'Verb mismatch (err)'], ['ACTION-DRIFT-006', 'Missing action button / hook (err)'], ['ACTION-DRIFT-003', 'Dead service calls (warn)'], ['ACTION-DRIFT-004', 'Dead controller endpoints (warn)'], ['ACTION-DRIFT-OK', 'All aligned'], ] for (const [code, label] of ordered) { const list = groups.get(code) ?? [] if (list.length === 0) continue const bullets = list .map(f => `- **${f.entity}** — ${f.message}\n - 👉 ${f.suggestion}`) .join('\n') sections.push(`### ${label}\n\n${bullets}`) } return [ ``, `# Actions alignment audit`, `_${date} · Verdict : ${verdict} · ${counts.err} err · ${counts.warn} warn · ${counts.ok} ok_`, ``, `## Sources scanned`, ``, `| Source | Path | Count |`, `| --- | --- | --- |`, `| Pagespecs | \`${args.modulePath ?? '(discovered)'}\` | ${inventory.pageActions} actions |`, `| Controllers | \`${args.backendPath}\` | ${inventory.controllerEndpoints} routes |`, `| Services | \`${args.projectPath}\` | ${inventory.serviceCalls} calls |`, `| Entities covered | — | ${inventory.entitiesCovered.join(', ') || '(none)'} |`, ``, `## Findings`, ``, sections.join('\n\n'), ``, `## Next steps`, ``, counts.err === 0 && counts.warn === 0 ? '- ✅ No alignment issues detected.' : [ '- Pick a source-of-truth per finding (pagespec vs controller).', '- Run `--mode apply --source-of-truth=pagespec` to rewrite the controller routes, OR', '- Run `--mode apply --source-of-truth=controller` to rewrite the pagespec endpoints.', '- Then re-run `/ba-develop` to regenerate `service.ts` from the agreed pagespec.', ].join('\n'), ].join('\n') } /** Used by `index.ts` to know where to write the report. */ export function reportPath(projectPath: string): string { return path.join(projectPath, '_audit', 'actions-alignment.md') } /** Used by tests to assert the file landed. */ export async function reportExists(projectPath: string): Promise { return fileExists(reportPath(projectPath)) }