@kernlang/core 3.1.2 → 3.1.3

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.
@@ -7,6 +7,14 @@
7
7
  * Machine nodes are KERN's killer feature: 12 lines of KERN → 140+ lines of TS.
8
8
  */
9
9
  import type { IRNode } from './types.js';
10
+ /** Validate and emit a safe identifier for generated code. Throws on invalid. */
11
+ export declare function emitIdentifier(value: string | undefined, fallback: string, node?: IRNode): string;
12
+ /** Escape a string for safe interpolation into a single-quoted JS string literal. */
13
+ export declare function emitStringLiteral(value: string): string;
14
+ /** Validate and emit a safe filesystem path for generated code. */
15
+ export declare function emitPath(value: string, node?: IRNode): string;
16
+ /** Escape a value for interpolation into a template literal in generated code. */
17
+ export declare function emitTemplateSafe(value: string): string;
10
18
  /** Register an evolved generator (called at startup). */
11
19
  export declare function registerEvolvedGenerator(keyword: string, fn: (node: IRNode) => string[]): void;
12
20
  /** Register a target-specific evolved generator (called at startup). */
@@ -73,7 +81,7 @@ export declare function generateResolve(node: IRNode): string[];
73
81
  export declare function generateExpect(node: IRNode): string[];
74
82
  export declare function generateRecover(node: IRNode): string[];
75
83
  export declare function generatePattern(node: IRNode): string[];
76
- export declare function generateApply(node: IRNode): string[];
84
+ export declare function generateApply(node: IRNode, _depth?: number): string[];
77
85
  export declare function generateConditional(node: IRNode): string[];
78
86
  export declare function generateSelect(node: IRNode): string[];
79
87
  export declare function generateModel(node: IRNode): string[];
@@ -8,6 +8,50 @@
8
8
  */
9
9
  import { isTemplateNode, expandTemplateNode } from './template-engine.js';
10
10
  import { KernCodegenError } from './errors.js';
