From ba11aaf12385ec7e35b41d047be3b466ba8631eb Mon Sep 17 00:00:00 2001 From: Josh Wulf Date: Thu, 21 May 2026 17:12:18 +1200 Subject: [PATCH 1/2] refactor: make materializer a generic transformer (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The materializer no longer maintains a hand-written registry mapping template names to short on-disk directory names. The active config's scenario-templates ABox is the single source of truth: every template declared there is emitted to `generated//playwright/templates//`, mirroring the planner's `scenarios/templates//` layout. Adding a new template now requires only a new ABox row (plus, eventually, a renderer when one is needed). Layout change (visible to anyone reading the generated tree): generated//playwright/edges/ -> templates/EdgeLifecycle/ generated//playwright/entities/ -> templates/EntityLifecycle/ generated//playwright/runtime-entities/ -> templates/UpdatedFieldVisibleOnReadBack/ generated//playwright/state-transitions/ -> templates/StateTransitionVisibleAfterAction/ What changed: - `materializer/src/templateRegistry.ts` deleted. The materializer reads the ABox via `loadScenarioTemplatesAbox()` and iterates the resulting names. - `materializer/src/coverage.ts` now takes `templateNames: readonly string[]` and derives `emittedSpec = templates//.lifecycle.spec.ts` via the new `templateOutputDir(name)` helper. No taxonomy table. - `materializer/src/playwright/templateEmitter.ts` support-import paths bumped to `../../support/...` to reflect the one-level-deeper spec location. - L3 invariants (`configs/camunda-oca/regression-invariants.test.ts`) retargeted to the new paths. `+26 lifecycle suites, -73 suppressed` is unchanged; only the on-disk paths moved. - `tests/codegen/template-registry.test.ts` deleted (the symmetry it guarded — registry name ↔ ABox name — is structural now: the ABox IS the source). Loader already enforces no-duplicate names. - README, JSDoc comments updated. Counts byte-stable: 117 endpoints, +70 variant suites, +26 lifecycle suites, -73 suppressed. Closes #335 --- README.md | 2 +- .../camunda-oca/regression-invariants.test.ts | 18 ++++--- materializer/src/coverage.ts | 50 +++++++++++------- materializer/src/index.ts | 33 +++++++----- .../src/playwright/templateEmitter.ts | 26 +++++----- materializer/src/templateRegistry.ts | 51 ------------------- path-analyser/src/types.ts | 2 +- tests/codegen/coverage.test.ts | 13 ++--- tests/codegen/template-registry.test.ts | 45 ---------------- 9 files changed, 85 insertions(+), 155 deletions(-) delete mode 100644 materializer/src/templateRegistry.ts delete mode 100644 tests/codegen/template-registry.test.ts diff --git a/README.md b/README.md index b07ce02..63abb80 100644 --- a/README.md +++ b/README.md @@ -360,7 +360,7 @@ emits a self-contained, runnable Playwright project under runtime helpers (`support/`), fixtures, and a README. Template-derived suites (`#268` Phase 2 / `#270`) land under -`generated//playwright/edges/.lifecycle.spec.ts` — +`generated//playwright/templates/EdgeLifecycle/.lifecycle.spec.ts` — one per ABox-declared edge. Each suite runs the full lifecycle (establish → present-observe → revoke → absent-observe) and is sourced from `generated//scenarios/templates/EdgeLifecycle/.json`, diff --git a/configs/camunda-oca/regression-invariants.test.ts b/configs/camunda-oca/regression-invariants.test.ts index 8e76f75..7ef1a45 100644 --- a/configs/camunda-oca/regression-invariants.test.ts +++ b/configs/camunda-oca/regression-invariants.test.ts @@ -2327,7 +2327,8 @@ describeForThisConfig('bundled-spec invariants: fixture selection by required st // is unchanged, only the spec file moved. const spec = join( GENERATED_TESTS_DIR, - 'state-transitions', + 'templates', + 'StateTransitionVisibleAfterAction', 'Incident.resolveIncident.lifecycle.spec.ts', ); if (!existsSync(spec)) { @@ -7880,7 +7881,7 @@ describeForThisConfig('bundled-spec invariants: ontology visualisation emitter', // every (template × edge) pair declared in the ABoxes into a TemplateScenario // JSON file under generated//scenarios/templates//.json, // and the Playwright emitter materialises one -// generated//playwright/edges/.lifecycle.spec.ts per edge. +// generated//playwright/templates/EdgeLifecycle/.lifecycle.spec.ts per edge. // // These invariants pin the structural contract of that output — coverage // (every edge has a JSON + a .spec.ts), shape (5 steps in the established → @@ -7896,7 +7897,7 @@ describeForThisConfig( () => { const TEMPLATES_ROOT = join(SCENARIOS_DIR, 'templates'); const EDGE_LIFECYCLE_DIR = join(TEMPLATES_ROOT, 'EdgeLifecycle'); - const EDGES_SUITE_DIR = join(GENERATED_TESTS_DIR, 'edges'); + const EDGES_SUITE_DIR = join(GENERATED_TESTS_DIR, 'templates', 'EdgeLifecycle'); interface TemplateScenarioFile { templateName: string; @@ -8107,7 +8108,7 @@ describeForThisConfig( } }); - it('every edge has a generated Playwright lifecycle suite under generated//playwright/edges/', async () => { + it('every edge has a generated Playwright lifecycle suite under generated//playwright/templates/EdgeLifecycle/', async () => { const { loadEdgesAbox } = await import('../../path-analyser/src/ontology/loader.js'); const edges = loadEdgesAbox(REPO_ROOT); if (!edges) throw new Error('edges ABox missing'); @@ -8705,7 +8706,8 @@ describeForThisConfig( it('Incident.resolveIncident slice: emitted Playwright spec asserts state === RESOLVED via getIncident read-back', () => { const specPath = join( GENERATED_TESTS_DIR, - 'state-transitions', + 'templates', + 'StateTransitionVisibleAfterAction', 'Incident.resolveIncident.lifecycle.spec.ts', ); if (!existsSync(specPath)) { @@ -8726,7 +8728,8 @@ describeForThisConfig( it('UserTask.completeUserTask slice (#305 Phase 5d-2): emitted Playwright spec asserts state === COMPLETED via getUserTask read-back', () => { const specPath = join( GENERATED_TESTS_DIR, - 'state-transitions', + 'templates', + 'StateTransitionVisibleAfterAction', 'UserTask.completeUserTask.lifecycle.spec.ts', ); if (!existsSync(specPath)) { @@ -8747,7 +8750,8 @@ describeForThisConfig( it('ProcessInstance.cancelProcessInstance slice (#305 Phase 5d-4): emitted Playwright spec asserts state === CANCELED via getProcessInstance read-back, using cancellable-blocked.bpmn fixture', () => { const specPath = join( GENERATED_TESTS_DIR, - 'state-transitions', + 'templates', + 'StateTransitionVisibleAfterAction', 'ProcessInstance.cancelProcessInstance.lifecycle.spec.ts', ); if (!existsSync(specPath)) { diff --git a/materializer/src/coverage.ts b/materializer/src/coverage.ts index 8c42d6d..ca36565 100644 --- a/materializer/src/coverage.ts +++ b/materializer/src/coverage.ts @@ -1,4 +1,4 @@ -// Scenario-template coverage extractor (#331). +// Scenario-template coverage extractor (#331, #335). // // Walks the per-template scenario JSON files emitted by the planner // (`generated//scenarios/templates//*.json`) and @@ -13,9 +13,15 @@ // ScenarioTemplate added to `configs//ontology/scenario-templates.json` // (and emitted by the planner under a new subdirectory of // `scenarios/templates/`) extends suppression automatically as soon -// as it is included in the `templates` option passed to `buildCoverage` -// (in production, that's `TEMPLATE_REGISTRY` — see -// `materializer/src/templateRegistry.ts`). +// as its name appears in `templateNames`. In production the caller +// passes the ABox's template names directly — there is no separate +// materializer-side registry to keep in sync (#335). +// +// The on-disk output dir for each template is derived purely from the +// template name: `playwright/templates//`. This mirrors +// the planner's `scenarios/templates//` layout so the +// scenario JSON → emitted spec relationship is visible from the +// directory structure alone. // // `PrereqChain` steps are intentionally *excluded* from coverage — // they are scaffolding, not units-under-test. The Invoke/Observe vs @@ -24,7 +30,16 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { TemplateBinding } from './templateRegistry.js'; + +/** + * Subdirectory under the Playwright suite root where the materializer + * writes per-template suites. Derived purely from the template name — + * no taxonomy translation. Mirrors the planner's + * `scenarios/templates//` layout (#335). + */ +export function templateOutputDir(templateName: string): string { + return path.join('templates', templateName); +} export interface CoverageEntry { operationId: string; @@ -47,16 +62,16 @@ export interface BuildCoverageOptions { * to treat every template as suppressing (the default). */ templatesAboxPath: string | undefined; /** - * The materializer's scenario-template registry — the single source - * of truth for which templates emit a spec and where. For each - * scenario file under - * `//.json` the - * recorded `emittedSpec` becomes - * `/.lifecycle.spec.ts`. Templates - * not present in this registry are skipped (no spec is emitted on + * The set of scenario-template names the materializer recognises + * (in production, the names declared by the active config's + * scenario-templates ABox). For each scenario file under + * `//.json` + * the recorded `emittedSpec` becomes + * `templates//.lifecycle.spec.ts`. + * Templates not in this list are skipped (no spec is emitted on * disk, so coverage cannot legitimately claim the op). */ - templates: readonly TemplateBinding[]; + templateNames: readonly string[]; } interface ScenarioStep { @@ -95,10 +110,11 @@ export async function buildCoverage(opts: BuildCoverageOptions): Promise [t.name, t.outputDir])); + // Build a name → outputDir lookup. Templates not in `templateNames` + // are skipped (no spec is emitted on disk, so coverage cannot + // legitimately claim the op). The output dir is derived purely from + // the name (`templates//`) — no taxonomy table. + const outputDirByName = new Map(opts.templateNames.map((n) => [n, templateOutputDir(n)])); for (const templateName of templateDirs) { const templateDir = path.join(opts.templateScenariosRootDir, templateName); diff --git a/materializer/src/index.ts b/materializer/src/index.ts index 3be8762..930d6a4 100644 --- a/materializer/src/index.ts +++ b/materializer/src/index.ts @@ -25,11 +25,12 @@ import { assertSafeGlobalContextSeeds, deriveArtifactKindsViews, deriveGlobalContextSeedsViews, + loadScenarioTemplatesAbox, } from 'path-analyser/ontology/loader'; import { getEmitterRoleForOperation } from 'path-analyser/ontology/operationRoles'; import type { EndpointScenarioCollection, GlobalContextSeed } from 'path-analyser/types'; import { parseCliArgs } from './cli-args.js'; -import { buildCoverage, type CoverageResult } from './coverage.js'; +import { buildCoverage, type CoverageResult, templateOutputDir } from './coverage.js'; import { writeEmitted, writeScaffolded } from './orchestrator.js'; import { PlaywrightEmitter } from './playwright/emitter.js'; import { @@ -40,7 +41,6 @@ import { } from './playwright/materialize-support.js'; import { loadRoleBundlesForActiveConfig } from './playwright/roleRenderer.js'; import { emitTemplateSuites } from './playwright/templateEmitter.js'; -import { TEMPLATE_REGISTRY } from './templateRegistry.js'; // Built-in emitter registrations. RoleHookProviders are no longer // registered statically here: every provider lives next to its role @@ -469,20 +469,29 @@ async function run() { await writeScaffolded(emitter, buildCtx('', 'feature')); if (positional === '--all') { + // #335: scenario-template names are derived from the active + // config's scenario-templates ABox — the single source of truth. + // The materializer is a generic transformer: for each ABox row it + // reads `scenarios/templates//` and emits to + // `playwright/templates//`. The on-disk layout mirrors the + // planner's so the scenario → emitted-spec relationship is visible + // from the directory structure alone. Returns `[]` when no ABox + // ships (template suites are then a no-op). + const templatesAbox = loadScenarioTemplatesAbox(repoRoot); + const templateNames = (templatesAbox?.templates ?? []).map((t) => t.name); + // #331: scenario-template coverage. Build the suppression set // from on-disk template scenario JSONs before the feature loop so // operations covered by a well-formed scenario-template spec do // not also emit a structurally weaker per-endpoint feature spec. // Suppression only applies to emitters that ship the - // corresponding template suites; for now that is Playwright. The - // canonical list of templates lives in `TEMPLATE_REGISTRY` - // (#333) — see `materializer/src/templateRegistry.ts` and #331. + // corresponding template suites; for now that is Playwright. let coverage: CoverageResult = { suppressedOpIds: new Set(), entries: [] }; if (emitter.id === PlaywrightEmitter.id) { coverage = await buildCoverage({ templateScenariosRootDir: getTemplateScenariosRootDir(repoRoot), templatesAboxPath: path.join(configDir, 'ontology', 'scenario-templates.json'), - templates: TEMPLATE_REGISTRY, + templateNames, }); } let count = 0; @@ -535,28 +544,28 @@ async function run() { // Template-derived suites (Lift 22 / #270; extended in #280 with // EntityLifecycle, #305 with UpdatedFieldVisibleOnReadBack + // StateTransitionVisibleAfterAction). One Playwright suite per - // subject under `//`. + // subject under `/templates//`. // Only the Playwright emitter wires this — other emitters opt in // by implementing their own template-aware renderer. The scenarios // are produced by the planner (`scenarioTemplateInstantiator.ts`); // if a directory is missing (older planner runs, configs that // don't ship the corresponding template), `emitTemplateSuites` - // no-ops. Adding a new template is one entry in `TEMPLATE_REGISTRY` - // (#333). + // no-ops. Adding a new template requires only an ABox row in + // `configs//ontology/scenario-templates.json` (#335). let lifecycleCount = 0; if (emitter.id === PlaywrightEmitter.id) { const seedsArg = globalContextSeeds.map((s) => ({ binding: s.binding, seedRule: s.seedRule, })); - for (const template of TEMPLATE_REGISTRY) { - const templateOutDir = path.join(outDir, template.outputDir); + for (const templateName of templateNames) { + const templateOutDir = path.join(outDir, templateOutputDir(templateName)); // Wipe the per-template subdir for the same reason the parent // `outDir` is wiped above: stale specs from a previous spec // version must not survive into the current run. await fs.rm(templateOutDir, { recursive: true, force: true }); const written = await emitTemplateSuites({ - scenariosDir: getTemplateScenariosDir(repoRoot, template.name), + scenariosDir: getTemplateScenariosDir(repoRoot, templateName), outDir: templateOutDir, globalContextSeeds: seedsArg, }); diff --git a/materializer/src/playwright/templateEmitter.ts b/materializer/src/playwright/templateEmitter.ts index 4b1eff6..554bae5 100644 --- a/materializer/src/playwright/templateEmitter.ts +++ b/materializer/src/playwright/templateEmitter.ts @@ -38,7 +38,7 @@ export interface EmitTemplateSuitesOptions { scenariosDir: string; /** * Absolute path to the destination directory, i.e. - * `generated//playwright/edges/`. Wiped and recreated by the + * `generated//playwright/templates/EdgeLifecycle/`. Wiped and recreated by the * caller (the materializer's `run()`). */ outDir: string; @@ -265,15 +265,15 @@ function renderLifecycleSuite( const lines: string[] = []; lines.push("import { expect, test } from '@playwright/test';"); - lines.push("import { authHeaders, buildBaseUrl } from '../support/env';"); + lines.push("import { authHeaders, buildBaseUrl } from '../../support/env';"); const seedingImports = ['initSpecSalt', 'seedBinding']; if (needsExtractInto) seedingImports.push('extractInto'); - lines.push(`import { ${seedingImports.join(', ')} } from '../support/seeding';`); + lines.push(`import { ${seedingImports.join(', ')} } from '../../support/seeding';`); if (needsAwaitEventually) { - lines.push("import { awaitEventually } from '../support/await-eventually';"); + lines.push("import { awaitEventually } from '../../support/await-eventually';"); } if (needsResolveFixture) { - lines.push("import { resolveFixture } from '../support/fixtures';"); + lines.push("import { resolveFixture } from '../../support/fixtures';"); } lines.push(''); lines.push(`initSpecSalt('${file.subjectName}.lifecycle');`); @@ -650,15 +650,15 @@ function renderReadBackSuite( const lines: string[] = []; lines.push("import { expect, test } from '@playwright/test';"); - lines.push("import { authHeaders, buildBaseUrl } from '../support/env';"); + lines.push("import { authHeaders, buildBaseUrl } from '../../support/env';"); const seedingImports = ['initSpecSalt', 'seedBinding']; if (needsExtractInto) seedingImports.push('extractInto'); - lines.push(`import { ${seedingImports.join(', ')} } from '../support/seeding';`); + lines.push(`import { ${seedingImports.join(', ')} } from '../../support/seeding';`); if (needsAwaitEventually) { - lines.push("import { awaitEventually } from '../support/await-eventually';"); + lines.push("import { awaitEventually } from '../../support/await-eventually';"); } if (needsResolveFixture) { - lines.push("import { resolveFixture } from '../support/fixtures';"); + lines.push("import { resolveFixture } from '../../support/fixtures';"); } lines.push(''); lines.push(`initSpecSalt('${file.subjectName}.lifecycle');`); @@ -837,15 +837,15 @@ function renderStateTransitionSuite( const lines: string[] = []; lines.push("import { expect, test } from '@playwright/test';"); - lines.push("import { authHeaders, buildBaseUrl } from '../support/env';"); + lines.push("import { authHeaders, buildBaseUrl } from '../../support/env';"); const seedingImports = ['initSpecSalt', 'seedBinding']; if (needsExtractInto) seedingImports.push('extractInto'); - lines.push(`import { ${seedingImports.join(', ')} } from '../support/seeding';`); + lines.push(`import { ${seedingImports.join(', ')} } from '../../support/seeding';`); if (needsAwaitEventually) { - lines.push("import { awaitEventually } from '../support/await-eventually';"); + lines.push("import { awaitEventually } from '../../support/await-eventually';"); } if (needsResolveFixture) { - lines.push("import { resolveFixture } from '../support/fixtures';"); + lines.push("import { resolveFixture } from '../../support/fixtures';"); } lines.push(''); lines.push(`initSpecSalt('${file.subjectName}.state-transition');`); diff --git a/materializer/src/templateRegistry.ts b/materializer/src/templateRegistry.ts deleted file mode 100644 index dd6af44..0000000 --- a/materializer/src/templateRegistry.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Scenario-template registry (#333). -// -// Single source of truth for the materializer's knowledge of which -// scenario templates exist and where their emitted Playwright suites -// live. Consumed by: -// -// - `materializer/src/index.ts` — the template emission loop reads -// this registry to wire `emitTemplateSuites` per template, instead -// of open-coding one block per template. -// - `materializer/src/coverage.ts` — `buildCoverage` reads this -// registry to map template-name → emitted output directory when -// computing the per-endpoint feature-spec suppression set (#331). -// -// Adding a new ScenarioTemplate normally requires two coordinated edits: -// -// 1. Add an ABox row in `configs//ontology/scenario-templates.json`. -// 2. Add one entry here. -// -// The TBox in `path-analyser/src/ontology/scenarioTemplateSchema.ts` -// is name-agnostic (template names are just strings), so it only needs -// updating when the ABox row *shape* changes (a new step kind, a new -// `appliesTo` kind, etc.). Likewise, `emitTemplateSuites` only needs -// extending when the new template's rendered shape genuinely differs -// from the lifecycle templates it already handles. -// -// No other site in the orchestrator needs to know about the new -// template. The guard in `tests/codegen/template-registry.test.ts` -// asserts symmetric equality between the active config's ABox and -// this registry so an ABox row without a registry entry (or vice -// versa) fails red. -// -// Step 2 of #333 (deriving the registry directly from the ABox) is -// deferred; that requires teaching the materializer to dispatch -// emitters by name when future templates need shapes beyond the -// universal `emitTemplateSuites` renderer. - -export interface TemplateBinding { - /** Template name as declared in `scenarioTemplateSchema.ts` and as - * the subdirectory name under `generated//scenarios/templates/`. */ - name: string; - /** Output subdirectory relative to the Playwright suite root, - * i.e. `generated//playwright//`. */ - outputDir: string; -} - -export const TEMPLATE_REGISTRY: readonly TemplateBinding[] = [ - { name: 'EdgeLifecycle', outputDir: 'edges' }, - { name: 'EntityLifecycle', outputDir: 'entities' }, - { name: 'UpdatedFieldVisibleOnReadBack', outputDir: 'runtime-entities' }, - { name: 'StateTransitionVisibleAfterAction', outputDir: 'state-transitions' }, -]; diff --git a/path-analyser/src/types.ts b/path-analyser/src/types.ts index a8f1249..5e4169d 100644 --- a/path-analyser/src/types.ts +++ b/path-analyser/src/types.ts @@ -944,7 +944,7 @@ export interface GeneratedModelSpec { // BFS-derived `EndpointScenario`s. They are written to // `generated//scenarios/templates//.json` // and consumed by the Playwright emitter to produce -// `generated//playwright/edges/.lifecycle.spec.ts`. +// `generated//playwright/templates/EdgeLifecycle/.lifecycle.spec.ts`. // The two output trees are independent — no field on `EndpointScenario` // is touched here. // --------------------------------------------------------------------------- diff --git a/tests/codegen/coverage.test.ts b/tests/codegen/coverage.test.ts index 78b2988..3b7f47e 100644 --- a/tests/codegen/coverage.test.ts +++ b/tests/codegen/coverage.test.ts @@ -57,7 +57,7 @@ describe('buildCoverage (#331)', () => { const result = await buildCoverage({ templateScenariosRootDir: root, templatesAboxPath: undefined, - templates: [{ name: 'EntityLifecycle', outputDir: 'entities' }], + templateNames: ['EntityLifecycle'], }); expect([...result.suppressedOpIds].sort()).toEqual(['createUser', 'getUser']); @@ -86,10 +86,7 @@ describe('buildCoverage (#331)', () => { const result = await buildCoverage({ templateScenariosRootDir: root, templatesAboxPath: aboxPath, - templates: [ - { name: 'EntityLifecycle', outputDir: 'entities' }, - { name: 'SmokeTemplate', outputDir: 'smoke' }, - ], + templateNames: ['EntityLifecycle', 'SmokeTemplate'], }); expect(result.suppressedOpIds.has('createUser')).toBe(true); @@ -107,14 +104,14 @@ describe('buildCoverage (#331)', () => { const result = await buildCoverage({ templateScenariosRootDir: root, templatesAboxPath: undefined, - templates: [{ name: 'EntityLifecycle', outputDir: 'entities' }], + templateNames: ['EntityLifecycle'], }); expect([...result.suppressedOpIds].sort()).toEqual(['createUser', 'getUser']); expect(result.suppressedOpIds.has('createTenant')).toBe(false); }); - test('templates not present in the buildCoverage templates option are skipped (no spec emitted, no coverage claimed)', async () => { + test('templates not present in the buildCoverage templateNames option are skipped (no spec emitted, no coverage claimed)', async () => { const root = path.join(tmp, 'scenarios', 'templates'); await writeScenario(root, 'UnwiredTemplate', 'Thing', [ { kind: 'invoke', operationId: 'doThing' }, @@ -123,7 +120,7 @@ describe('buildCoverage (#331)', () => { const result = await buildCoverage({ templateScenariosRootDir: root, templatesAboxPath: undefined, - templates: [], // not wired + templateNames: [], // not wired }); expect(result.suppressedOpIds.size).toBe(0); diff --git a/tests/codegen/template-registry.test.ts b/tests/codegen/template-registry.test.ts deleted file mode 100644 index f52ae28..0000000 --- a/tests/codegen/template-registry.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import path from 'node:path'; -import { describe, expect, test } from 'vitest'; -import { TEMPLATE_REGISTRY } from '../../materializer/src/templateRegistry.ts'; -import { loadScenarioTemplatesAbox } from '../../path-analyser/src/ontology/loader.ts'; - -const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..'); - -/** - * Registry / ABox symmetry guard (#333). - * - * Locks in the invariant that the active config's scenario-templates - * ABox and the materializer's `TEMPLATE_REGISTRY` agree on which - * templates exist. Drift in either direction is a real defect: - * - * - Template in ABox but not in registry → planner emits scenario - * JSONs that the orchestrator never renders to a spec, and - * `buildCoverage` silently skips them. Adding a template would - * appear to do nothing. - * - Template in registry but not in ABox → orchestrator wipes and - * re-creates an output dir for a template whose scenarios the - * planner never produced; the dir stays empty and `buildCoverage` - * sees no scenarios. Effectively dead wiring. - * - * The single registry file (`materializer/src/templateRegistry.ts`) - * is the only place to wire a new template into the orchestrator. - */ -describe('TEMPLATE_REGISTRY ↔ scenario-templates ABox symmetry (#333)', () => { - test('every ABox template has a registry entry, and vice versa', () => { - const abox = loadScenarioTemplatesAbox(REPO_ROOT); - if (!abox) { - throw new Error( - 'scenario-templates ABox missing for the active config — cannot verify registry symmetry', - ); - } - const aboxNames = abox.templates.map((t) => t.name).sort(); - const registryNames = TEMPLATE_REGISTRY.map((t) => t.name).sort(); - expect(registryNames).toEqual(aboxNames); - }); - - test('registry outputDirs are distinct (no two templates clobber the same subdir)', () => { - const outputDirs = TEMPLATE_REGISTRY.map((t) => t.outputDir); - const unique = new Set(outputDirs); - expect(unique.size).toBe(outputDirs.length); - }); -}); From d9cba4a3bbaa0d160b9857895e7f168ac6b86f37 Mon Sep 17 00:00:00 2001 From: Josh Wulf Date: Thu, 21 May 2026 17:28:48 +1200 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20generalize=20EmitTemplateSuitesOptions=20JSDoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot reviewer noted that the JSDoc on scenariosDir and outDir still referenced EdgeLifecycle specifically, even though emitTemplateSuites is now driven by templateNames and used for both EdgeLifecycle and EntityLifecycle (and future templates). Replace the hard-coded template name in both paths with so the documentation stays accurate as new templates are added. --- materializer/src/playwright/templateEmitter.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/materializer/src/playwright/templateEmitter.ts b/materializer/src/playwright/templateEmitter.ts index 554bae5..d86dd42 100644 --- a/materializer/src/playwright/templateEmitter.ts +++ b/materializer/src/playwright/templateEmitter.ts @@ -30,16 +30,16 @@ export interface TemplateGlobalContextSeed { export interface EmitTemplateSuitesOptions { /** - * Absolute path to the EdgeLifecycle scenarios directory, i.e. - * `generated//scenarios/templates/EdgeLifecycle/`. - * Each `.json` underneath is read and rendered to one - * `.lifecycle.spec.ts`. + * Absolute path to a template's scenarios directory, i.e. + * `generated//scenarios/templates//` + * (e.g. `EdgeLifecycle`, `EntityLifecycle`). Each `.json` underneath + * is read and rendered to one `.lifecycle.spec.ts`. */ scenariosDir: string; /** * Absolute path to the destination directory, i.e. - * `generated//playwright/templates/EdgeLifecycle/`. Wiped and recreated by the - * caller (the materializer's `run()`). + * `generated//playwright/templates//`. Wiped + * and recreated by the caller (the materializer's `run()`). */ outDir: string; /**