@kernlang/core 3.1.1 → 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.
@@ -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.
@@ -90,17 +134,18 @@ export function handlerCode(node) {
90
134
  const p = getProps;
91
135
  const kids = getChildren;
92
136
  const firstChild = getFirstChild;
93
- function exportPrefix(node) {
137
+ export function exportPrefix(node) {
94
138
  return p(node).export === 'false' ? '' : 'export ';
95
139
  }
96
140
  // ── Type Alias ───────────────────────────────────────────────────────────
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);
@@ -1033,157 +1084,7 @@ function findDefaultSeparator(rest) {
1033
1084
  export function capitalize(s) {
1034
1085
  return s.charAt(0).toUpperCase() + s.slice(1);
1035
1086
  }
1036
- // ── Hook ─────────────────────────────────────────────────────────────────
1037
- // hook name=useSearch params="initialState:SearchState" returns=UseSearchResult
1038
- // state name=query type=string init="initialState.query"
1039
- // ref name=abortCtrl type=AbortController init="new AbortController()"
1040
- // context name=env type=EnvConfig source=EnvContext
1041
- // handler <<<
1042
- // const { data } = useSWR(cacheKey, fetcher);
1043
- // >>>
1044
- // memo name=cacheKey deps="query,filters"
1045
- // handler <<<
1046
- // return buildCacheKey(query, filters);
1047
- // >>>
1048
- // callback name=handleFilter params="field:string,value:string" deps="query"
1049
- // handler <<<
1050
- // setQuery(prev => updateFilter(prev, field, value));
1051
- // >>>
1052
- // effect deps="query"
1053
- // handler <<<
1054
- // trackSearch(query);
1055
- // >>>
1056
- // returns names="articles:data?.articles,isLoading,handleFilter,cacheKey"
1057
- export function generateHook(node) {
1058
- const props = p(node);
1059
- const name = props.name;
1060
- const params = props.params || '';
1061
- const returnsType = props.returns;
1062
- const exp = exportPrefix(node);
1063
- const lines = [];
1064
- const reactImports = new Set();
1065
- // Parse params
1066
- const paramList = parseParamList(params);
1067
- const retClause = returnsType ? `: ${returnsType}` : '';
1068
- lines.push(`${exp}function ${name}(${paramList})${retClause} {`);
1069
- // Emit children in source order — returns is always last
1070
- const children = kids(node);
1071
- const returnsNode = children.find(c => c.type === 'returns');
1072
- const ordered = children.filter(c => c.type !== 'returns');
1073
- for (const child of ordered) {
1074
- const cp = p(child);
1075
- switch (child.type) {
1076
- case 'state': {
1077
- reactImports.add('useState');
1078
- const sname = cp.name;
1079
- const stype = cp.type || 'unknown';
1080
- const sinit = cp.init || 'undefined';
1081
- const setter = `set${capitalize(sname)}`;
1082
- lines.push(` const [${sname}, ${setter}] = useState<${stype}>(${sinit});`);
1083
- break;
1084
- }
1085
- case 'ref': {
1086
- reactImports.add('useRef');
1087
- const rname = cp.name;
1088
- const rtype = cp.type || 'unknown';
1089
- const rinit = cp.init || 'null';
1090
- lines.push(` const ${rname} = useRef<${rtype}>(${rinit});`);
1091
- break;
1092
- }
1093
- case 'context': {
1094
- reactImports.add('useContext');
1095
- const cname = cp.name;
1096
- const csource = cp.source;
1097
- lines.push(` const ${cname} = useContext(${csource});`);
1098
- break;
1099
- }
1100
- case 'handler': {
1101
- const code = cp.code || '';
1102
- const dedented = dedent(code);
1103
- for (const line of dedented.split('\n')) {
1104
- lines.push(` ${line}`);
1105
- }
1106
- break;
1107
- }
1108
- case 'memo': {
1109
- reactImports.add('useMemo');
1110
- const mname = cp.name;
1111
- const mdeps = cp.deps || '';
1112
- const mcode = handlerCode(child);
1113
- const depsArr = mdeps ? `[${mdeps}]` : '[]';
1114
- lines.push(` const ${mname} = useMemo(() => {`);
1115
- if (mcode) {
1116
- for (const line of mcode.split('\n')) {
1117
- lines.push(` ${line}`);
1118
- }
1119
- }
1120
- lines.push(` }, ${depsArr});`);
1121
- break;
1122
- }
1123
- case 'callback': {
1124
- reactImports.add('useCallback');
1125
- const cbname = cp.name;
1126
- const cbparams = cp.params || '';
1127
- const cbdeps = cp.deps || '';
1128
- const cbcode = handlerCode(child);
1129
- const cbParamList = parseParamList(cbparams);
1130
- const cbDepsArr = cbdeps ? `[${cbdeps}]` : '[]';
1131
- lines.push(` const ${cbname} = useCallback((${cbParamList}) => {`);
1132
- if (cbcode) {
1133
- for (const line of cbcode.split('\n')) {
1134
- lines.push(` ${line}`);
1135
- }
1136
- }
1137
- lines.push(` }, ${cbDepsArr});`);
1138
- break;
1139
- }
1140
- case 'effect': {
1141
- reactImports.add('useEffect');
1142
- const edeps = cp.deps || '';
1143
- const ecode = handlerCode(child);
1144
- const eDepsArr = edeps ? `[${edeps}]` : '[]';
1145
- lines.push(` useEffect(() => {`);
1146
- if (ecode) {
1147
- for (const line of ecode.split('\n')) {
1148
- lines.push(` ${line}`);
1149
- }
1150
- }
1151
- // Check for cleanup block
1152
- const cleanupNode = firstChild(child, 'cleanup');
1153
- if (cleanupNode) {
1154
- const cleanupCode = p(cleanupNode).code || '';
1155
- const cleanupDedented = dedent(cleanupCode);
1156
- lines.push(` return () => {`);
1157
- for (const line of cleanupDedented.split('\n')) {
1158
- lines.push(` ${line}`);
1159
- }
1160
- lines.push(` };`);
1161
- }
1162
- lines.push(` }, ${eDepsArr});`);
1163
- break;
1164
- }
1165
- // Skip unknown child types silently
1166
- }
1167
- }
1168
- // Returns — always last
1169
- if (returnsNode) {
1170
- const rnames = p(returnsNode).names || '';
1171
- const entries = rnames.split(',').map(e => {
1172
- const [key, ...valueParts] = e.split(':');
1173
- const value = valueParts.join(':').trim();
1174
- return value ? `${key.trim()}: ${value}` : key.trim();
1175
- });
1176
- lines.push(` return { ${entries.join(', ')} };`);
1177
- }
1178
- lines.push('}');
1179
- // Prepend React imports
1180
- if (reactImports.size > 0) {
1181
- const importLine = `import { ${[...reactImports].sort().join(', ')} } from 'react';`;
1182
- lines.unshift('');
1183
- lines.unshift(importLine);
1184
- }
1185
- return lines;
1186
- }
1087
+ // Hook codegen moved to @kernlang/react (generateHook in codegen-react.ts)
1187
1088
  // ── Reason & Confidence Annotations ──────────────────────────────────────
1188
1089
  export function emitReasonAnnotations(node) {
1189
1090
  const reasonNode = firstChild(node, 'reason');
@@ -1240,7 +1141,7 @@ export function generateDerive(node) {
1240
1141
  const conf = p(node).confidence;
1241
1142
  const todo = emitLowConfidenceTodo(node, conf);
1242
1143
  const props = p(node);
1243
- const name = props.name;
1144
+ const name = emitIdentifier(props.name, 'derived', node);
1244
1145
  const expr = props.expr;
1245
1146
  const constType = props.type;
1246
1147
  const exp = exportPrefix(node);
@@ -1255,7 +1156,7 @@ export function generateTransform(node) {
1255
1156
  const conf = p(node).confidence;
1256
1157
  const todo = emitLowConfidenceTodo(node, conf);
1257
1158
  const props = p(node);
1258
- const name = props.name;
1159
+ const name = emitIdentifier(props.name, 'transform', node);
1259
1160
  const target = props.target;
1260
1161
  const via = props.via;
1261
1162
  const constType = props.type;
@@ -1288,7 +1189,7 @@ export function generateAction(node) {
1288
1189
  const conf = p(node).confidence;
1289
1190
  const todo = emitLowConfidenceTodo(node, conf);
1290
1191
  const props = p(node);
1291
- const name = props.name;
1192
+ const name = emitIdentifier(props.name, 'action', node);
1292
1193
  const idempotent = props.idempotent === 'true' || props.idempotent === true;
1293
1194
  const reversible = props.reversible === 'true' || props.reversible === true;
1294
1195
  const params = props.params || '';
@@ -1407,7 +1308,7 @@ export function generateCollect(node) {
1407
1308
  const conf = p(node).confidence;
1408
1309
  const todo = emitLowConfidenceTodo(node, conf);
1409
1310
  const props = p(node);
1410
- const name = props.name;
1311
+ const name = emitIdentifier(props.name, 'collected', node);
1411
1312
  const from = props.from;
1412
1313
  const where = props.where;
1413
1314
  const limit = props.limit;
@@ -1464,7 +1365,7 @@ export function generateResolve(node) {
1464
1365
  const conf = p(node).confidence;
1465
1366
  const todo = emitLowConfidenceTodo(node, conf);
1466
1367
  const props = p(node);
1467
- const name = props.name;
1368
+ const name = emitIdentifier(props.name, 'resolver', node);
1468
1369
  const candidates = kids(node, 'candidate');
1469
1370
  const discriminator = firstChild(node, 'discriminator');
1470
1371
  if (!discriminator)
@@ -1478,7 +1379,7 @@ export function generateResolve(node) {
1478
1379
  lines.push(`const _${name}_candidates = [`);
1479
1380
  for (const c of candidates) {
1480
1381
  const cp = p(c);
1481
- const cname = cp.name;
1382
+ const cname = emitIdentifier(cp.name, 'candidate', c);
1482
1383
  const code = handlerCode(c);
1483
1384
  lines.push(` { name: '${cname}', fn: (signal: unknown) => { ${code.trim()} } },`);
1484
1385
  }
@@ -1542,7 +1443,7 @@ export function generateRecover(node) {
1542
1443
  const conf = p(node).confidence;
1543
1444
  const todo = emitLowConfidenceTodo(node, conf);
1544
1445
  const props = p(node);
1545
- const name = props.name;
1446
+ const name = emitIdentifier(props.name, 'recovery', node);
1546
1447
  const strategies = kids(node, 'strategy');
1547
1448
  const hasFallback = strategies.some(s => p(s).name === 'fallback');
1548
1449
  if (!hasFallback)
@@ -1552,7 +1453,7 @@ export function generateRecover(node) {
1552
1453
  lines.push(`async function ${name}WithRecovery<T>(fn: () => Promise<T>): Promise<T> {`);
1553
1454
  for (const strategy of strategies) {
1554
1455
  const sp = p(strategy);
1555
- const sname = sp.name;
1456
+ const sname = emitIdentifier(sp.name, 'strategy', strategy);
1556
1457
  const code = handlerCode(strategy);
1557
1458
  if (sname === 'retry') {
1558
1459
  const max = Number(sp.max) || 3;
@@ -1596,16 +1497,16 @@ export function generatePattern(node) {
1596
1497
  // pattern nodes are registered as templates — no direct output
1597
1498
  return [];
1598
1499
  }
1599
- export function generateApply(node) {
1500
+ export function generateApply(node, _depth = 0) {
1600
1501
  // apply nodes expand the referenced pattern
1601
1502
  const props = p(node);
1602
1503
  const patternName = props.pattern;
1603
1504
  if (!patternName)
1604
1505
  return [];
1605
- // Delegate to template expansion with the pattern name as node type
1506
+ // Delegate to template expansion propagate depth to prevent infinite recursion
1606
1507
  const syntheticNode = { ...node, type: patternName };
1607
1508
  if (isTemplateNode(patternName)) {
1608
- return expandTemplateNode(syntheticNode);
1509
+ return expandTemplateNode(syntheticNode, _depth + 1);
1609
1510
  }
1610
1511
  return [`// apply: pattern '${patternName}' not found`];
1611
1512
  }
@@ -1659,12 +1560,12 @@ export function generateSelect(node) {
1659
1560
  const lines = onChange ? [`{/* kern:use-client */}`] : [];
1660
1561
  lines.push(`<select ${attrs.join(' ')}>`);
1661
1562
  if (placeholder) {
1662
- lines.push(` <option value="" disabled>${placeholder}</option>`);
1563
+ lines.push(` <option value="" disabled>${emitTemplateSafe(placeholder)}</option>`);
1663
1564
  }
1664
1565
  for (const opt of kids(node, 'option')) {
1665
1566
  const op = p(opt);
1666
- const optValue = op.value || '';
1667
- const optLabel = op.label || optValue;
1567
+ const optValue = emitTemplateSafe(op.value || '');
1568
+ const optLabel = emitTemplateSafe(op.label || op.value || '');
1668
1569
  lines.push(` <option value="${optValue}">${optLabel}</option>`);
1669
1570
  }
1670
1571
  lines.push(`</select>`);
@@ -1677,7 +1578,7 @@ export function generateSelect(node) {
1677
1578
  // relation name=posts target=Post kind=one-to-many cascade=delete
1678
1579
  export function generateModel(node) {
1679
1580
  const props = p(node);
1680
- const name = props.name;
1581
+ const name = emitIdentifier(props.name, 'UnknownModel', node);
1681
1582
  const table = props.table;
1682
1583
  const exp = exportPrefix(node);
1683
1584
  const lines = [];
@@ -1685,14 +1586,14 @@ export function generateModel(node) {
1685
1586
  lines.push(`${exp}interface ${name} {`);
1686
1587
  for (const col of kids(node, 'column')) {
1687
1588
  const cp = p(col);
1688
- const colName = cp.name;
1589
+ const colName = emitIdentifier(cp.name, 'column', col);
1689
1590
  const colType = mapColumnType(cp.type);
1690
1591
  const opt = cp.optional === 'true' || cp.optional === true ? '?' : '';
1691
1592
  lines.push(` ${colName}${opt}: ${colType};`);
1692
1593
  }
1693
1594
  for (const rel of kids(node, 'relation')) {
1694
1595
  const rp = p(rel);
1695
- const relName = rp.name;
1596
+ const relName = emitIdentifier(rp.name, 'relation', rel);
1696
1597
  const target = rp.target;
1697
1598
  const kind = rp.kind || 'one-to-many';
1698
1599
  const relType = kind.includes('many') ? `${target}[]` : target;
@@ -1722,7 +1623,7 @@ function mapColumnType(kernType) {
1722
1623
  // handler <<<return this.findOne({ email });>>>
1723
1624
  export function generateRepository(node) {
1724
1625
  const props = p(node);
1725
- const name = props.name;
1626
+ const name = emitIdentifier(props.name, 'UnknownRepo', node);
1726
1627
  const model = props.model;
1727
1628
  const exp = exportPrefix(node);
1728
1629
  const lines = [];
@@ -1733,7 +1634,7 @@ export function generateRepository(node) {
1733
1634
  }
1734
1635
  for (const method of kids(node, 'method')) {
1735
1636
  const mp = p(method);
1736
- const mname = mp.name;
1637
+ const mname = emitIdentifier(mp.name, 'method', method);
1737
1638
  const mparams = mp.params ? parseParamList(mp.params) : '';
1738
1639
  const isAsync = mp.async === 'true' || mp.async === true;
1739
1640
  const asyncKw = isAsync ? 'async ' : '';
@@ -1758,7 +1659,7 @@ export function generateRepository(node) {
1758
1659
  // returns AuthService with=repo
1759
1660
  export function generateDependency(node) {
1760
1661
  const props = p(node);
1761
- const name = props.name;
1662
+ const name = emitIdentifier(props.name, 'unknownDep', node);
1762
1663
  const scope = props.scope || 'transient';
1763
1664
  const exp = exportPrefix(node);
1764
1665
  const lines = [];
@@ -1775,7 +1676,7 @@ export function generateDependency(node) {
1775
1676
  }
1776
1677
  for (const inj of injects) {
1777
1678
  const ip = p(inj);
1778
- const injName = ip.name;
1679
+ const injName = emitIdentifier(ip.name, 'dep', inj);
1779
1680
  const injType = ip.type;
1780
1681
  const injFrom = ip.from;
1781
1682
  const injWith = ip.with;
@@ -1810,7 +1711,7 @@ export function generateDependency(node) {
1810
1711
  // invalidate on=userUpdate tags="user:{id}"
1811
1712
  export function generateCache(node) {
1812
1713
  const props = p(node);
1813
- const name = props.name;
1714
+ const name = emitIdentifier(props.name, 'unknownCache', node);
1814
1715
  const backend = props.backend || 'memory';
1815
1716
  const prefix = props.prefix || '';
1816
1717
  const ttl = props.ttl;
@@ -1825,7 +1726,7 @@ export function generateCache(node) {
1825
1726
  // Entry methods
1826
1727
  for (const entry of kids(node, 'entry')) {
1827
1728
  const ep = p(entry);
1828
- const entryName = ep.name;
1729
+ const entryName = emitIdentifier(ep.name, 'entry', entry);
1829
1730
  const key = ep.key || entryName;
1830
1731
  const strategyNode = firstChild(entry, 'strategy');
1831
1732
  const strategy = strategyNode ? (p(strategyNode).name || 'cache-aside') : 'cache-aside';
@@ -1910,7 +1811,7 @@ export function generateCoreNode(node, target) {
1910
1811
  case 'event': return generateEvent(node);
1911
1812
  case 'import': return generateImport(node);
1912
1813
  case 'const': return generateConst(node);
1913
- case 'hook': return generateHook(node);
1814
+ case 'hook': return []; // Handled by @kernlang/react
1914
1815
  case 'on': return generateOn(node);
1915
1816
  case 'websocket': return generateWebSocket(node);
1916
1817
  // Ground layer