/** * scaffold-controller — the guarded legacy sweep (lib/guarded-rm) through the * REAL index.ts: the pre-classification controller + permissions class * (`.Api/Controllers//…`, `.Api/Permissions//…`) are * removed on re-run and traced in `data.filesRemoved`; an `@customised` * legacy file survives and is warned; nothing is reported when nothing was * there (audit 2026-09-24, A-WARN-11). */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { runCli } from '../../../../../../lib/__tests__/helpers/run-cli.js' import { generate, legacyPaths } from '../generate.js' import { ScaffoldControllerInputSchema } from '../types.js' const INDEX = resolve(dirname(fileURLToPath(import.meta.url)), '../index.ts') const CUSTOMISED_HEAD = '// @customised — hand-written, the developer owns it\n' const norm = (p: string): string => p.replace(/\\/g, '/') /** The invocation documented in backend/controller/SKILL.md (## Invocation). */ const spec = (projectPath: string) => ({ name: 'Employee', module: 'hrm', appCode: 'MyApp', applicationCode: 'hr', section: 'employees', navRoute: 'hrm.employees', fields: [{ name: 'firstName', type: 'string', required: true }, { name: 'lastName', type: 'string', required: true }], projectPath, }) const LEGACY_CONTROLLER = 'src/MyApp.Api/Controllers/Hrm/EmployeesController.cs' const LEGACY_PERMISSIONS = 'src/MyApp.Api/Permissions/Hrm/HrmPermissions.Employees.cs' let root: string beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'ss-controller-legacy-')) }) afterEach(() => rmSync(root, { recursive: true, force: true })) function plant(rel: string, content: string): string { const abs = join(root, rel) mkdirSync(dirname(abs), { recursive: true }) writeFileSync(abs, content, 'utf-8') return abs } function run() { const r = runCli(INDEX, ['--spec', JSON.stringify(spec(root))]) expect(r.envelope, r.stderr).not.toBeNull() expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) return r } describe('scaffold-controller / legacyPaths — the pre-classification locations', () => { it('names the module-only controller and the module-only permissions partial', () => { const parsed = ScaffoldControllerInputSchema.parse(spec(root)) expect(legacyPaths(parsed)).toEqual([LEGACY_CONTROLLER, LEGACY_PERMISSIONS]) }) it('never names a file the current run is about to write', () => { const parsed = ScaffoldControllerInputSchema.parse(spec(root)) const current = new Set(generate(parsed).map(f => f.path)) for (const p of legacyPaths(parsed)) expect(current.has(p), `legacyPaths would delete a current output: ${p}`).toBe(false) }) }) describe('scaffold-controller / guarded legacy sweep', () => { it('removes an unmarked legacy file (traced in data.filesRemoved) and keeps a @customised one with a warning', () => { const bespoke = `${CUSTOMISED_HEAD}public static partial class HrmPermissions { }\n` const permAbs = plant(LEGACY_PERMISSIONS, bespoke) const ctrlAbs = plant(LEGACY_CONTROLLER, 'public class EmployeesController { }\n') const r = run() expect(existsSync(ctrlAbs)).toBe(false) expect(r.envelope.data.filesRemoved).toEqual([LEGACY_CONTROLLER]) expect(existsSync(permAbs)).toBe(true) expect(readFileSync(permAbs, 'utf-8')).toBe(bespoke) expect(r.envelope.warnings).toContain(`legacy path ${LEGACY_PERMISSIONS} kept: marked @customised — delete manually if truly superseded.`) // The classified outputs land regardless. const created: string[] = r.envelope.filesCreated.map(norm) expect(created.some(p => p.endsWith('/src/MyApp.Api/Controllers/Hr/Hrm/EmployeesController.cs'))).toBe(true) expect(r.envelope.data.skipped).toEqual([]) }) it('with no legacy file on disk the sweep reports nothing and warns nothing', () => { const r = run() expect(r.envelope.data.filesRemoved).toEqual([]) expect(r.envelope.warnings.some((w: string) => /legacy path/.test(w))).toBe(false) }) it('a re-run after the relocate is a clean no-op on the sweep (the legacy copy is already gone)', () => { plant(LEGACY_CONTROLLER, 'public class EmployeesController { }\n') const r1 = run() expect(r1.envelope.data.filesRemoved).toEqual([LEGACY_CONTROLLER]) const r2 = run() expect(r2.envelope.data.filesRemoved).toEqual([]) }) it('a @customised CURRENT output is never overwritten (data.skipped + warning)', () => { const parsed = ScaffoldControllerInputSchema.parse(spec(root)) const ctrlRel = generate(parsed).map(f => f.path).find(p => p.endsWith('/EmployeesController.cs'))! const bespoke = `${CUSTOMISED_HEAD}public class EmployeesController { /* mine */ }\n` const abs = plant(ctrlRel, bespoke) const r = run() expect(readFileSync(abs, 'utf-8')).toBe(bespoke) expect(r.envelope.data.skipped.map(norm)).toEqual([norm(abs)]) expect(r.envelope.filesCreated.map(norm)).not.toContain(norm(abs)) expect(r.envelope.warnings.some((w: string) => w.includes('kept: marked @customised — not overwritten'))).toBe(true) }) })