11
+ // ── Safe Emitters (prompt-injection immunity) ────────────────────────────
12
+ // Every prop value interpolated into generated code MUST go through these.
13
+ // Raw string splicing is the root cause of codegen injection (audit 2026-03-25).
14
+ // Matches valid JS/TS identifiers — KERN hyphens are converted to camelCase by the parser.
15
+ // Allows $ for React patterns (e.g., $state). Does NOT allow hyphens since
16
+ // generated TypeScript rejects them (e.g., `interface My-User` is invalid TS).
17
+ const SAFE_IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
18
+ const SAFE_PATH_RE = /^[A-Za-z0-9/_.\-~]+$/;
19
+ /** Validate and emit a safe identifier for generated code. Throws on invalid. */
20
+ export function emitIdentifier(value, fallback, node) {
21
+ const v = value || fallback;
22
+ if (!SAFE_IDENT_RE.test(v)) {
23
+ throw new KernCodegenError(`Invalid identifier: '${v.slice(0, 50)}' — must match KERN identifier grammar [A-Za-z_$][A-Za-z0-9_$-]*`, node);
24
+ }
25
+ return v;
26
+ }
27
+ /** Escape a string for safe interpolation into a single-quoted JS string literal. */
28
+ export function emitStringLiteral(value) {
29
+ const escaped = value
30
+ .replace(/\\/g, '\\\\')
31
+ .replace(/'/g, "\\'")
32
+ .replace(/`/g, '\\`')
33
+ .replace(/\$/g, '\\$')
34
+ .replace(/\n/g, '\\n')
35
+ .replace(/\r/g, '\\r');
36
+ return `'${escaped}'`;
37
+ }
38
+ /** Validate and emit a safe filesystem path for generated code. */
39
+ export function emitPath(value, node) {
40
+ if (!SAFE_PATH_RE.test(value)) {
41
+ throw new KernCodegenError(`Invalid path: '${value.slice(0, 80)}' — contains unsafe characters`, node);
42
+ }
43
+ if (value.includes('..')) {
44
+ throw new KernCodegenError(`Invalid path: '${value.slice(0, 80)}' — path traversal (..) not allowed`, node);
45
+ }
46
+ return emitStringLiteral(value);
47
+ }
48
+ /** Escape a value for interpolation into a template literal in generated code. */
49
+ export function emitTemplateSafe(value) {
50
+ return value
51
+ .replace(/\\/g, '\\\\')
52
+ .replace(/`/g, '\\`')
53
+ .replace(/\$\{/g, '\\${');
54
+ }
11
55
  // ── Evolved Generators (v4) ─────────────────────────────────────────────
12
56
  // Populated at startup by evolved-node-loader. Checked in generateCoreNode
13
57
  // before the default case, allowing graduated nodes to produce output.
@@ -97,10 +141,11 @@ export function exportPrefix(node) {
97
141
  // type name=PlanState values="draft|approved|running|paused|completed|failed|cancelled"
98
142
  // → export type PlanState = 'draft' | 'approved' | 'running' | ...;
99
143
  export function generateType(node) {
100
- const { name, values, alias } = p(node);
144
+ const { name: rawName, values, alias } = p(node);
145
+ const name = emitIdentifier(rawName, 'UnknownType', node);
101
146
  const exp = exportPrefix(node);
102
147
  if (values) {
103
- const members = values.split('|').map(v => `'${v.trim()}'`).join(' | ');
148
+ const members = values.split('|').map(v => `'${emitTemplateSafe(v.trim())}'`).join(' | ');
104
149
  return [`${exp}type ${name} = ${members};`];
105
150
  }
106
151
  if (alias) {
@@ -116,15 +161,16 @@ export function generateType(node) {
116
161
  // field name=engineId type=string optional=true
117
162
  export function generateInterface(node) {
118
163
  const props = p(node);
119
- const name = props.name;
164
+ const name = emitIdentifier(props.name, 'UnknownInterface', node);
120
165
  const ext = props.extends ? ` extends ${props.extends}` : '';
121
166
  const exp = exportPrefix(node);
122
167
  const lines = [];
123
168
  lines.push(`${exp}interface ${name}${ext} {`);
124
169
  for (const field of kids(node, 'field')) {
125
170
  const fp = p(field);
171
+ const fieldName = emitIdentifier(fp.name, 'field', field);
126
172
  const opt = fp.optional === 'true' || fp.optional === true ? '?' : '';
127
- lines.push(` ${fp.name}${opt}: ${fp.type};`);
173
+ lines.push(` ${fieldName}${opt}: ${fp.type};`);
128
174
  }
129
175
  lines.push('}');
130
176
  return lines;
@@ -141,8 +187,8 @@ export function generateInterface(node) {
141
187
  // | { type: 'code'; language: string; code: string };
142
188
  export function generateUnion(node) {
143
189
  const props = p(node);
144
- const name = props.name;
145
- const discriminant = props.discriminant || 'type';
190
+ const name = emitIdentifier(props.name, 'UnknownUnion', node);
191
+ const discriminant = emitIdentifier(props.discriminant, 'type', node);
146
192
  const exp = exportPrefix(node);
147
193
  const variants = kids(node, 'variant');
148
194
  if (variants.length === 0) {
@@ -151,9 +197,9 @@ export function generateUnion(node) {
151
197
  const lines = [`${exp}type ${name} =`];
152
198
  for (let i = 0; i < variants.length; i++) {
153
199
  const vp = p(variants[i]);
154
- const vname = vp.name;
200
+ const vname = emitIdentifier(vp.name, 'variant', variants[i]);
155
201
  const fields = kids(variants[i], 'field');
156
- const fieldParts = [`${discriminant}: '${vname}'`];
202
+ const fieldParts = [`${discriminant}: '${emitTemplateSafe(vname)}'`];
157
203
  for (const field of fields) {
158
204
  const fp = p(field);
159
205
  const opt = fp.optional === 'true' || fp.optional === true ? '?' : '';
@@ -180,7 +226,7 @@ export function generateUnion(node) {
180
226
  // → export const tracker = new TokenTracker();
181
227
  export function generateService(node) {
182
228
  const props = p(node);
183
- const name = props.name;
229
+ const name = emitIdentifier(props.name, 'UnknownService', node);
184
230
  const impl = props.implements;
185
231
  const exp = exportPrefix(node);
186
232
  const lines = [];
@@ -214,7 +260,7 @@ export function generateService(node) {
214
260
  // Methods
215
261
  for (const method of kids(node, 'method')) {
216
262
  const mp = p(method);
217
- const mname = mp.name;
263
+ const mname = emitIdentifier(mp.name, 'method', method);
218
264
  const mparams = mp.params ? parseParamList(mp.params) : '';
219
265
  const isAsync = mp.async === 'true' || mp.async === true;
220
266
  const isStream = mp.stream === 'true' || mp.stream === true;
@@ -241,8 +287,8 @@ export function generateService(node) {
241
287
  // Singleton instances
242
288
  for (const singleton of kids(node, 'singleton')) {
243
289
  const sp = p(singleton);
244
- const sname = sp.name;
245
- const stype = sp.type || name;
290
+ const sname = emitIdentifier(sp.name, 'instance', singleton);
291
+ const stype = emitIdentifier(sp.type, name, singleton);
246
292
  lines.push('');
247
293
  lines.push(`${exp}const ${sname} = new ${stype}();`);
248
294
  }
@@ -255,7 +301,7 @@ export function generateService(node) {
255
301
  // >>>
256
302
  export function generateFunction(node) {
257
303
  const props = p(node);
258
- const name = props.name;
304
+ const name = emitIdentifier(props.name, 'unknownFn', node);
259
305
  const params = props.params || '';
260
306
  const returns = props.returns;
261
307
  const isAsync = props.async === 'true' || props.async === true;
@@ -290,7 +336,7 @@ export function generateFunction(node) {
290
336
  lines.push(`${exp}${asyncKw}function ${name}(${paramList})${retClause} {`);
291
337
  // Signal → AbortController setup
292
338
  if (hasSignal) {
293
- const signalName = p(signalNode).name || 'abort';
339
+ const signalName = emitIdentifier(p(signalNode).name, 'abort', signalNode);
294
340
  lines.push(` const ${signalName} = new AbortController();`);
295
341
  }
296
342
  // Wrap body in try/finally if cleanup exists
@@ -327,8 +373,8 @@ export function generateFunction(node) {
327
373
  // message "Invalid plan state: expected ${expected}, got ${actual}"
328
374
  export function generateError(node) {
329
375
  const props = p(node);
330
- const name = props.name;
331
- const ext = props.extends || 'Error';
376
+ const name = emitIdentifier(props.name, 'UnknownError', node);
377
+ const ext = emitIdentifier(props.extends, 'Error', node);
332
378
  const message = props.message;
333
379
  const exp = exportPrefix(node);
334
380
  const fields = kids(node, 'field');
@@ -412,18 +458,18 @@ export function generateError(node) {
412
458
  // - approvePlan(), startPlan(), cancelPlan(), failPlan() functions
413
459
  export function generateMachine(node) {
414
460
  const props = p(node);
415
- const name = props.name;
461
+ const name = emitIdentifier(props.name, 'UnknownMachine', node);
416
462
  const exp = exportPrefix(node);
417
463
  const lines = [];
418
464
  // Collect states
419
465
  const states = kids(node, 'state');
420
466
  const stateNames = states.map(s => {
421
467
  const sp = p(s);
422
- return (sp.name || sp.value);
468
+ return emitIdentifier((sp.name || sp.value), 'state', s);
423
469
  });
424
470
  // State type
425
471
  const stateType = `${name}State`;
426
- lines.push(`${exp}type ${stateType} = ${stateNames.map(s => `'${s}'`).join(' | ')};`);
472
+ lines.push(`${exp}type ${stateType} = ${stateNames.map(s => `'${emitTemplateSafe(s)}'`).join(' | ')};`);
427
473
  lines.push('');
428
474
  // Error class
429
475
  const errorName = `${name}StateError`;
@@ -442,7 +488,7 @@ export function generateMachine(node) {
442
488
  const transitions = kids(node, 'transition');
443
489
  for (const t of transitions) {
444
490
  const tp = p(t);
445
- const tname = tp.name;
491
+ const tname = emitIdentifier(tp.name, 'transition', t);
446
492
  const from = tp.from;
447
493
  const to = tp.to;
448
494
  const fromStates = from.split('|').map(s => s.trim());
@@ -480,7 +526,7 @@ export function generateMachine(node) {
480
526
  // Called by transpiler-ink.ts when target=ink.
481
527
  export function generateMachineReducer(node) {
482
528
  const props = p(node);
483
- const name = props.name;
529
+ const name = emitIdentifier(props.name, 'UnknownMachine', node);
484
530
  const exp = exportPrefix(node);
485
531
  const lines = [];
486
532
  // First emit the standard machine output
@@ -496,7 +542,7 @@ export function generateMachineReducer(node) {
496
542
  const transitions = kids(node, 'transition');
497
543
  const stateType = `${name}State`;
498
544
  // Action type union
499
- const actionNames = transitions.map(t => p(t).name);
545
+ const actionNames = transitions.map(t => emitIdentifier(p(t).name, 'action', t));
500
546
  lines.push(`${exp}type ${name}Action = ${actionNames.map(a => `'${a}'`).join(' | ')};`);
501
547
  lines.push('');
502
548
  // Reducer function
@@ -505,9 +551,9 @@ export function generateMachineReducer(node) {
505
551
  lines.push(` switch (action) {`);
506
552
  for (const t of transitions) {
507
553
  const tp = p(t);
508
- const tname = tp.name;
554
+ const tname = emitIdentifier(tp.name, 'action', t);
509
555
  const fnName = `${tname}${name}`;
510
- lines.push(` case '${tname}': return ${fnName}(entity).state;`);
556
+ lines.push(` case '${emitTemplateSafe(tname)}': return ${fnName}(entity).state;`);
511
557
  }
512
558
  lines.push(` default: return state;`);
513
559
  lines.push(` }`);
@@ -527,7 +573,7 @@ export function generateMachineReducer(node) {
527
573
  // field name=approvalLevel type=ApprovalLevel default="plan"
528
574
  export function generateConfig(node) {
529
575
  const props = p(node);
530
- const name = props.name;
576
+ const name = emitIdentifier(props.name, 'Config', node);
531
577
  const exp = exportPrefix(node);
532
578
  const fields = kids(node, 'field');
533
579
  const lines = [];
@@ -535,8 +581,9 @@ export function generateConfig(node) {
535
581
  lines.push(`${exp}interface ${name} {`);
536
582
  for (const field of fields) {
537
583
  const fp = p(field);
584
+ const fieldName = emitIdentifier(fp.name, 'field', field);
538
585
  const opt = fp.default !== undefined ? '?' : '';
539
- lines.push(` ${fp.name}${opt}: ${fp.type};`);
586
+ lines.push(` ${fieldName}${opt}: ${fp.type};`);
540
587
  }
541
588
  lines.push('}');
542
589
  lines.push('');
@@ -544,6 +591,7 @@ export function generateConfig(node) {
544
591
  lines.push(`${exp}const DEFAULT_${name.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()}: Required<${name}> = {`);
545
592
  for (const field of fields) {
546
593
  const fp = p(field);
594
+ const fieldName = emitIdentifier(fp.name, 'field', field);
547
595
  const ftype = fp.type;
548
596
  let def = fp.default;
549
597
  if (def === undefined) {
@@ -557,9 +605,9 @@ export function generateConfig(node) {
557
605
  def = "''";
558
606
  }
559
607
  else if (ftype === 'string' || (!['number', 'boolean'].includes(ftype) && !ftype.endsWith('[]') && !def.startsWith("'") && !def.startsWith('"'))) {
560
- def = `'${def}'`;
608
+ def = emitStringLiteral(def);
561
609
  }
562
- lines.push(` ${fp.name}: ${def},`);
610
+ lines.push(` ${fieldName}: ${def},`);
563
611
  }
564
612
  lines.push('};');
565
613
  return lines;
@@ -569,16 +617,17 @@ export function generateConfig(node) {
569
617
  // model Plan
570
618
  export function generateStore(node) {
571
619
  const props = p(node);
572
- const name = props.name;
573
- const storePath = props.path || '~/.data';
574
- const key = props.key || 'id';
575
- const model = props.model || 'unknown';
620
+ const name = emitIdentifier(props.name, 'Store', node);
621
+ const rawPath = props.path || '~/.data';
622
+ const key = emitIdentifier(props.key, 'id', node);
623
+ const model = emitIdentifier(props.model, 'unknown', node);
576
624
  const exp = exportPrefix(node);
577
625
  const lines = [];
578
626
  const dirConst = `${name.toUpperCase()}_DIR`;
579
- const resolvedPath = storePath.startsWith('~/')
580
- ? `join(homedir(), '${storePath.slice(2)}')`
581
- : `'${storePath}'`;
627
+ // Validate path before interpolation — blocks injection + traversal via storePath
628
+ const resolvedPath = rawPath.startsWith('~/')
629
+ ? `join(homedir(), ${emitPath(rawPath.slice(2), node)})`
630
+ : emitPath(rawPath, node);
582
631
  lines.push(`import { readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';`);
583
632
  lines.push(`import { join, resolve } from 'node:path';`);
584
633
  lines.push(`import { homedir } from 'node:os';`);
@@ -591,6 +640,7 @@ export function generateStore(node) {
591
640
  lines.push('');
592
641
  lines.push(`function safe${name}Path(id: string): string {`);
593
642
  lines.push(` const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, '');`);
643
+ lines.push(` if (!sanitized) throw new Error(\`Invalid ID: \${id}\`);`);
594
644
  lines.push(` const full = resolve(${dirConst}, \`\${sanitized}.json\`);`);
595
645
  lines.push(` if (!full.startsWith(resolve(${dirConst}))) throw new Error(\`Invalid ID: \${id}\`);`);
596
646
  lines.push(` return full;`);
@@ -603,17 +653,18 @@ export function generateStore(node) {
603
653
  lines.push('');
604
654
  lines.push(`${exp}function load${name}(id: string): ${model} | null {`);
605
655
  lines.push(` try { return JSON.parse(readFileSync(safe${name}Path(id), 'utf-8')) as ${model}; }`);
606
- lines.push(` catch { return null; }`);
656
+ lines.push(` catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; throw e; }`);
607
657
  lines.push('}');
608
658
  lines.push('');
609
659
  lines.push(`${exp}function list${name}s(limit = 20): ${model}[] {`);
610
660
  lines.push(` ensure${name}Dir();`);
611
- lines.push(` try {`);
612
- lines.push(` return readdirSync(${dirConst}).filter(f => f.endsWith('.json'))`);
613
- lines.push(` .map(f => JSON.parse(readFileSync(join(${dirConst}, f), 'utf-8')) as ${model})`);
614
- lines.push(` .sort((a: any, b: any) => (b.updatedAt || '').localeCompare(a.updatedAt || ''))`);
615
- lines.push(` .slice(0, limit);`);
616
- lines.push(` } catch { return []; }`);
661
+ lines.push(` const files = readdirSync(${dirConst}).filter(f => f.endsWith('.json'));`);
662
+ lines.push(` const items: ${model}[] = [];`);
663
+ lines.push(` for (const f of files) {`);
664
+ lines.push(` try { items.push(JSON.parse(readFileSync(join(${dirConst}, f), 'utf-8')) as ${model}); }`);
665
+ lines.push(` catch { /* skip corrupt files */ }`);
666
+ lines.push(` }`);
667
+ lines.push(` return items.sort((a: any, b: any) => (b.updatedAt || '').localeCompare(a.updatedAt || '')).slice(0, limit);`);
617
668
  lines.push('}');
618
669
  lines.push('');
619
670
  lines.push(`${exp}function delete${name}(id: string): boolean {`);
@@ -631,7 +682,7 @@ export function generateStore(node) {
631
682
  // >>>
632
683
  export function generateTest(node) {
633
684
  const props = p(node);
634
- const name = props.name;
685
+ const name = emitTemplateSafe(props.name || 'UnknownTest');
635
686
  const lines = [];
636
687
  lines.push(`import { describe, it, expect } from 'vitest';`);
637
688
  lines.push('');
@@ -644,10 +695,10 @@ export function generateTest(node) {
644
695
  }
645
696
  lines.push(`describe('${name}', () => {`);
646
697
  for (const desc of kids(node, 'describe')) {
647
- const dname = p(desc).name;
698
+ const dname = emitTemplateSafe(p(desc).name || 'describe');
648
699
  lines.push(` describe('${dname}', () => {`);
649
700
  for (const test of kids(desc, 'it')) {
650
- const tname = p(test).name;
701
+ const tname = emitTemplateSafe(p(test).name || 'test');
651
702
  const code = handlerCode(test);
652
703
  lines.push(` it('${tname}', () => {`);
653
704
  if (code) {
@@ -660,7 +711,7 @@ export function generateTest(node) {
660
711
  }
661
712
  // Top-level it blocks
662
713
  for (const test of kids(node, 'it')) {
663
- const tname = p(test).name;
714
+ const tname = emitTemplateSafe(p(test).name || 'test');
664
715
  const code = handlerCode(test);
665
716
  lines.push(` it('${tname}', () => {`);
666
717
  if (code) {
@@ -679,12 +730,12 @@ export function generateTest(node) {
679
730
  // type name="winner:determined" data="{ winner: string, bestScore: number }"
680
731
  export function generateEvent(node) {
681
732
  const props = p(node);
682
- const name = props.name;
733
+ const name = emitIdentifier(props.name, 'UnknownEvent', node);
683
734
  const exp = exportPrefix(node);
684
735
  const types = kids(node, 'type');
685
736
  const lines = [];
686
737
  // Event type union
687
- lines.push(`${exp}type ${name}Type = ${types.map(t => `'${(p(t).name || p(t).value)}'`).join(' | ')};`);
738
+ lines.push(`${exp}type ${name}Type = ${types.map(t => `'${emitTemplateSafe((p(t).name || p(t).value))}'`).join(' | ')};`);
688
739
  lines.push('');
689
740
  // Event interface
690
741
  lines.push(`${exp}interface ${name} {`);
@@ -697,7 +748,7 @@ export function generateEvent(node) {
697
748
  lines.push(`${exp}interface ${name}Map {`);
698
749
  for (const t of types) {
699
750
  const tp = p(t);
700
- const tname = (tp.name || tp.value);
751
+ const tname = emitTemplateSafe((tp.name || tp.value));
701
752
  const data = tp.data || 'Record<string, unknown>';
702
753
  lines.push(` '${tname}': ${data};`);
703
754
  }
@@ -853,7 +904,7 @@ export function generateWebSocket(node) {
853
904
  // export from="./plan.js" names="createPlan,advanceStep"
854
905
  export function generateModule(node) {
855
906
  const props = p(node);
856
- const name = props.name;
907
+ const name = emitTemplateSafe(props.name || 'unknown');
857
908
  const lines = [];
858
909
  lines.push(`// ── Module: ${name} ──`);
859
910
  lines.push('');
@@ -949,7 +1000,7 @@ export function generateImport(node) {
949
1000
  // → export const DEFAULT_WEIGHTS: ScoreWeights = { pass: 50 };
950
1001
  export function generateConst(node) {
951
1002
  const props = p(node);
952
- const name = props.name;
1003
+ const name = emitIdentifier(props.name, 'unknownConst', node);
953
1004
  const constType = props.type;
954
1005
  const value = props.value;
955
1006
  const exp = exportPrefix(node);
@@ -1090,7 +1141,7 @@ export function generateDerive(node) {
1090
1141
  const conf = p(node).confidence;
1091
1142
  const todo = emitLowConfidenceTodo(node, conf);
1092
1143
  const props = p(node);
1093
- const name = props.name;
1144
+ const name = emitIdentifier(props.name, 'derived', node);
1094
1145
  const expr = props.expr;
1095
1146
  const constType = props.type;
1096
1147
  const exp = exportPrefix(node);
@@ -1105,7 +1156,7 @@ export function generateTransform(node) {
1105
1156
  const conf = p(node).confidence;
1106
1157
  const todo = emitLowConfidenceTodo(node, conf);
1107
1158
  const props = p(node);
1108
- const name = props.name;
1159
+ const name = emitIdentifier(props.name, 'transform', node);
1109
1160
  const target = props.target;
1110
1161
  const via = props.via;
1111
1162
  const constType = props.type;
@@ -1138,7 +1189,7 @@ export function generateAction(node) {
1138
1189
  const conf = p(node).confidence;
1139
1190
  const todo = emitLowConfidenceTodo(node, conf);
1140
1191
  const props = p(node);
1141
- const name = props.name;
1192
+ const name = emitIdentifier(props.name, 'action', node);
1142
1193
  const idempotent = props.idempotent === 'true' || props.idempotent === true;
1143
1194
  const reversible = props.reversible === 'true' || props.reversible === true;
1144
1195
  const params = props.params || '';
@@ -1257,7 +1308,7 @@ export function generateCollect(node) {
1257
1308
  const conf = p(node).confidence;
1258
1309
  const todo = emitLowConfidenceTodo(node, conf);
1259
1310
  const props = p(node);
1260
- const name = props.name;
1311
+ const name = emitIdentifier(props.name, 'collected', node);
1261
1312
  const from = props.from;
1262
1313
  const where = props.where;
1263
1314
  const limit = props.limit;
@@ -1314,7 +1365,7 @@ export function generateResolve(node) {
1314
1365
  const conf = p(node).confidence;
1315
1366
  const todo = emitLowConfidenceTodo(node, conf);
1316
1367
  const props = p(node);
1317
- const name = props.name;
1368
+ const name = emitIdentifier(props.name, 'resolver', node);
1318
1369
  const candidates = kids(node, 'candidate');
1319
1370
  const discriminator = firstChild(node, 'discriminator');
1320
1371
  if (!discriminator)
@@ -1328,7 +1379,7 @@ export function generateResolve(node) {
1328
1379
  lines.push(`const _${name}_candidates = [`);
1329
1380
  for (const c of candidates) {
1330
1381
  const cp = p(c);
1331
- const cname = cp.name;
1382
+ const cname = emitIdentifier(cp.name, 'candidate', c);
1332
1383
  const code = handlerCode(c);
1333
1384
  lines.push(` { name: '${cname}', fn: (signal: unknown) => { ${code.trim()} } },`);
1334
1385
  }
@@ -1392,7 +1443,7 @@ export function generateRecover(node) {
1392
1443
  const conf = p(node).confidence;
1393
1444
  const todo = emitLowConfidenceTodo(node, conf);
1394
1445
  const props = p(node);
1395
- const name = props.name;
1446
+ const name = emitIdentifier(props.name, 'recovery', node);
1396
1447
  const strategies = kids(node, 'strategy');
1397
1448
  const hasFallback = strategies.some(s => p(s).name === 'fallback');
1398
1449
  if (!hasFallback)
@@ -1402,7 +1453,7 @@ export function generateRecover(node) {
1402
1453
  lines.push(`async function ${name}WithRecovery<T>(fn: () => Promise<T>): Promise<T> {`);
1403
1454
  for (const strategy of strategies) {
1404
1455
  const sp = p(strategy);
1405
- const sname = sp.name;
1456
+ const sname = emitIdentifier(sp.name, 'strategy', strategy);
1406
1457
  const code = handlerCode(strategy);
1407
1458
  if (sname === 'retry') {
1408
1459
  const max = Number(sp.max) || 3;
@@ -1446,16 +1497,16 @@ export function generatePattern(node) {
1446
1497
  // pattern nodes are registered as templates — no direct output
1447
1498
  return [];
1448
1499
  }
1449
- export function generateApply(node) {
1500
+ export function generateApply(node, _depth = 0) {
1450
1501
  // apply nodes expand the referenced pattern
1451
1502
  const props = p(node);
1452
1503
  const patternName = props.pattern;
1453
1504
  if (!patternName)
1454
1505
  return [];
1455
- // Delegate to template expansion with the pattern name as node type
1506
+ // Delegate to template expansion propagate depth to prevent infinite recursion
1456
1507
  const syntheticNode = { ...node, type: patternName };
1457
1508
  if (isTemplateNode(patternName)) {
1458
- return expandTemplateNode(syntheticNode);
1509
+ return expandTemplateNode(syntheticNode, _depth + 1);
1459
1510
  }
1460
1511
  return [`// apply: pattern '${patternName}' not found`];
1461
1512
  }
@@ -1509,12 +1560,12 @@ export function generateSelect(node) {
1509
1560
  const lines = onChange ? [`{/* kern:use-client */}`] : [];
1510
1561
  lines.push(`<select ${attrs.join(' ')}>`);
1511
1562
  if (placeholder) {
1512
- lines.push(` <option value="" disabled>${placeholder}</option>`);
1563
+ lines.push(` <option value="" disabled>${emitTemplateSafe(placeholder)}</option>`);
1513
1564
  }
1514
1565
  for (const opt of kids(node, 'option')) {
1515
1566
  const op = p(opt);
1516
- const optValue = op.value || '';
1517
- const optLabel = op.label || optValue;
1567
+ const optValue = emitTemplateSafe(op.value || '');
1568
+ const optLabel = emitTemplateSafe(op.label || op.value || '');
1518
1569
  lines.push(` <option value="${optValue}">${optLabel}</option>`);
1519
1570
  }
1520
1571
  lines.push(`</select>`);
@@ -1527,7 +1578,7 @@ export function generateSelect(node) {
1527
1578
  // relation name=posts target=Post kind=one-to-many cascade=delete
1528
1579
  export function generateModel(node) {
1529
1580
  const props = p(node);
1530
- const name = props.name;
1581
+ const name = emitIdentifier(props.name, 'UnknownModel', node);
1531
1582
  const table = props.table;
1532
1583
  const exp = exportPrefix(node);
1533
1584
  const lines = [];
@@ -1535,14 +1586,14 @@ export function generateModel(node) {
1535
1586
  lines.push(`${exp}interface ${name} {`);
1536
1587
  for (const col of kids(node, 'column')) {
1537
1588
  const cp = p(col);
1538
- const colName = cp.name;
1589
+ const colName = emitIdentifier(cp.name, 'column', col);
1539
1590
  const colType = mapColumnType(cp.type);
1540
1591
  const opt = cp.optional === 'true' || cp.optional === true ? '?' : '';
1541
1592
  lines.push(` ${colName}${opt}: ${colType};`);
1542
1593
  }
1543
1594
  for (const rel of kids(node, 'relation')) {
1544
1595
  const rp = p(rel);
1545
- const relName = rp.name;
1596
+ const relName = emitIdentifier(rp.name, 'relation', rel);
1546
1597
  const target = rp.target;
1547
1598
  const kind = rp.kind || 'one-to-many';
1548
1599
  const relType = kind.includes('many') ? `${target}[]` : target;
@@ -1572,7 +1623,7 @@ function mapColumnType(kernType) {
1572
1623
  // handler <<<return this.findOne({ email });>>>
1573
1624
  export function generateRepository(node) {
1574
1625
  const props = p(node);
1575
- const name = props.name;
1626
+ const name = emitIdentifier(props.name, 'UnknownRepo', node);
1576
1627
  const model = props.model;
1577
1628
  const exp = exportPrefix(node);
1578
1629
  const lines = [];
@@ -1583,7 +1634,7 @@ export function generateRepository(node) {
1583
1634
  }
1584
1635
  for (const method of kids(node, 'method')) {
1585
1636
  const mp = p(method);
1586
- const mname = mp.name;
1637
+ const mname = emitIdentifier(mp.name, 'method', method);
1587
1638
  const mparams = mp.params ? parseParamList(mp.params) : '';
1588
1639
  const isAsync = mp.async === 'true' || mp.async === true;
1589
1640
  const asyncKw = isAsync ? 'async ' : '';
@@ -1608,7 +1659,7 @@ export function generateRepository(node) {
1608
1659
  // returns AuthService with=repo
1609
1660
  export function generateDependency(node) {
1610
1661
  const props = p(node);
1611
- const name = props.name;
1662
+ const name = emitIdentifier(props.name, 'unknownDep', node);
1612
1663
  const scope = props.scope || 'transient';
1613
1664
  const exp = exportPrefix(node);
1614
1665
  const lines = [];
@@ -1625,7 +1676,7 @@ export function generateDependency(node) {
1625
1676
  }
1626
1677
  for (const inj of injects) {
1627
1678
  const ip = p(inj);
1628
- const injName = ip.name;
1679
+ const injName = emitIdentifier(ip.name, 'dep', inj);
1629
1680
  const injType = ip.type;
1630
1681
  const injFrom = ip.from;
1631
1682
  const injWith = ip.with;
@@ -1660,7 +1711,7 @@ export function generateDependency(node) {
1660
1711
  // invalidate on=userUpdate tags="user:{id}"
1661
1712
  export function generateCache(node) {
1662
1713
  const props = p(node);
1663
- const name = props.name;
1714
+ const name = emitIdentifier(props.name, 'unknownCache', node);
1664
1715
  const backend = props.backend || 'memory';
1665
1716
  const prefix = props.prefix || '';
1666
1717
  const ttl = props.ttl;
@@ -1675,7 +1726,7 @@ export function generateCache(node) {
1675
1726
  // Entry methods
1676
1727
  for (const entry of kids(node, 'entry')) {
1677
1728
  const ep = p(entry);
1678
- const entryName = ep.name;
1729
+ const entryName = emitIdentifier(ep.name, 'entry', entry);
1679
1730
  const key = ep.key || entryName;
1680
1731
  const strategyNode = firstChild(entry, 'strategy');
1681
1732
  const strategy = strategyNode ? (p(strategyNode).name || 'cache-aside') : 'cache-aside';