/** * lib/app-code — ONE grammar for a scaffolder `appCode` (audit 2026-09-24, D10). * * FIRES: until 2026-09-24 six backend scaffolders each carried an inline regex — * scaffold-core-seed / scaffold-seed accepted `test-v2`, scaffold-entity / * scaffold-business / scaffold-controller / scaffold-screen-controller refused it, * so the same spec passed Phase 0 of /ba-develop and died in Phase 1. The contract * block below imports the six REAL schemas (seven `appCode` fields) and is red on * any disagreement between them, or between them and the lib. * SILENT: the real scaffold-entity entry point (index.ts spawned through tsx, the * documented invocation) accepts an underscore appCode end to end; a kebab one is * refused by all six (it would become an invalid C# namespace root). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { APP_CODE_MESSAGE, APP_CODE_RE } from '../app-code.js' import { REPO_ROOT, runCli } from './helpers/run-cli.js' import { ScaffoldBusinessInputSchema } from '../../development/backend/business-layer/cli/scaffold-business/types.js' import { ScaffoldControllerInputSchema } from '../../development/backend/controller/cli/scaffold-controller/types.js' import { ScaffoldEntityInputSchema } from '../../development/backend/data-layer/cli/scaffold-entity/types.js' import { PageSpecSchema, ScaffoldScreenControllerSpecSchema } from '../../development/backend/screen-controller/cli/scaffold-screen-controller/types.js' import { CoreSeedSpecSchema } from '../../development/backend/core-seed/cli/scaffold-core-seed/types.js' import { ScaffoldSeedInputSchema } from '../../development/backend/seed-data/cli/scaffold-seed/types.js' /** What `ss init` lets through as a project name, plus the kebab names the seed scaffolders already served. */ const ACCEPTED = ['MyApp', 'My_App', 'App2', '_App', 'TestV2', 'Hr', 'a', 'myapp'] /** Anything that could build a path outside the project, or no path at all. */ const REFUSED = ['test-v2', '../x', 'a/b', 'a\\b', 'a b', '.hidden', '', '2App', '-x', 'My.App', ' MyApp', 'MyApp\n', 'MyApp.Api'] const SAMPLES = [...ACCEPTED, ...REFUSED] interface AppCodeField { safeParse(value: unknown): { success: boolean; error?: { issues: Array<{ message: string }> } } } interface AppCodeSchema { shape: { appCode: AppCodeField } } /** The six real schemas — seven `appCode` fields (screen-controller carries two). */ const SCHEMAS: ReadonlyArray = [ ['scaffold-business.ScaffoldBusinessInputSchema', ScaffoldBusinessInputSchema], ['scaffold-controller.ScaffoldControllerInputSchema', ScaffoldControllerInputSchema], ['scaffold-entity.ScaffoldEntityInputSchema', ScaffoldEntityInputSchema], ['scaffold-screen-controller.PageSpecSchema', PageSpecSchema], ['scaffold-screen-controller.ScaffoldScreenControllerSpecSchema', ScaffoldScreenControllerSpecSchema], ['scaffold-core-seed.CoreSeedSpecSchema', CoreSeedSpecSchema], ['scaffold-seed.ScaffoldSeedInputSchema', ScaffoldSeedInputSchema], ] const verdicts = (schema: AppCodeSchema): boolean[] => SAMPLES.map(v => schema.shape.appCode.safeParse(v).success) describe('APP_CODE_RE — the one grammar', () => { it.each(ACCEPTED)('accepts %j', (v) => { expect(APP_CODE_RE.test(v)).toBe(true) }) it.each(REFUSED)('refuses %j', (v) => { expect(APP_CODE_RE.test(v)).toBe(false) }) it('the message names what appCode is, what it is not, and the example that used to split the pipeline', () => { expect(APP_CODE_MESSAGE).toContain('src/{appCode}.Api') expect(APP_CODE_MESSAGE).toContain('test-v2') expect(APP_CODE_MESSAGE).toContain('My_App') expect(APP_CODE_MESSAGE).toMatch(/namespace/) expect(APP_CODE_MESSAGE).toMatch(/applicationCode/) }) }) describe('contract — the six scaffolder schemas give the SAME verdict as the lib (FIRES on `test-v2` before 2026-09-24)', () => { it.each(SCHEMAS)('%s accepts exactly the lib set', (name, schema) => { for (const v of ACCEPTED) { expect(schema.shape.appCode.safeParse(v).success, `${name} should accept ${JSON.stringify(v)}`).toBe(true) } for (const v of REFUSED) { expect(schema.shape.appCode.safeParse(v).success, `${name} should refuse ${JSON.stringify(v)}`).toBe(false) } }) it('the seven appCode fields agree with each other on every sample (the D10 disagreement: core-seed said yes, entity said no)', () => { const [first, ...rest] = SCHEMAS const reference = verdicts(first[1]) for (const [name, schema] of rest) { const row = verdicts(schema) const disagreements = SAMPLES.filter((_, i) => row[i] !== reference[i]) expect(disagreements, `${name} disagrees with ${first[0]} on ${JSON.stringify(disagreements)}`).toEqual([]) } }) it('a refusal carries the ONE shared message — the schemas import it, they do not copy it', () => { for (const [name, schema] of SCHEMAS) { const r = schema.shape.appCode.safeParse('a/b') expect(r.success, name).toBe(false) expect(r.error?.issues.map(i => i.message), name).toContain(APP_CODE_MESSAGE) } }) it('an empty appCode is still refused by every schema (`.min(1)` kept next to the shared regex)', () => { for (const [name, schema] of SCHEMAS) { expect(schema.shape.appCode.safeParse('').success, name).toBe(false) } }) }) describe('SILENT — the real scaffold-entity entry point accepts an underscore appCode end to end', () => { const INDEX = resolve(REPO_ROOT, 'templates/skills/development/backend/data-layer/cli/scaffold-entity/index.ts') let tmp: string beforeAll(() => { tmp = mkdtempSync(join(tmpdir(), 'ss-app-code-')) }) afterAll(() => { rmSync(tmp, { recursive: true, force: true }) }) it('scaffold-entity with appCode "My_App" answers success:true and emits under the My_App.* projects (the six schemas share one grammar)', () => { const spec = { name: 'JourFerie', module: 'hrm', appCode: 'My_App', applicationCode: 'hr', domainPrefix: 'hrm', fields: [{ name: 'Libelle', type: 'string', maxLength: 100 }], projectPath: tmp, } const r = runCli(INDEX, ['--spec', JSON.stringify(spec)]) expect(r.envelope, `stdout was: ${r.stdout.slice(0, 300)} / stderr: ${r.stderr.slice(0, 300)}`).not.toBeNull() expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) const created: string[] = (r.envelope.filesCreated ?? []).map((f: string) => f.replace(/\\/g, '/')) expect(created.some(f => f.endsWith('/JourFerie.cs')), created.join('\n')).toBe(true) expect(created.some(f => f.includes('/My_App.')), created.join('\n')).toBe(true) }) })