@pattern-stack/codegen 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Pattern Registry — library + app pattern storage and discovery.
3
+ *
4
+ * Three stores keyed by pattern name:
5
+ * - `LIBRARY_PATTERNS` — seeded by the codegen package itself when the
6
+ * `src/patterns/library/*` barrel imports execute. Consumers never
7
+ * list these in `codegen.config.yaml patterns:`. Domain only.
8
+ * - `APP_PATTERNS` — populated by `loadAppPatterns()` from a
9
+ * consumer-supplied glob set (default `src/patterns/*.pattern.ts`).
10
+ * Domain only.
11
+ * - `ORCHESTRATION_APP_PATTERNS` — populated by the same loader,
12
+ * routed by `kind: 'orchestration'` (ADR-032). No library
13
+ * orchestration patterns ship in Phase 3-1.
14
+ *
15
+ * `getPattern()` checks app patterns first so a consumer could, in
16
+ * principle, shadow a library pattern by using the same `name`. That's
17
+ * not a documented feature, but nothing in the API prevents it.
18
+ *
19
+ * The Hygen subprocess (`src/cli/shared/hygen.ts:64`) reloads this module
20
+ * independently — it has no shared memory with the CLI process. Both
21
+ * loads are deterministic, side-effect-free reads of the same files, so
22
+ * the registry contents are identical across processes. The registry
23
+ * test suite asserts this determinism explicitly.
24
+ *
25
+ * See `docs/adrs/ADR-031-app-defined-patterns.md` §"Decision 5" and
26
+ * `docs/specs/app-defined-patterns-implementation.md` §3.
27
+ */
28
+
29
+ import { glob } from 'glob';
30
+ import path from 'node:path';
31
+ import { pathToFileURL } from 'node:url';
32
+ import {
33
+ isOrchestrationPattern,
34
+ isPatternDefinition,
35
+ type AnyPatternDefinition,
36
+ type OrchestrationPatternDefinition,
37
+ type PatternDefinition,
38
+ } from './pattern-definition.js';
39
+
40
+ // ============================================================================
41
+ // Stores
42
+ // ============================================================================
43
+
44
+ const LIBRARY_PATTERNS: Map<string, PatternDefinition> = new Map();
45
+ const APP_PATTERNS: Map<string, PatternDefinition> = new Map();
46
+
47
+ /**
48
+ * Orchestration patterns (ADR-032). Library never ships orchestration
49
+ * patterns in Phase 3-1 — only the app-pattern map exists for this kind.
50
+ * If a library-shipped orchestration pattern ever lands, add a parallel
51
+ * `LIBRARY_ORCHESTRATION_PATTERNS` map; for now keep storage minimal.
52
+ */
53
+ const ORCHESTRATION_APP_PATTERNS: Map<string, OrchestrationPatternDefinition> =
54
+ new Map();
55
+
56
+ /**
57
+ * Every pattern must contribute *something* — either at least one column
58
+ * or at least one of the two class references. A pattern that contributes
59
+ * nothing would generate no useful output and almost certainly indicates
60
+ * a typo or an unfinished definition.
61
+ */
62
+ function assertHasContribution(def: PatternDefinition): void {
63
+ const hasColumns = Array.isArray(def.columns) && def.columns.length > 0;
64
+ const hasRepo =
65
+ typeof def.repositoryClass === 'string' && def.repositoryClass.length > 0;
66
+ const hasService =
67
+ typeof def.serviceClass === 'string' && def.serviceClass.length > 0;
68
+
69
+ if (!hasColumns && !hasRepo && !hasService) {
70
+ throw new Error(
71
+ `Pattern '${def.name}' contributes nothing — at least one of ` +
72
+ '`columns`, `repositoryClass`, or `serviceClass` is required.',
73
+ );
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Orchestration counterpart to `assertHasContribution`. An orchestration
79
+ * pattern's minimum contribution is one registry with at least one entry —
80
+ * a registry with zero entries would emit a token + module that nothing
81
+ * resolves to, almost certainly a typo. Detailed entry validation
82
+ * (duplicate keys, malformed entries, co-keyed mismatches) lives in the
83
+ * project-level validator so loader behaviour stays symmetrical with the
84
+ * domain side: load is non-throwing for content-level issues, validator
85
+ * is the single authoritative reporter.
86
+ */
87
+ function assertOrchestrationContribution(
88
+ def: OrchestrationPatternDefinition,
89
+ ): void {
90
+ if (!def.registry || typeof def.registry !== 'object') {
91
+ throw new Error(
92
+ `Orchestration pattern '${def.name}' is missing a 'registry' field.`,
93
+ );
94
+ }
95
+ if (
96
+ typeof def.registry.keyType !== 'string' ||
97
+ def.registry.keyType.length === 0
98
+ ) {
99
+ throw new Error(
100
+ `Orchestration pattern '${def.name}' registry.keyType must be a non-empty string.`,
101
+ );
102
+ }
103
+ if (
104
+ typeof def.registry.valueType !== 'string' ||
105
+ def.registry.valueType.length === 0
106
+ ) {
107
+ throw new Error(
108
+ `Orchestration pattern '${def.name}' registry.valueType must be a non-empty string.`,
109
+ );
110
+ }
111
+ if (
112
+ !Array.isArray(def.registry.entries) ||
113
+ def.registry.entries.length === 0
114
+ ) {
115
+ throw new Error(
116
+ `Orchestration pattern '${def.name}' registry.entries must contain at least one entry.`,
117
+ );
118
+ }
119
+ }
120
+
121
+ // ============================================================================
122
+ // Library pattern registration
123
+ // ============================================================================
124
+
125
+ /**
126
+ * Insert a library pattern into the registry. Called once by each
127
+ * `src/patterns/library/*.pattern.ts` file via the barrel. Re-registering
128
+ * the same name overwrites the previous value silently; this is
129
+ * intentional for hot-reload scenarios but should not happen in normal
130
+ * use.
131
+ */
132
+ export function registerLibraryPattern(def: PatternDefinition): void {
133
+ assertHasContribution(def);
134
+ LIBRARY_PATTERNS.set(def.name, def);
135
+ }
136
+
137
+ // ============================================================================
138
+ // Lookup
139
+ // ============================================================================
140
+
141
+ /**
142
+ * Resolve a **domain** pattern by name. App patterns shadow library
143
+ * patterns with the same name — useful in principle but not a documented
144
+ * feature.
145
+ *
146
+ * Orchestration patterns live in a disjoint store; use
147
+ * `getOrchestrationPattern()` to look those up. The two surfaces are
148
+ * intentionally separate (ADR-032 Decision 8) so callers don't have to
149
+ * narrow the result on every callsite.
150
+ */
151
+ export function getPattern(name: string): PatternDefinition | undefined {
152
+ return APP_PATTERNS.get(name) ?? LIBRARY_PATTERNS.get(name);
153
+ }
154
+
155
+ /**
156
+ * Return every registered domain pattern name (library + app), sorted for
157
+ * deterministic output. The two-process determinism test relies on this
158
+ * ordering being stable across processes. Orchestration names are NOT
159
+ * included — see `getOrchestrationPatternNames()`.
160
+ */
161
+ export function getAllPatternNames(): string[] {
162
+ const set = new Set<string>([
163
+ ...LIBRARY_PATTERNS.keys(),
164
+ ...APP_PATTERNS.keys(),
165
+ ]);
166
+ return [...set].sort();
167
+ }
168
+
169
+ /** Library-only view — mainly for debugging and tests. */
170
+ export function getLibraryPatternNames(): string[] {
171
+ return [...LIBRARY_PATTERNS.keys()].sort();
172
+ }
173
+
174
+ /** App-only view — mainly for debugging and tests. */
175
+ export function getAppPatternNames(): string[] {
176
+ return [...APP_PATTERNS.keys()].sort();
177
+ }
178
+
179
+ // ============================================================================
180
+ // Orchestration accessors (ADR-032)
181
+ // ============================================================================
182
+
183
+ /** Resolve an orchestration pattern by name. */
184
+ export function getOrchestrationPattern(
185
+ name: string,
186
+ ): OrchestrationPatternDefinition | undefined {
187
+ return ORCHESTRATION_APP_PATTERNS.get(name);
188
+ }
189
+
190
+ /** Sorted list of orchestration pattern names. */
191
+ export function getOrchestrationPatternNames(): string[] {
192
+ return [...ORCHESTRATION_APP_PATTERNS.keys()].sort();
193
+ }
194
+
195
+ /**
196
+ * Every registered orchestration pattern, sorted by name. The
197
+ * project-level validator iterates this list in one place so issue
198
+ * ordering is stable across processes.
199
+ */
200
+ export function getAllOrchestrationPatterns(): OrchestrationPatternDefinition[] {
201
+ return getOrchestrationPatternNames().map(
202
+ (n) => ORCHESTRATION_APP_PATTERNS.get(n)!,
203
+ );
204
+ }
205
+
206
+ // ============================================================================
207
+ // App pattern discovery
208
+ // ============================================================================
209
+
210
+ export interface LoadAppPatternsResult {
211
+ /** Pattern names that were successfully registered, sorted */
212
+ loaded: string[];
213
+ /** One human-readable error per failed file import */
214
+ errors: string[];
215
+ }
216
+
217
+ /**
218
+ * Expand every glob in `manifestPaths` relative to `cwd`, dynamic-import
219
+ * each matching file, and register every exported value that passes
220
+ * `isPatternDefinition()`. Exports whose name ends in `Pattern` and
221
+ * pass the shape check are registered; other exports are ignored so
222
+ * that files can export helper values alongside their pattern.
223
+ *
224
+ * Import failures are non-fatal — the error is collected and returned
225
+ * so the CLI can surface it without breaking generation of unrelated
226
+ * entities. A pattern that fails the "at-least-one-contribution" check
227
+ * surfaces here as an error too.
228
+ *
229
+ * Idempotent: calling twice with the same arguments leaves `APP_PATTERNS`
230
+ * in the same state as calling once.
231
+ */
232
+ export async function loadAppPatterns(
233
+ manifestPaths: string[],
234
+ cwd: string,
235
+ ): Promise<LoadAppPatternsResult> {
236
+ const loaded = new Set<string>();
237
+ const errors: string[] = [];
238
+
239
+ // Collect + dedupe absolute file paths across every glob pattern so
240
+ // a file matched by two globs is imported once.
241
+ const files = new Set<string>();
242
+ for (const raw of manifestPaths) {
243
+ try {
244
+ const expanded = await glob(raw, { cwd, absolute: true, nodir: true });
245
+ for (const filePath of expanded) {
246
+ files.add(filePath);
247
+ }
248
+ } catch (err) {
249
+ errors.push(
250
+ `Failed to expand pattern glob '${raw}': ${stringifyError(err)}`,
251
+ );
252
+ }
253
+ }
254
+
255
+ // Sort so dynamic-import order is deterministic across processes —
256
+ // the Hygen subprocess relies on this to produce the same registry
257
+ // as the CLI.
258
+ const sortedFiles = [...files].sort();
259
+
260
+ for (const filePath of sortedFiles) {
261
+ try {
262
+ // `pathToFileURL` is required for absolute-path dynamic imports on
263
+ // Windows and makes the behavior identical on macOS/Linux.
264
+ const mod = (await import(pathToFileURL(filePath).href)) as Record<
265
+ string,
266
+ unknown
267
+ >;
268
+ for (const [key, val] of Object.entries(mod)) {
269
+ if (!key.endsWith('Pattern')) continue;
270
+ if (!isPatternDefinition(val)) continue;
271
+
272
+ // Route on `kind`. Domain (default) and orchestration land in
273
+ // disjoint maps; same-name collisions within either map are
274
+ // load-time errors (silent overwrite was wrong by CLAUDE.md
275
+ // "architectural correctness" — see ADR-032 §Composition rules
276
+ // row 1).
277
+ if (isOrchestrationPattern(val as unknown as AnyPatternDefinition)) {
278
+ const orch = val as unknown as OrchestrationPatternDefinition;
279
+ try {
280
+ assertOrchestrationContribution(orch);
281
+ } catch (assertErr) {
282
+ errors.push(
283
+ `Orchestration pattern '${orch.name}' in ${relPath(filePath, cwd)} is invalid: ${stringifyError(assertErr)}`,
284
+ );
285
+ continue;
286
+ }
287
+ const existingOrch = ORCHESTRATION_APP_PATTERNS.get(orch.name);
288
+ if (existingOrch && existingOrch !== orch) {
289
+ errors.push(
290
+ `Orchestration pattern '${orch.name}' in ${relPath(filePath, cwd)} duplicates a previously loaded orchestration pattern. Pattern names must be unique.`,
291
+ );
292
+ continue;
293
+ }
294
+ ORCHESTRATION_APP_PATTERNS.set(orch.name, orch);
295
+ loaded.add(orch.name);
296
+ } else {
297
+ try {
298
+ assertHasContribution(val);
299
+ } catch (assertErr) {
300
+ errors.push(
301
+ `Pattern '${val.name}' in ${relPath(filePath, cwd)} is invalid: ${stringifyError(assertErr)}`,
302
+ );
303
+ continue;
304
+ }
305
+ const existingDom = APP_PATTERNS.get(val.name);
306
+ if (existingDom && existingDom !== val) {
307
+ errors.push(
308
+ `Pattern '${val.name}' in ${relPath(filePath, cwd)} duplicates a previously loaded app pattern. Pattern names must be unique.`,
309
+ );
310
+ continue;
311
+ }
312
+ APP_PATTERNS.set(val.name, val);
313
+ loaded.add(val.name);
314
+ }
315
+ }
316
+ } catch (err) {
317
+ errors.push(
318
+ `Failed to load pattern file '${relPath(filePath, cwd)}': ${stringifyError(err)}`,
319
+ );
320
+ }
321
+ }
322
+
323
+ return {
324
+ loaded: [...loaded].sort(),
325
+ errors,
326
+ };
327
+ }
328
+
329
+ // ============================================================================
330
+ // Test-only reset
331
+ // ============================================================================
332
+
333
+ /**
334
+ * Clear every registered app pattern and, optionally, library patterns too.
335
+ *
336
+ * Intended for unit tests that build isolated scenarios on top of a clean
337
+ * registry. Not exported from the barrel — tests import it directly from
338
+ * `./registry.js`.
339
+ */
340
+ export function _resetRegistryForTests(
341
+ opts: { includeLibrary?: boolean } = {},
342
+ ): void {
343
+ APP_PATTERNS.clear();
344
+ ORCHESTRATION_APP_PATTERNS.clear();
345
+ if (opts.includeLibrary) {
346
+ LIBRARY_PATTERNS.clear();
347
+ }
348
+ }
349
+
350
+ // ============================================================================
351
+ // Helpers
352
+ // ============================================================================
353
+
354
+ function stringifyError(err: unknown): string {
355
+ if (err instanceof Error) return err.message;
356
+ return String(err);
357
+ }
358
+
359
+ function relPath(abs: string, cwd: string): string {
360
+ try {
361
+ return path.relative(cwd, abs) || abs;
362
+ } catch {
363
+ return abs;
364
+ }
365
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * naming-config.schema.mjs
3
+ *
4
+ * Pure-JS mirror of naming-config.schema.ts for use in hygen (Node.js) context.
5
+ * No Zod, no TypeScript — only plain-object constants and functions.
6
+ *
7
+ * Keep in sync with naming-config.schema.ts.
8
+ */
9
+
10
+ // ============================================================================
11
+ // Default Configuration
12
+ // ============================================================================
13
+
14
+ export const DEFAULT_BACKEND_NAMING = {
15
+ fileCase: 'kebab-case',
16
+ suffixStyle: 'dotted',
17
+ entityInclusion: 'flat-only',
18
+ terminology: {
19
+ command: 'command',
20
+ query: 'query',
21
+ },
22
+ };
23
+
24
+ // ============================================================================
25
+ // Validation (plain JS — no Zod)
26
+ // ============================================================================
27
+
28
+ const VALID_FILE_CASES = ['kebab-case', 'camelCase', 'snake_case', 'PascalCase'];
29
+ const VALID_SUFFIX_STYLES = ['dotted', 'suffixed', 'worded'];
30
+ const VALID_ENTITY_INCLUSIONS = ['always', 'never', 'flat-only'];
31
+ const VALID_COMMAND_TERMS = ['command', 'use-case'];
32
+ const VALID_QUERY_TERMS = ['query', 'use-case'];
33
+
34
+ /**
35
+ * Validate and parse a backend naming config object.
36
+ * Applies defaults for missing fields.
37
+ * Throws on invalid values.
38
+ */
39
+ export const BackendNamingConfigSchema = {
40
+ parse(data) {
41
+ const fc = data?.fileCase ?? DEFAULT_BACKEND_NAMING.fileCase;
42
+ const ss = data?.suffixStyle ?? DEFAULT_BACKEND_NAMING.suffixStyle;
43
+ const ei = data?.entityInclusion ?? DEFAULT_BACKEND_NAMING.entityInclusion;
44
+ const tc = data?.terminology?.command ?? DEFAULT_BACKEND_NAMING.terminology.command;
45
+ const tq = data?.terminology?.query ?? DEFAULT_BACKEND_NAMING.terminology.query;
46
+
47
+ if (!VALID_FILE_CASES.includes(fc)) {
48
+ throw new Error(`Invalid fileCase: ${fc}. Must be one of: ${VALID_FILE_CASES.join(', ')}`);
49
+ }
50
+ if (!VALID_SUFFIX_STYLES.includes(ss)) {
51
+ throw new Error(`Invalid suffixStyle: ${ss}. Must be one of: ${VALID_SUFFIX_STYLES.join(', ')}`);
52
+ }
53
+ if (!VALID_ENTITY_INCLUSIONS.includes(ei)) {
54
+ throw new Error(`Invalid entityInclusion: ${ei}. Must be one of: ${VALID_ENTITY_INCLUSIONS.join(', ')}`);
55
+ }
56
+ if (!VALID_COMMAND_TERMS.includes(tc)) {
57
+ throw new Error(`Invalid terminology.command: ${tc}. Must be one of: ${VALID_COMMAND_TERMS.join(', ')}`);
58
+ }
59
+ if (!VALID_QUERY_TERMS.includes(tq)) {
60
+ throw new Error(`Invalid terminology.query: ${tq}. Must be one of: ${VALID_QUERY_TERMS.join(', ')}`);
61
+ }
62
+
63
+ return {
64
+ fileCase: fc,
65
+ suffixStyle: ss,
66
+ entityInclusion: ei,
67
+ terminology: { command: tc, query: tq },
68
+ layers: data?.layers ?? undefined,
69
+ };
70
+ },
71
+ };
72
+
73
+ // ============================================================================
74
+ // Resolution Helper
75
+ // ============================================================================
76
+
77
+ /**
78
+ * Resolve effective naming config for a specific layer.
79
+ * Merges layer-specific overrides with global defaults.
80
+ */
81
+ export function resolveLayerNaming(config, layer) {
82
+ const layerConfig = config.layers?.[layer];
83
+ return {
84
+ fileCase: layerConfig?.fileCase ?? config.fileCase,
85
+ suffixStyle: layerConfig?.suffixStyle ?? config.suffixStyle,
86
+ entityInclusion: layerConfig?.entityInclusion ?? config.entityInclusion,
87
+ terminology: {
88
+ command: layerConfig?.terminology?.command ?? config.terminology.command,
89
+ query: layerConfig?.terminology?.query ?? config.terminology.query,
90
+ },
91
+ };
92
+ }
93
+
94
+ // ============================================================================
95
+ // File Type Suffixes
96
+ // ============================================================================
97
+
98
+ export const FILE_TYPE_SUFFIXES = {
99
+ entity: { dotted: '.entity', suffixed: 'Entity', word: 'entity' },
100
+ repositoryInterface: {
101
+ dotted: '.repository.interface',
102
+ suffixed: 'RepositoryInterface',
103
+ word: 'repository-interface',
104
+ },
105
+ repository: { dotted: '.repository', suffixed: 'Repository', word: 'repository' },
106
+ command: { dotted: '.command', suffixed: 'Command', word: 'command' },
107
+ query: { dotted: '.query', suffixed: 'Query', word: 'query' },
108
+ dto: { dotted: '.dto', suffixed: 'Dto', word: 'dto' },
109
+ controller: { dotted: '.controller', suffixed: 'Controller', word: 'controller' },
110
+ module: { dotted: '.module', suffixed: 'Module', word: 'module' },
111
+ schema: { dotted: '.schema', suffixed: 'Schema', word: 'schema' },
112
+ };
113
+
114
+ export default {
115
+ DEFAULT_BACKEND_NAMING,
116
+ BackendNamingConfigSchema,
117
+ resolveLayerNaming,
118
+ FILE_TYPE_SUFFIXES,
119
+ };