@kernlang/cli 2.0.0 → 3.1.0

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.
package/dist/cli.js CHANGED
@@ -2,16 +2,18 @@
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
3
3
  import { resolve, basename, dirname, relative } from 'path';
4
4
  import { createJiti } from 'jiti';
5
- import { parse, decompile, resolveConfig, VALID_TARGETS, VALID_STRUCTURES, generateCoreNode, isCoreNode, detectVersionsFromPackageJson, scanProject, generateConfigSource, formatScanSummary, registerTemplate, isTemplateNode, expandTemplateNode, clearTemplates, detectTemplates, COMMON_TEMPLATES } from '@kernlang/core';
5
+ import { parse, decompile, resolveConfig, VALID_TARGETS, VALID_STRUCTURES, generateCoreNode, isCoreNode, detectVersionsFromPackageJson, scanProject, generateConfigSource, formatScanSummary, registerTemplate, isTemplateNode, expandTemplateNode, clearTemplates, detectTemplates, COMMON_TEMPLATES, collectCoverageGaps, writeCoverageGaps, NODE_TYPES } from '@kernlang/core';
6
6
  import { generateReactNode, isReactNode } from '@kernlang/react';
7
7
  import { transpile } from '@kernlang/native';
8
8
  import { transpileWeb, transpileTailwind, transpileNextjs } from '@kernlang/react';
9
9
  import { transpileExpress } from '@kernlang/express';
10
+ import { transpileFastAPI } from '@kernlang/fastapi';
10
11
  import { transpileCliApp } from './transpiler-cli.js';
11
- import { transpileTerminal } from '@kernlang/terminal';
12
+ import { transpileTerminal, transpileInk } from '@kernlang/terminal';
12
13
  import { transpileVue, transpileNuxt } from '@kernlang/vue';
13
14
  import { collectLanguageMetrics } from '@kernlang/metrics';
14
- import { reviewFile, reviewDirectory, formatReport, formatSummary, checkEnforcement, formatEnforcement, exportKernIR, buildLLMPrompt, dedup, runESLint, runTSCDiagnosticsFromPaths, linkToNodes } from '@kernlang/review';
15
+ import { reviewFile, reviewGraph, resolveImportGraph, formatReport, formatSARIF, formatSummary, checkEnforcement, formatEnforcement, exportKernIR, buildLLMPrompt, dedup, runESLint, runTSCDiagnosticsFromPaths, linkToNodes, runLLMReview, isLLMAvailable, analyzeTaint, checkSpecFiles, specViolationsToFindings } from '@kernlang/review';
16
+ import { evolve, loadBuiltinDetectors, listStaged, updateStagedStatus, promoteLocal, cleanRejected, formatSplitView, loadEvolvedNodes, runGoldenTests, formatGoldenTestResults, rollbackNode, restoreNode, readEvolvedManifest, buildDiscoveryPrompt, parseDiscoveryResponse, selectRepresentativeFiles, collectTsFiles, estimateTokens, createLLMProvider, TokenBudget, validateEvolveProposal, graduateNode, compileCodegenToJS, stageEvolveV4Proposal, listStagedEvolveV4, getStagedEvolveV4, updateStagedEvolveV4Status, cleanRejectedEvolveV4, cleanApprovedEvolveV4, formatEvolveV4SplitView, promoteNode, pruneNodes, detectCollisions, renameEvolvedNode, readNodeDefinition, buildBackfillPrompt, buildRetryPrompt, rebuildEvolvedManifest } from '@kernlang/evolve';
15
17
  const args = process.argv.slice(2);
16
18
  const GENERATED_HEADER = '// Generated by KERN — do not edit. Source: ';
17
19
  // ── kern dev <dir|file> [--target=...] [--outdir=...] ─────────────────
@@ -57,10 +59,17 @@ if (args[0] === 'dev') {
57
59
  console.log(` Auto-detected: ${parts.join(', ')}`);
58
60
  }
59
61
  }
60
- catch { }
62
+ catch {
63
+ // Intentional: package.json detection is optional — build continues without it
64
+ }
61
65
  }
62
66
  // Load templates before compilation
63
67
  loadTemplates(devConfig);
68
+ // Load evolved nodes (v4) — graduated nodes from .kern/evolved/
69
+ const evolvedResult = loadEvolvedNodes(process.cwd(), args.includes('--verify'));
70
+ if (evolvedResult.loaded > 0) {
71
+ console.log(` Evolved nodes: ${evolvedResult.loaded} loaded`);
72
+ }
64
73
  console.log(`\n KERN dev — watching for changes`);
65
74
  console.log(` Target: ${devConfig.target}`);
66
75
  console.log(` Watch: ${relative(process.cwd(), watchDir) || '.'}`);
@@ -68,7 +77,7 @@ if (args[0] === 'dev') {
68
77
  // Initial build of all .kern files
69
78
  const initialFiles = findKernFiles(watchDir, watchPattern);
70
79
  for (const file of initialFiles) {
71
- transpileAndWrite(file, devConfig, devOutDir);
80
+ transpileAndWrite(file, devConfig, devOutDir, watchDir);
72
81
  }
73
82
  if (initialFiles.length > 0) {
74
83
  console.log(` ${initialFiles.length} file(s) compiled.\n`);
@@ -86,7 +95,7 @@ if (args[0] === 'dev') {
86
95
  const rel = relative(process.cwd(), filePath);
87
96
  const start = performance.now();
88
97
  try {
89
- transpileAndWrite(filePath, devConfig, devOutDir);
98
+ transpileAndWrite(filePath, devConfig, devOutDir, watchDir);
90
99
  const ms = Math.round(performance.now() - start);
91
100
  console.log(` ${rel} → compiled (${ms}ms)`);
92
101
  }
@@ -98,7 +107,7 @@ if (args[0] === 'dev') {
98
107
  const rel = relative(process.cwd(), filePath);
99
108
  const start = performance.now();
100
109
  try {
101
- transpileAndWrite(filePath, devConfig, devOutDir);
110
+ transpileAndWrite(filePath, devConfig, devOutDir, watchDir);
102
111
  const ms = Math.round(performance.now() - start);
103
112
  console.log(` ${rel} → compiled (${ms}ms)`);
104
113
  }
@@ -110,9 +119,12 @@ if (args[0] === 'dev') {
110
119
  const rel = relative(process.cwd(), filePath);
111
120
  const ext = filePath.endsWith('.kern') ? '.kern' : '.ir';
112
121
  const fileBaseName = basename(filePath, ext);
113
- const outDir = resolve(devOutDir ? resolve(devOutDir) : dirname(filePath), devConfig.output.outDir);
114
- const outExt = (devConfig.target === 'vue' || devConfig.target === 'nuxt') ? '.vue'
115
- : (devConfig.target === 'express' || devConfig.target === 'cli' || devConfig.target === 'terminal') ? '.ts' : '.tsx';
122
+ const unlinkRelDir = relative(resolve(watchDir), dirname(filePath));
123
+ const unlinkBaseDir = devOutDir ? resolve(resolve(devOutDir), unlinkRelDir) : dirname(filePath);
124
+ const outDir = resolve(unlinkBaseDir, devConfig.output.outDir);
125
+ const outExt = devConfig.target === 'fastapi' ? '.py'
126
+ : (devConfig.target === 'vue' || devConfig.target === 'nuxt') ? '.vue'
127
+ : (devConfig.target === 'express' || devConfig.target === 'cli' || devConfig.target === 'terminal') ? '.ts' : '.tsx';
116
128
  const outFile = resolve(outDir, `${fileBaseName}${outExt}`);
117
129
  try {
118
130
  if (existsSync(outFile)) {
@@ -169,7 +181,7 @@ function findKernFiles(dir, singleFile) {
169
181
  walk(dir);
170
182
  return files;
171
183
  }
172
- function transpileAndWrite(file, cfg, outDirOverride) {
184
+ function transpileAndWrite(file, cfg, outDirOverride, inputBase) {
173
185
  const source = readFileSync(file, 'utf-8');
174
186
  const ast = parse(source);
175
187
  const ext = file.endsWith('.kern') ? '.kern' : '.ir';
@@ -177,6 +189,13 @@ function transpileAndWrite(file, cfg, outDirOverride) {
177
189
  const target = cfg.target;
178
190
  const relSource = relative(process.cwd(), file);
179
191
  const header = GENERATED_HEADER + relSource + '\n\n';
192
+ // Emit coverage gaps unless --no-gaps
193
+ if (!args.includes('--no-gaps')) {
194
+ const gaps = collectCoverageGaps(ast, file);
195
+ if (gaps.length > 0) {
196
+ writeCoverageGaps(gaps, resolve(process.cwd(), '.kern-gaps'));
197
+ }
198
+ }
180
199
  const result = target === 'native'
181
200
  ? transpile(ast, cfg)
182
201
  : target === 'web'
@@ -185,16 +204,24 @@ function transpileAndWrite(file, cfg, outDirOverride) {
185
204
  ? transpileTailwind(ast, cfg)
186
205
  : target === 'express'
187
206
  ? transpileExpress(ast, cfg)
188
- : target === 'cli'
189
- ? transpileCliApp(ast, cfg)
190
- : target === 'terminal'
191
- ? transpileTerminal(ast, cfg)
192
- : target === 'vue'
193
- ? transpileVue(ast, cfg)
194
- : target === 'nuxt'
195
- ? transpileNuxt(ast, cfg)
196
- : transpileNextjs(ast, cfg);
197
- const outDir = resolve(outDirOverride ? resolve(outDirOverride) : dirname(file), cfg.output.outDir);
207
+ : target === 'fastapi'
208
+ ? transpileFastAPI(ast, cfg)
209
+ : target === 'cli'
210
+ ? transpileCliApp(ast, cfg)
211
+ : target === 'terminal'
212
+ ? transpileTerminal(ast, cfg)
213
+ : target === 'ink'
214
+ ? transpileInk(ast, cfg)
215
+ : target === 'vue'
216
+ ? transpileVue(ast, cfg)
217
+ : target === 'nuxt'
218
+ ? transpileNuxt(ast, cfg)
219
+ : transpileNextjs(ast, cfg);
220
+ // Preserve relative directory structure when outDirOverride is set
221
+ // e.g. kern/llm/patterns.kern with --outdir=app/ → app/llm/page.tsx (not app/page.tsx)
222
+ const relDir = inputBase ? relative(resolve(inputBase), dirname(file)) : '';
223
+ const baseDir = outDirOverride ? resolve(resolve(outDirOverride), relDir) : dirname(file);
224
+ const outDir = resolve(baseDir, cfg.output.outDir);
198
225
  mkdirSync(outDir, { recursive: true });
199
226
  if (result.artifacts && result.artifacts.length > 0 && cfg.structure !== 'flat') {
200
227
  for (const artifact of result.artifacts) {
@@ -204,14 +231,18 @@ function transpileAndWrite(file, cfg, outDirOverride) {
204
231
  }
205
232
  }
206
233
  else {
207
- const outExt = (target === 'vue' || target === 'nuxt') ? '.vue'
208
- : (target === 'express' || target === 'cli' || target === 'terminal') ? '.ts' : '.tsx';
234
+ const outExt = target === 'fastapi' ? '.py'
235
+ : (target === 'vue' || target === 'nuxt') ? '.vue'
236
+ : (target === 'express' || target === 'cli' || target === 'terminal') ? '.ts'
237
+ : '.tsx';
209
238
  // For Next.js target, use the file convention name from the transpiler result (page.tsx, layout.tsx, etc.)
210
239
  const resultWithFiles = result;
211
240
  const outFileName = (target === 'nextjs' && resultWithFiles.files && resultWithFiles.files.length > 0)
212
241
  ? resultWithFiles.files[0].path
213
242
  : `${name}${outExt}`;
214
- writeFileSync(resolve(outDir, outFileName), header + result.code);
243
+ const outFilePath = resolve(outDir, outFileName);
244
+ mkdirSync(dirname(outFilePath), { recursive: true });
245
+ writeFileSync(outFilePath, header + result.code);
215
246
  if (result.artifacts) {
216
247
  for (const artifact of result.artifacts) {
217
248
  const artifactPath = resolve(outDir, artifact.path);
@@ -232,10 +263,41 @@ function loadConfig() {
232
263
  }
233
264
  catch (err) {
234
265
  console.error(`Warning: Failed to load kern.config.ts: ${err.message}`);
235
- return resolveConfig({});
236
266
  }
237
267
  }
238
- return resolveConfig({});
268
+ // No kern.config.ts — auto-detect target from package.json
269
+ const autoDetected = autoDetectTarget();
270
+ return resolveConfig(autoDetected ? { target: autoDetected } : {});
271
+ }
272
+ function autoDetectTarget() {
273
+ const pkgPath = resolve(process.cwd(), 'package.json');
274
+ if (!existsSync(pkgPath))
275
+ return null;
276
+ try {
277
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
278
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
279
+ // Priority: most specific first
280
+ if (allDeps['next'])
281
+ return 'nextjs';
282
+ if (allDeps['nuxt'])
283
+ return 'nuxt';
284
+ if (allDeps['vue'])
285
+ return 'vue';
286
+ if (allDeps['react-native'])
287
+ return 'native';
288
+ if (allDeps['fastapi'] || allDeps['flask'] || allDeps['django'])
289
+ return 'express'; // Python web → express rules are closest
290
+ if (allDeps['express'] || allDeps['fastify'] || allDeps['koa'] || allDeps['hono'])
291
+ return 'express';
292
+ if (allDeps['tailwindcss'] && allDeps['react'])
293
+ return 'tailwind';
294
+ if (allDeps['react'])
295
+ return 'web';
296
+ return null;
297
+ }
298
+ catch {
299
+ return null;
300
+ }
239
301
  }
240
302
  function loadTemplates(cfg) {
241
303
  clearTemplates();
@@ -448,17 +510,42 @@ if (args[0] === 'init-templates') {
448
510
  console.log('');
449
511
  process.exit(0);
450
512
  }
451
- // ── kern review <file|dir|--diff base> [--json] [--recursive] [--enforce] [--min-coverage=N] [--export-kern] [--llm] [--fix] [--lint] ──
513
+ // ── kern review <file|dir|--diff base> [--json] [--sarif] [--recursive] [--enforce] [--min-coverage=N] [--max-complexity=N] [--export-kern] [--llm] [--fix] [--lint] ──
452
514
  if (args[0] === 'review') {
453
515
  const jsonOutput = args.includes('--json');
516
+ const sarifOutput = args.includes('--sarif') || args.includes('--format=sarif');
454
517
  const recursive = args.includes('--recursive') || args.includes('-r');
455
518
  const enforce = args.includes('--enforce');
456
519
  const exportKern = args.includes('--export-kern');
457
520
  const llmMode = args.includes('--llm');
521
+ const cloudMode = args.includes('--cloud');
522
+ const securityMode = args.includes('--security');
523
+ const mcpMode = args.includes('--mcp');
524
+ const specMode = args.includes('--spec');
525
+ const specFile = args.find(a => a.endsWith('.kern') && a !== 'review');
458
526
  const fixMode = args.includes('--fix');
459
527
  const lintMode = args.includes('--lint');
528
+ const graphMode = args.includes('--graph');
529
+ const batchMode = args.includes('--batch');
530
+ const maxDepthArg = args.find(a => a.startsWith('--max-depth='))?.split('=')[1];
531
+ const maxDepth = maxDepthArg ? Number(maxDepthArg) : 3;
532
+ const batchSizeArg = args.find(a => a.startsWith('--batch-size='))?.split('=')[1];
533
+ const batchSize = batchSizeArg ? Number(batchSizeArg) : 20;
534
+ const tsconfigPath = args.find(a => a.startsWith('--tsconfig='))?.split('=')[1];
460
535
  const minCoverageArg = args.find(a => a.startsWith('--min-coverage='))?.split('=')[1];
461
536
  const minCoverage = minCoverageArg ? Number(minCoverageArg) : undefined;
537
+ const maxComplexityArg = args.find(a => a.startsWith('--max-complexity='))?.split('=')[1];
538
+ const maxComplexity = maxComplexityArg ? Number(maxComplexityArg) : 15;
539
+ const maxErrorsArg = args.find(a => a.startsWith('--max-errors='))?.split('=')[1];
540
+ const maxErrors = maxErrorsArg ? Number(maxErrorsArg) : 0;
541
+ const maxWarningsArg = args.find(a => a.startsWith('--max-warnings='))?.split('=')[1];
542
+ const maxWarnings = maxWarningsArg ? Number(maxWarningsArg) : undefined;
543
+ const showConfidence = args.includes('--confidence');
544
+ const minConfidenceArg = args.find(a => a.startsWith('--min-confidence='))?.split('=')[1];
545
+ const minConfidence = minConfidenceArg ? Number(minConfidenceArg) : undefined;
546
+ const disableRuleArgs = args.filter(a => a.startsWith('--disable-rule=')).map(a => a.split('=')[1]);
547
+ const strictArg = args.find(a => a === '--strict' || a.startsWith('--strict='));
548
+ const strict = strictArg === '--strict' ? 'inline' : strictArg === '--strict=all' ? 'all' : false;
462
549
  const diffBase = args.find(a => a.startsWith('--diff'))
463
550
  ? (args.find(a => a.startsWith('--diff='))?.split('=')[1] || args[args.indexOf('--diff') + 1] || 'origin/main')
464
551
  : undefined;
@@ -467,8 +554,9 @@ if (args[0] === 'review') {
467
554
  let reviewInput = reviewInputs[0];
468
555
  if (diffBase && !reviewInput) {
469
556
  try {
470
- const { execSync } = await import('child_process');
471
- const diffFiles = execSync(`git diff --name-only --diff-filter=ACMR ${diffBase}`, { encoding: 'utf-8' })
557
+ const { execFileSync } = await import('child_process');
558
+ const sanitizedBase = diffBase.replace(/[^a-zA-Z0-9_.\/\-~]/g, '');
559
+ const diffFiles = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACMR', sanitizedBase], { encoding: 'utf-8' })
472
560
  .trim()
473
561
  .split('\n')
474
562
  .filter(f => f.endsWith('.ts') || f.endsWith('.tsx'))
@@ -488,7 +576,8 @@ if (args[0] === 'review') {
488
576
  }
489
577
  }
490
578
  if (!reviewInput) {
491
- console.error('Usage: kern review <file.ts|dir> [--diff base] [--json] [--recursive] [--enforce] [--min-coverage=N] [--export-kern] [--llm] [--fix]');
579
+ console.error('Usage: kern review <file.ts|dir> [--security] [--mcp] [--llm] [--spec file.kern] [--cloud]');
580
+ console.error(' [--diff base] [--json] [--sarif] [--recursive] [--enforce] [--fix]');
492
581
  process.exit(1);
493
582
  }
494
583
  // Skip stat check for --diff mode (files are resolved individually below)
@@ -501,12 +590,29 @@ if (args[0] === 'review') {
501
590
  }
502
591
  }
503
592
  // Load kern.config.ts to get registered templates and target
593
+ // Auto-detects target from package.json if no config exists
504
594
  const reviewCfg = loadConfig();
595
+ if (!jsonOutput && !sarifOutput) {
596
+ const configExists = existsSync(resolve(process.cwd(), 'kern.config.ts'));
597
+ if (!configExists) {
598
+ console.log(` Target: ${reviewCfg.target} (auto-detected from package.json)`);
599
+ }
600
+ }
601
+ // Merge disabledRules from config + CLI flags
602
+ const cfgDisabledRules = reviewCfg.review.disabledRules ?? [];
603
+ const mergedDisabledRules = [...new Set([...cfgDisabledRules, ...disableRuleArgs])];
505
604
  const reviewConfig = {
506
605
  registeredTemplates: [],
507
606
  minCoverage: minCoverage ?? 0,
508
607
  enforceTemplates: enforce,
608
+ maxComplexity: maxComplexity ?? reviewCfg.review.maxComplexity,
609
+ maxErrors,
610
+ maxWarnings,
509
611
  target: reviewCfg.target,
612
+ showConfidence: showConfidence || reviewCfg.review.showConfidence,
613
+ minConfidence: minConfidence ?? reviewCfg.review.minConfidence,
614
+ disabledRules: mergedDisabledRules.length > 0 ? mergedDisabledRules : undefined,
615
+ strict,
510
616
  };
511
617
  // Load templates and collect their names
512
618
  if (reviewCfg.templates && reviewCfg.templates.length > 0) {
@@ -538,7 +644,9 @@ if (args[0] === 'review') {
538
644
  registerTemplate(node, file);
539
645
  }
540
646
  }
541
- catch { }
647
+ catch (e) {
648
+ console.error(` Warning: Failed to parse template ${basename(file)}: ${e.message}`);
649
+ }
542
650
  }
543
651
  }
544
652
  if (reviewConfig.registeredTemplates.length > 0) {
@@ -547,20 +655,13 @@ if (args[0] === 'review') {
547
655
  }
548
656
  // Collect reports from diff, directory, or single file
549
657
  let reports = [];
658
+ // Collect entry file paths for --graph mode
659
+ let entryFilePaths = [];
550
660
  if (reviewInput === '__diff__') {
551
661
  const diffFiles = globalThis.__diffFiles;
552
- for (const f of diffFiles) {
553
- const fullPath = resolve(f);
554
- if (existsSync(fullPath)) {
555
- try {
556
- reports.push(reviewFile(fullPath, reviewConfig));
557
- }
558
- catch { }
559
- }
560
- }
662
+ entryFilePaths = diffFiles.map(f => resolve(f)).filter(f => existsSync(f));
561
663
  }
562
664
  else {
563
- // Support multiple positional paths: kern review file1.ts file2.ts dir/
564
665
  const paths = reviewInputs.length > 0 ? reviewInputs : [reviewInput];
565
666
  for (const p of paths) {
566
667
  const rPath = resolve(p);
@@ -568,20 +669,78 @@ if (args[0] === 'review') {
568
669
  continue;
569
670
  const rStat = statSync(rPath);
570
671
  if (rStat.isDirectory()) {
571
- reports.push(...reviewDirectory(rPath, recursive, reviewConfig));
672
+ // Collect files from directory for graph seeding
673
+ entryFilePaths.push(...collectTsFilesFlat(rPath, recursive));
572
674
  }
573
675
  else {
676
+ entryFilePaths.push(rPath);
677
+ }
678
+ }
679
+ }
680
+ if (graphMode && entryFilePaths.length > 0) {
681
+ // --graph: resolve import graph, review all files with provenance
682
+ const graphOpts = { maxDepth, tsConfigFilePath: tsconfigPath ? resolve(tsconfigPath) : undefined };
683
+ const graph = resolveImportGraph(entryFilePaths, graphOpts);
684
+ console.log(` Graph: ${graph.totalFiles} files resolved (${graph.skipped} skipped, depth ${maxDepth})`);
685
+ reports = reviewGraph(entryFilePaths, reviewConfig, graphOpts);
686
+ }
687
+ else if (batchMode && entryFilePaths.length > batchSize) {
688
+ // --batch: process in chunks for large repos
689
+ const totalBatches = Math.ceil(entryFilePaths.length / batchSize);
690
+ for (let i = 0; i < entryFilePaths.length; i += batchSize) {
691
+ const batch = entryFilePaths.slice(i, i + batchSize);
692
+ const batchNum = Math.floor(i / batchSize) + 1;
693
+ for (const f of batch) {
574
694
  try {
575
- reports.push(reviewFile(rPath, reviewConfig));
695
+ reports.push(reviewFile(f, reviewConfig));
696
+ }
697
+ catch (e) {
698
+ console.error(` Review error in ${f}: ${e.message}`);
576
699
  }
577
- catch { }
700
+ }
701
+ const batchFindings = reports.slice(-batch.length).reduce((sum, r) => sum + r.findings.length, 0);
702
+ console.log(` Batch ${batchNum}/${totalBatches}: ${batch.length} files reviewed (${batchFindings} findings)`);
703
+ }
704
+ }
705
+ else {
706
+ // Standard mode: review each file individually
707
+ for (const f of entryFilePaths) {
708
+ try {
709
+ reports.push(reviewFile(f, reviewConfig));
710
+ }
711
+ catch (e) {
712
+ console.error(` Review error in ${f}: ${e.message}`);
578
713
  }
579
714
  }
580
715
  }
581
716
  if (reports.length === 0) {
582
- console.log(' No .ts/.tsx files found to review.');
717
+ console.log(' No reviewable files found (.ts/.tsx/.py/.kern).');
583
718
  process.exit(0);
584
719
  }
720
+ // MCP security review — --mcp flag or auto-detect MCP server files
721
+ try {
722
+ const { reviewIfMCP, reviewMCPSource } = await import('@kernlang/review-mcp');
723
+ let mcpFileCount = 0;
724
+ for (const report of reports) {
725
+ const source = readFileSync(report.filePath, 'utf-8');
726
+ const mcpFindings = mcpMode
727
+ ? reviewMCPSource(source, report.filePath)
728
+ : reviewIfMCP(source, report.filePath);
729
+ if (mcpFindings && mcpFindings.length > 0) {
730
+ report.findings.push(...mcpFindings);
731
+ mcpFileCount++;
732
+ }
733
+ }
734
+ if (mcpFileCount > 0 && !jsonOutput && !sarifOutput) {
735
+ console.log(` MCP security: ${mcpFileCount} server file(s) scanned`);
736
+ }
737
+ }
738
+ catch {
739
+ // @kernlang/review-mcp not installed — skip silently
740
+ if (mcpMode) {
741
+ console.error(' @kernlang/review-mcp not installed. Run: pnpm add @kernlang/review-mcp');
742
+ }
743
+ }
585
744
  // --export-kern: output KERN IR for AI review (v1 compat)
586
745
  if (exportKern) {
587
746
  for (const report of reports) {
@@ -590,15 +749,186 @@ if (args[0] === 'review') {
590
749
  }
591
750
  process.exit(0);
592
751
  }
593
- // --llm: output structured LLM prompt with nodeId aliases
594
- if (llmMode) {
752
+ // --spec: verify .kern spec contracts against .ts implementation
753
+ if (specMode && specFile) {
754
+ const kernFilePath = resolve(specFile);
755
+ if (!existsSync(kernFilePath)) {
756
+ console.error(` .kern spec file not found: ${specFile}`);
757
+ process.exit(1);
758
+ }
759
+ console.log(`\n KERN spec check: ${specFile} → ${reports.length} implementation files\n`);
760
+ let totalViolations = 0;
595
761
  for (const report of reports) {
596
- console.log(`\n// ── ${report.filePath} ──`);
597
- console.log(buildLLMPrompt(report.inferred, report.templateMatches));
762
+ const result = checkSpecFiles(kernFilePath, report.filePath);
763
+ if (result.violations.length > 0) {
764
+ const findings = specViolationsToFindings(result);
765
+ totalViolations += findings.length;
766
+ report.findings.push(...findings);
767
+ report.findings = dedup(report.findings);
768
+ for (const v of result.violations) {
769
+ const icon = v.kind.includes('missing') || v.kind === 'spec-unimplemented' ? '✗' : '~';
770
+ const sev = v.kind === 'spec-auth-missing' || v.kind === 'spec-unimplemented' ? 'ERROR' : v.kind === 'spec-undeclared' ? 'INFO' : 'WARN';
771
+ console.log(` ${icon} [${sev}] ${v.kind}: ${v.detail}`);
772
+ if (v.suggestion)
773
+ console.log(` → ${v.suggestion}`);
774
+ }
775
+ }
776
+ if (result.matched.length > 0) {
777
+ const satisfied = result.matched.length - result.violations.filter(v => v.kind !== 'spec-undeclared' && v.kind !== 'spec-unimplemented').length;
778
+ console.log(`\n Matched: ${result.matched.length} routes | Satisfied: ${satisfied} | Violations: ${totalViolations}`);
779
+ if (result.unmatchedSpecs.length > 0)
780
+ console.log(` Unimplemented: ${result.unmatchedSpecs.map(s => s.routeKey).join(', ')}`);
781
+ if (result.unmatchedImpls.length > 0)
782
+ console.log(` Undeclared: ${result.unmatchedImpls.map(i => i.routeKey).join(', ')}`);
783
+ }
784
+ }
785
+ if (totalViolations === 0) {
786
+ console.log(' All spec contracts satisfied.');
787
+ }
788
+ console.log('');
789
+ // Fall through to normal output
790
+ }
791
+ // --security: show only security-related findings
792
+ if (securityMode) {
793
+ const SECURITY_RULES = new Set([
794
+ 'xss-unsafe-html', 'hardcoded-secret', 'command-injection', 'no-eval',
795
+ 'insecure-random', 'cors-wildcard', 'helmet-missing', 'open-redirect',
796
+ 'jwt-weak-verification', 'cookie-hardening', 'csrf-detection', 'csp-strength',
797
+ 'path-traversal', 'weak-password-hashing', 'regex-dos', 'missing-input-validation',
798
+ 'prototype-pollution', 'information-exposure', 'prompt-injection',
799
+ 'taint-command', 'taint-fs', 'taint-sql', 'taint-redirect', 'taint-eval',
800
+ 'taint-insufficient-sanitizer', 'taint-crossfile-command', 'taint-crossfile-fs',
801
+ 'taint-crossfile-sql', 'taint-crossfile-redirect', 'taint-crossfile-eval',
802
+ 'spec-auth-missing', 'spec-validate-missing', 'spec-guard-missing',
803
+ 'spec-middleware-missing', 'spec-unimplemented',
804
+ ]);
805
+ console.log('\n KERN Security Report\n');
806
+ let totalSec = 0;
807
+ for (const report of reports) {
808
+ const secFindings = report.findings.filter(f => SECURITY_RULES.has(f.ruleId));
809
+ if (secFindings.length === 0)
810
+ continue;
811
+ totalSec += secFindings.length;
812
+ const rel = relative(process.cwd(), report.filePath);
813
+ console.log(` ${rel}:`);
814
+ for (const f of secFindings) {
815
+ const icon = f.severity === 'error' ? '✗' : f.severity === 'warning' ? '~' : '-';
816
+ console.log(` ${icon} L${f.primarySpan.startLine}: [${f.ruleId}] ${f.message}`);
817
+ if (f.suggestion)
818
+ console.log(` → ${f.suggestion}`);
819
+ }
820
+ console.log('');
821
+ }
822
+ if (totalSec === 0) {
823
+ console.log(' No security issues found.');
598
824
  }
599
- console.log('\n// Paste the JSON response from your AI to validate and map findings back to TS.');
825
+ else {
826
+ const errors = reports.flatMap(r => r.findings).filter(f => SECURITY_RULES.has(f.ruleId) && f.severity === 'error').length;
827
+ const warnings = reports.flatMap(r => r.findings).filter(f => SECURITY_RULES.has(f.ruleId) && f.severity === 'warning').length;
828
+ console.log(` Total: ${totalSec} security findings (${errors} errors, ${warnings} warnings)`);
829
+ }
830
+ console.log(' Rules: OWASP Top 10, OWASP LLM Top 10, Taint Tracking, Spec Contracts');
831
+ console.log('');
832
+ process.exit(0);
833
+ }
834
+ // --cloud: KERN Pro cloud review (coming soon)
835
+ if (cloudMode) {
836
+ console.log('');
837
+ console.log(' KERN Pro — Cloud-powered AI review');
838
+ console.log('');
839
+ console.log(' Coming soon. Cloud review will provide:');
840
+ console.log(' • LLM-powered security analysis without an AI IDE');
841
+ console.log(' • Team dashboard with trend tracking');
842
+ console.log(' • Custom rule engine for enterprise');
843
+ console.log(' • CI/CD integration with quality gates');
844
+ console.log('');
845
+ console.log(' For now, use --llm with your AI assistant (Claude Code, Cursor, etc.)');
846
+ console.log(' The assistant reads the KERN IR output and performs the AI review.');
847
+ console.log('');
848
+ console.log(' → kern review src/ --llm');
849
+ console.log('');
850
+ console.log(' Join the waitlist: https://kernlang.dev/pro');
851
+ console.log('');
600
852
  process.exit(0);
601
853
  }
854
+ // --llm: LLM-assisted security review (batch file check)
855
+ if (llmMode) {
856
+ // Build graph context for LLM markers if --graph is active
857
+ const llmGraphContext = graphMode ? (() => {
858
+ const fileDistances = new Map();
859
+ for (const report of reports) {
860
+ const finding = report.findings[0];
861
+ const distance = finding?.distance ?? 0;
862
+ fileDistances.set(report.filePath, distance);
863
+ }
864
+ for (const ep of entryFilePaths) {
865
+ fileDistances.set(ep, 0);
866
+ }
867
+ return { fileDistances };
868
+ })() : undefined;
869
+ if (isLLMAvailable()) {
870
+ // Phase 3: actual LLM API call with taint context
871
+ console.log(' LLM review: calling API...');
872
+ const llmInputs = reports.map(report => ({
873
+ filePath: report.filePath,
874
+ inferred: report.inferred,
875
+ templateMatches: report.templateMatches,
876
+ taintResults: analyzeTaint(report.inferred, report.filePath),
877
+ graphContext: llmGraphContext,
878
+ }));
879
+ try {
880
+ const llmFindings = await runLLMReview(llmInputs);
881
+ console.log(` LLM review: ${llmFindings.length} findings from AI`);
882
+ // Merge LLM findings into reports
883
+ for (const f of llmFindings) {
884
+ const report = reports.find(r => r.filePath === f.primarySpan.file);
885
+ if (report) {
886
+ report.findings.push(f);
887
+ }
888
+ else if (reports.length > 0) {
889
+ reports[0].findings.push(f);
890
+ }
891
+ }
892
+ // Dedup after merge
893
+ for (const report of reports) {
894
+ report.findings = dedup(report.findings);
895
+ }
896
+ }
897
+ catch (err) {
898
+ console.error(` LLM review failed: ${err.message}`);
899
+ }
900
+ // Fall through to normal output (don't exit — show merged findings)
901
+ }
902
+ else {
903
+ // No cloud API key — output static findings + KERN IR for the AI assistant
904
+ // The LLM running this command (Claude Code, Cursor, etc.) IS the reviewer
905
+ console.log('\n ── KERN IR for LLM review ──\n');
906
+ for (const report of reports) {
907
+ console.log(`// ── ${report.filePath} ──`);
908
+ console.log(buildLLMPrompt(report.inferred, report.templateMatches, llmGraphContext));
909
+ // Include taint analysis context
910
+ const taintResults = analyzeTaint(report.inferred, report.filePath);
911
+ if (taintResults.length > 0) {
912
+ console.log('\n// Taint analysis:');
913
+ for (const t of taintResults) {
914
+ for (const p of t.paths) {
915
+ const status = p.sanitized ? `SANITIZED (${p.sanitizer})` : 'UNSANITIZED';
916
+ console.log(`// ${t.fnName}: ${p.source.origin} → ${p.sink.name}() [${status}]`);
917
+ }
918
+ }
919
+ }
920
+ console.log('');
921
+ }
922
+ // Show static findings summary
923
+ const totalFindings = reports.reduce((sum, r) => sum + r.findings.length, 0);
924
+ const errors = reports.reduce((sum, r) => sum + r.findings.filter(f => f.severity === 'error').length, 0);
925
+ const warnings = reports.reduce((sum, r) => sum + r.findings.filter(f => f.severity === 'warning').length, 0);
926
+ console.log(` Static analysis: ${totalFindings} findings (${errors} errors, ${warnings} warnings)`);
927
+ console.log(' Review the KERN IR above for security issues the static rules may have missed.');
928
+ console.log('');
929
+ }
930
+ // Fall through to normal output — show full report with static findings
931
+ }
602
932
  // --fix: auto-migration — write .kern files from template suggestions, verify roundtrip
603
933
  if (fixMode) {
604
934
  let fixed = 0;
@@ -665,30 +995,42 @@ if (args[0] === 'review') {
665
995
  }
666
996
  }
667
997
  if (jsonOutput) {
668
- console.log(JSON.stringify(reports.length === 1 ? reports[0] : reports, null, 2));
998
+ // Include KERN IR + LLM prompt in JSON so the calling AI can review
999
+ const enriched = reports.map(report => {
1000
+ const llmPrompt = buildLLMPrompt(report.inferred, report.templateMatches);
1001
+ const kernIR = exportKernIR(report.inferred, report.templateMatches);
1002
+ return { ...report, kernIR, llmPrompt };
1003
+ });
1004
+ console.log(JSON.stringify(enriched.length === 1 ? enriched[0] : enriched, null, 2));
1005
+ }
1006
+ else if (sarifOutput) {
1007
+ console.log(formatSARIF(reports));
669
1008
  }
670
1009
  else {
671
1010
  for (const report of reports) {
672
1011
  console.log('');
673
- console.log(formatReport(report));
1012
+ console.log(formatReport(report, reviewConfig));
674
1013
  }
675
1014
  if (reports.length > 1) {
676
1015
  console.log('');
677
1016
  console.log(formatSummary(reports));
678
1017
  }
679
1018
  // Enforcement
680
- if (enforce || minCoverage !== undefined) {
1019
+ const hasThresholds = minCoverage !== undefined || maxComplexityArg !== undefined || maxErrorsArg !== undefined || maxWarningsArg !== undefined;
1020
+ if (enforce || hasThresholds) {
681
1021
  console.log('');
682
1022
  let allPassed = true;
683
1023
  for (const report of reports) {
684
1024
  const result = checkEnforcement(report, reviewConfig);
685
1025
  if (!result.passed) {
686
1026
  allPassed = false;
1027
+ console.log(` File: ${report.filePath}`);
687
1028
  console.log(formatEnforcement(result));
1029
+ console.log('');
688
1030
  }
689
1031
  }
690
1032
  if (allPassed) {
691
- console.log(` Enforcement: PASS (all files)`);
1033
+ console.log(` Enforcement: PASS (all files checked against thresholds)`);
692
1034
  }
693
1035
  else {
694
1036
  process.exit(1);
@@ -697,6 +1039,886 @@ if (args[0] === 'review') {
697
1039
  }
698
1040
  process.exit(0);
699
1041
  }
1042
+ // ── kern evolve <dir|file> [options] ──────────────────────────────────
1043
+ if (args[0] === 'evolve' && args[1] !== undefined && !args[1].startsWith('evolve:')) {
1044
+ const evolveInput = args[1];
1045
+ if (!evolveInput || evolveInput.startsWith('--')) {
1046
+ console.error('Usage: kern evolve <dir|file> [--recursive] [--preview] [--min-confidence=N] [--min-support=N] [--json]');
1047
+ process.exit(1);
1048
+ }
1049
+ const evolvePath = resolve(evolveInput);
1050
+ const stat = existsSync(evolvePath) ? statSync(evolvePath) : null;
1051
+ if (!stat) {
1052
+ console.error(`Not found: ${evolveInput}`);
1053
+ process.exit(1);
1054
+ }
1055
+ const recursive = args.includes('--recursive') || args.includes('-r');
1056
+ const preview = args.includes('--preview');
1057
+ const jsonOutput = args.includes('--json');
1058
+ const minConfArg = args.find(a => a.startsWith('--min-confidence='))?.split('=')[1];
1059
+ const minSupportArg = args.find(a => a.startsWith('--min-support='))?.split('=')[1];
1060
+ const enableNodes = args.includes('--nodes') || args.includes('--from-gaps');
1061
+ const evolveOptions = {
1062
+ recursive,
1063
+ preview,
1064
+ enableNodeProposals: enableNodes,
1065
+ thresholds: {
1066
+ ...(minConfArg ? { minConfidence: Number(minConfArg) } : {}),
1067
+ ...(minSupportArg ? { minSupport: Number(minSupportArg) } : {}),
1068
+ },
1069
+ };
1070
+ // Load built-in detectors
1071
+ await loadBuiltinDetectors();
1072
+ console.log(`\n KERN evolve — scanning for template gaps\n`);
1073
+ console.log(` Input: ${relative(process.cwd(), evolvePath) || '.'}`);
1074
+ console.log(` Mode: ${preview ? 'preview (no staging)' : 'detect + stage'}`);
1075
+ console.log('');
1076
+ const result = evolve(evolvePath, evolveOptions);
1077
+ if (jsonOutput) {
1078
+ console.log(JSON.stringify(result, null, 2));
1079
+ }
1080
+ else {
1081
+ console.log(` Gaps detected: ${result.gaps.length}`);
1082
+ if (result.conceptSummary) {
1083
+ console.log(` ${result.conceptSummary.formatted}`);
1084
+ }
1085
+ console.log(` Patterns analyzed: ${result.analyzed.length}`);
1086
+ console.log(` Templates proposed: ${result.proposals.length}`);
1087
+ console.log(` Validated: ${result.validated.filter(v => v.validation.parseOk && v.validation.expansionOk).length}/${result.validated.length}`);
1088
+ if (!preview && result.staged.length > 0) {
1089
+ console.log(` Staged for review: ${result.staged.length}`);
1090
+ console.log(`\n Run 'kern evolve:review --list' to review proposals.`);
1091
+ }
1092
+ if (result.proposals.length > 0 && !jsonOutput) {
1093
+ console.log('\n Proposed templates:');
1094
+ for (const p of result.proposals) {
1095
+ const v = result.validated.find(v => v.proposal.id === p.id);
1096
+ const status = v ? (v.validation.parseOk && v.validation.expansionOk ? '✓' : '✗') : '?';
1097
+ console.log(` ${status} ${p.templateName} (${p.namespace}) — score: ${p.qualityScore.overallScore}, instances: ${p.instanceCount}`);
1098
+ }
1099
+ }
1100
+ // v3: Node proposals
1101
+ if (result.nodeProposals && result.nodeProposals.length > 0 && !jsonOutput) {
1102
+ console.log(`\n Node proposals (v3): ${result.nodeProposals.length}`);
1103
+ for (const np of result.nodeProposals) {
1104
+ const nv = result.nodeValidated?.find(v => v.proposal.id === np.id);
1105
+ const status = nv ? (nv.validation.parseOk && nv.validation.codegenOk ? '✓' : '✗') : '?';
1106
+ console.log(` ${status} ${np.nodeName} — express: ${np.expressibilityScore.overall}, freq: ${np.frequency}, score: ${np.qualityScore}`);
1107
+ }
1108
+ if (result.stagedNodes && result.stagedNodes.length > 0) {
1109
+ console.log(` Staged nodes: ${result.stagedNodes.length}`);
1110
+ }
1111
+ }
1112
+ }
1113
+ console.log('');
1114
+ process.exit(0);
1115
+ }
1116
+ // ── kern evolve:review [options] ─────────────────────────────────────
1117
+ if (args[0] === 'evolve:review') {
1118
+ const listMode = args.includes('--list') || args.length === 1;
1119
+ const approveId = (() => {
1120
+ const eqArg = args.find(a => a.startsWith('--approve='));
1121
+ if (eqArg)
1122
+ return eqArg.split('=')[1];
1123
+ const idx = args.indexOf('--approve');
1124
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
1125
+ })();
1126
+ const rejectId = (() => {
1127
+ const eqArg = args.find(a => a.startsWith('--reject='));
1128
+ if (eqArg)
1129
+ return eqArg.split('=')[1];
1130
+ const idx = args.indexOf('--reject');
1131
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
1132
+ })();
1133
+ const promoteMode = args.includes('--promote');
1134
+ const isLocal = args.includes('--local') || !args.includes('--catalog');
1135
+ if (approveId) {
1136
+ const updated = updateStagedStatus(approveId, 'approved');
1137
+ if (updated) {
1138
+ console.log(` Approved: ${approveId}`);
1139
+ }
1140
+ else {
1141
+ console.error(` Not found: ${approveId}`);
1142
+ process.exit(1);
1143
+ }
1144
+ process.exit(0);
1145
+ }
1146
+ if (rejectId) {
1147
+ const updated = updateStagedStatus(rejectId, 'rejected');
1148
+ if (updated) {
1149
+ console.log(` Rejected: ${rejectId}`);
1150
+ cleanRejected();
1151
+ }
1152
+ else {
1153
+ console.error(` Not found: ${rejectId}`);
1154
+ process.exit(1);
1155
+ }
1156
+ process.exit(0);
1157
+ }
1158
+ if (promoteMode) {
1159
+ if (!isLocal) {
1160
+ console.log(' Catalog promotion is for contributors who want to upstream templates.');
1161
+ console.log(' Use --local (default) to write templates to your project.');
1162
+ process.exit(0);
1163
+ }
1164
+ const promoted = promoteLocal();
1165
+ if (promoted.length === 0) {
1166
+ console.log(' No approved proposals to promote.');
1167
+ }
1168
+ else {
1169
+ console.log(` Promoted ${promoted.length} template(s) to templates/:`);
1170
+ for (const name of promoted) {
1171
+ console.log(` ${name}`);
1172
+ }
1173
+ }
1174
+ process.exit(0);
1175
+ }
1176
+ // Default: list mode
1177
+ const staged = listStaged();
1178
+ if (staged.length === 0) {
1179
+ console.log(' No staged proposals. Run \'kern evolve <dir>\' to detect gaps.');
1180
+ process.exit(0);
1181
+ }
1182
+ console.log(`\n KERN evolve:review — ${staged.length} proposal(s)\n`);
1183
+ for (const s of staged) {
1184
+ console.log(formatSplitView(s));
1185
+ console.log('');
1186
+ }
1187
+ process.exit(0);
1188
+ }
1189
+ // ── kern evolve:discover <dir> [--recursive] [--provider=openai|ollama] [--max-tokens=N] ──
1190
+ if (args[0] === 'evolve:discover') {
1191
+ const discoverInput = args[1];
1192
+ if (!discoverInput || discoverInput.startsWith('--')) {
1193
+ console.error('Usage: kern evolve:discover <dir> [--recursive] [--provider=openai|anthropic|ollama] [--max-tokens=N]');
1194
+ process.exit(1);
1195
+ }
1196
+ const discoverPath = resolve(discoverInput);
1197
+ if (!existsSync(discoverPath)) {
1198
+ console.error(`Not found: ${discoverInput}`);
1199
+ process.exit(1);
1200
+ }
1201
+ const recursive = args.includes('--recursive') || args.includes('-r');
1202
+ const providerArg = args.find(a => a.startsWith('--provider='))?.split('=')[1];
1203
+ const maxTokensArg = args.find(a => a.startsWith('--max-tokens='))?.split('=')[1];
1204
+ const maxTokens = maxTokensArg ? Number(maxTokensArg) : 100000;
1205
+ console.log(`\n KERN evolve:discover — LLM pattern discovery\n`);
1206
+ console.log(` Input: ${relative(process.cwd(), discoverPath) || '.'}`);
1207
+ // Collect files and batch
1208
+ const tsFiles = collectTsFiles(discoverPath, recursive);
1209
+ console.log(` Files found: ${tsFiles.length}`);
1210
+ if (tsFiles.length === 0) {
1211
+ console.log(' No TypeScript files to analyze.');
1212
+ process.exit(0);
1213
+ }
1214
+ const batches = selectRepresentativeFiles(tsFiles);
1215
+ console.log(` Batches: ${batches.length} (sampling representative files)`);
1216
+ // Load existing evolved keywords for dedup
1217
+ const manifest = readEvolvedManifest();
1218
+ const evolvedKeywords = manifest ? Object.keys(manifest.nodes) : [];
1219
+ // Create LLM provider
1220
+ let provider;
1221
+ try {
1222
+ provider = createLLMProvider({ provider: providerArg });
1223
+ }
1224
+ catch (err) {
1225
+ console.error(` ${err.message}`);
1226
+ process.exit(1);
1227
+ }
1228
+ console.log(` Provider: ${provider.name}`);
1229
+ const budget = new TokenBudget(maxTokens);
1230
+ const allProposals = [];
1231
+ const runId = `run-${Date.now()}`;
1232
+ for (let i = 0; i < batches.length; i++) {
1233
+ if (budget.exhausted) {
1234
+ console.log(` Token budget exhausted (${budget}). Stopping.`);
1235
+ break;
1236
+ }
1237
+ const batch = batches[i];
1238
+ const files = batch.map(fp => ({
1239
+ path: relative(process.cwd(), fp),
1240
+ content: readFileSync(fp, 'utf-8'),
1241
+ }));
1242
+ const prompt = buildDiscoveryPrompt(files, NODE_TYPES, evolvedKeywords);
1243
+ budget.add(estimateTokens(prompt));
1244
+ console.log(` Batch ${i + 1}/${batches.length}: ${files.map(f => f.path).join(', ')}`);
1245
+ try {
1246
+ const response = await provider.complete(prompt);
1247
+ budget.add(estimateTokens(response));
1248
+ const proposals = parseDiscoveryResponse(response, runId);
1249
+ allProposals.push(...proposals);
1250
+ if (proposals.length > 0) {
1251
+ console.log(` → ${proposals.length} pattern(s) found: ${proposals.map(p => p.keyword).join(', ')}`);
1252
+ }
1253
+ }
1254
+ catch (err) {
1255
+ console.error(` → Error: ${err.message}`);
1256
+ }
1257
+ }
1258
+ // Dedup across batches
1259
+ const seen = new Set();
1260
+ const uniqueProposals = allProposals.filter(p => {
1261
+ if (seen.has(p.keyword))
1262
+ return false;
1263
+ seen.add(p.keyword);
1264
+ return true;
1265
+ });
1266
+ console.log(`\n Discovery complete.`);
1267
+ console.log(` Tokens used: ${budget}`);
1268
+ console.log(` Proposals: ${uniqueProposals.length}\n`);
1269
+ // Validate and stage proposals (with LLM retry on failure, max 2 retries)
1270
+ const existingKw = [...NODE_TYPES, ...evolvedKeywords];
1271
+ let stagedCount = 0;
1272
+ const maxRetries = 2;
1273
+ for (let pi = 0; pi < uniqueProposals.length; pi++) {
1274
+ let proposal = uniqueProposals[pi];
1275
+ // Assign ID if missing
1276
+ if (!proposal.id) {
1277
+ proposal.id = `${proposal.keyword}-${Date.now()}`;
1278
+ }
1279
+ let validation = validateEvolveProposal(proposal, existingKw);
1280
+ let allOk = validation.schemaOk && validation.keywordOk && validation.parseOk && validation.codegenCompileOk && validation.codegenRunOk;
1281
+ // Retry on fixable failures (parse, codegen, typescript, golden diff)
1282
+ const isFixable = validation.schemaOk && validation.keywordOk && !allOk;
1283
+ if (!allOk && isFixable && provider) {
1284
+ for (let retry = 1; retry <= maxRetries; retry++) {
1285
+ console.log(` \u21BB ${proposal.keyword} — retry ${retry}/${maxRetries} (feeding errors to LLM)`);
1286
+ try {
1287
+ const retryPrompt = buildRetryPrompt(proposal, validation.errors);
1288
+ budget.add(estimateTokens(retryPrompt));
1289
+ const retryResponse = await provider.complete(retryPrompt);
1290
+ if (!retryResponse || typeof retryResponse !== 'string')
1291
+ throw new Error('LLM returned empty or invalid response');
1292
+ budget.add(estimateTokens(retryResponse));
1293
+ // Parse retry response as single object
1294
+ let json = retryResponse.trim();
1295
+ const fenceMatch = json.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
1296
+ if (fenceMatch)
1297
+ json = fenceMatch[1].trim();
1298
+ const objStart = json.indexOf('{');
1299
+ const objEnd = json.lastIndexOf('}');
1300
+ if (objStart !== -1 && objEnd > objStart)
1301
+ json = json.slice(objStart, objEnd + 1);
1302
+ const fixed = JSON.parse(json);
1303
+ if (typeof fixed !== 'object' || fixed === null)
1304
+ throw new Error('LLM retry response is not a JSON object');
1305
+ // Merge fixes into proposal
1306
+ if (typeof fixed.kernExample === 'string')
1307
+ proposal.kernExample = fixed.kernExample;
1308
+ if (typeof fixed.expectedOutput === 'string')
1309
+ proposal.expectedOutput = fixed.expectedOutput;
1310
+ if (typeof fixed.codegenSource === 'string')
1311
+ proposal.codegenSource = fixed.codegenSource;
1312
+ validation = validateEvolveProposal(proposal, existingKw);
1313
+ allOk = validation.schemaOk && validation.keywordOk && validation.parseOk && validation.codegenCompileOk && validation.codegenRunOk;
1314
+ if (allOk)
1315
+ break;
1316
+ }
1317
+ catch (e) {
1318
+ console.error(` Retry ${retry} failed: ${e.message}`);
1319
+ }
1320
+ }
1321
+ }
1322
+ const status = allOk ? '\u2713' : '\u2717';
1323
+ console.log(` ${status} ${proposal.keyword} — ${proposal.reason.observation}`);
1324
+ if (validation.errors.length > 0) {
1325
+ for (const err of validation.errors.slice(0, 3)) {
1326
+ console.log(` ${err}`);
1327
+ }
1328
+ }
1329
+ // Stage proposals for review (including failed ones — user can inspect)
1330
+ validation.retryCount = allOk ? 0 : maxRetries;
1331
+ stageEvolveV4Proposal(proposal, validation);
1332
+ stagedCount++;
1333
+ }
1334
+ if (stagedCount > 0) {
1335
+ console.log(`\n Staged ${stagedCount} proposal(s).`);
1336
+ console.log(` Run 'kern evolve:review-v4' to review and graduate proposals.`);
1337
+ }
1338
+ console.log('');
1339
+ process.exit(0);
1340
+ }
1341
+ // ── kern evolve:review-v4 [--list] [--approve=<id>] [--reject=<id>] [--detail=<id>] ──
1342
+ if (args[0] === 'evolve:review-v4') {
1343
+ const approveV4Id = (() => {
1344
+ const eqArg = args.find(a => a.startsWith('--approve='));
1345
+ if (eqArg)
1346
+ return eqArg.split('=')[1];
1347
+ const idx = args.indexOf('--approve');
1348
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
1349
+ })();
1350
+ const rejectV4Id = (() => {
1351
+ const eqArg = args.find(a => a.startsWith('--reject='));
1352
+ if (eqArg)
1353
+ return eqArg.split('=')[1];
1354
+ const idx = args.indexOf('--reject');
1355
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
1356
+ })();
1357
+ const detailV4Id = (() => {
1358
+ const eqArg = args.find(a => a.startsWith('--detail='));
1359
+ if (eqArg)
1360
+ return eqArg.split('=')[1];
1361
+ const idx = args.indexOf('--detail');
1362
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
1363
+ })();
1364
+ if (approveV4Id) {
1365
+ // Approve → validate → compile → graduate
1366
+ const staged = getStagedEvolveV4(approveV4Id);
1367
+ if (!staged) {
1368
+ console.error(` Not found: ${approveV4Id}`);
1369
+ process.exit(1);
1370
+ }
1371
+ const { proposal, validation } = staged;
1372
+ const allOk = validation.schemaOk && validation.keywordOk && validation.parseOk && validation.codegenCompileOk && validation.codegenRunOk;
1373
+ if (!allOk) {
1374
+ console.error(` Cannot approve — validation failed for '${proposal.keyword}':`);
1375
+ for (const err of validation.errors) {
1376
+ console.error(` ${err}`);
1377
+ }
1378
+ process.exit(1);
1379
+ }
1380
+ // Compile codegen source to JS
1381
+ let compiledJs;
1382
+ try {
1383
+ compiledJs = compileCodegenToJS(proposal.codegenSource);
1384
+ }
1385
+ catch (err) {
1386
+ console.error(` Failed to compile codegen for '${proposal.keyword}': ${err.message}`);
1387
+ process.exit(1);
1388
+ }
1389
+ // Graduate the node
1390
+ const result = graduateNode(proposal, compiledJs);
1391
+ if (result.success) {
1392
+ updateStagedEvolveV4Status(approveV4Id, 'approved');
1393
+ cleanApprovedEvolveV4(approveV4Id);
1394
+ console.log(` Graduated '${proposal.keyword}' → ${result.path}`);
1395
+ console.log(` The node is now available in kern compile and kern dev.`);
1396
+ }
1397
+ else {
1398
+ console.error(` Graduation failed: ${result.error}`);
1399
+ process.exit(1);
1400
+ }
1401
+ process.exit(0);
1402
+ }
1403
+ if (rejectV4Id) {
1404
+ const updated = updateStagedEvolveV4Status(rejectV4Id, 'rejected');
1405
+ if (updated) {
1406
+ console.log(` Rejected: ${updated.proposal.keyword} (${rejectV4Id})`);
1407
+ cleanRejectedEvolveV4();
1408
+ }
1409
+ else {
1410
+ console.error(` Not found: ${rejectV4Id}`);
1411
+ process.exit(1);
1412
+ }
1413
+ process.exit(0);
1414
+ }
1415
+ if (detailV4Id) {
1416
+ const staged = getStagedEvolveV4(detailV4Id);
1417
+ if (!staged) {
1418
+ console.error(` Not found: ${detailV4Id}`);
1419
+ process.exit(1);
1420
+ }
1421
+ const { proposal, validation } = staged;
1422
+ console.log(`\n DETAIL: ${proposal.keyword} (${proposal.displayName})\n`);
1423
+ console.log(` Description: ${proposal.description}`);
1424
+ console.log(` Props: ${proposal.props.map(p => `${p.name}:${p.type}${p.required ? '*' : ''}`).join(', ')}`);
1425
+ console.log(` Child types: ${proposal.childTypes.join(', ') || '(none)'}`);
1426
+ console.log(` Codegen tier: ${proposal.codegenTier}`);
1427
+ console.log(` Run ID: ${proposal.evolveRunId}`);
1428
+ if (proposal.parserHints) {
1429
+ console.log(` Parser hints: ${JSON.stringify(proposal.parserHints)}`);
1430
+ }
1431
+ console.log(`\n --- Codegen Source ---`);
1432
+ console.log(proposal.codegenSource);
1433
+ console.log(` --- Instances (${proposal.reason.instances.length}) ---`);
1434
+ for (const inst of proposal.reason.instances.slice(0, 10)) {
1435
+ console.log(` ${inst}`);
1436
+ }
1437
+ if (validation.errors.length > 0) {
1438
+ console.log(`\n --- Validation Errors ---`);
1439
+ for (const err of validation.errors) {
1440
+ console.log(` ${err}`);
1441
+ }
1442
+ }
1443
+ console.log('');
1444
+ process.exit(0);
1445
+ }
1446
+ // Default: interactive review or list mode
1447
+ const stagedV4 = listStagedEvolveV4();
1448
+ const pendingV4 = stagedV4.filter(s => s.status === 'pending');
1449
+ if (pendingV4.length === 0) {
1450
+ console.log(' No pending v4 proposals. Run \'kern evolve:discover <dir>\' to find patterns.');
1451
+ process.exit(0);
1452
+ }
1453
+ const listOnly = args.includes('--list');
1454
+ if (listOnly) {
1455
+ console.log(`\n KERN evolve:review-v4 — ${pendingV4.length} proposal(s)\n`);
1456
+ for (const s of pendingV4) {
1457
+ console.log(formatEvolveV4SplitView(s));
1458
+ console.log('');
1459
+ }
1460
+ process.exit(0);
1461
+ }
1462
+ // Interactive review — walk through each proposal
1463
+ const { createInterface } = await import('readline');
1464
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1465
+ const ask = (q) => new Promise(res => rl.question(q, res));
1466
+ console.log(`\n KERN evolve:review-v4 — ${pendingV4.length} proposal(s)\n`);
1467
+ for (const staged of pendingV4) {
1468
+ console.log(formatEvolveV4SplitView(staged));
1469
+ console.log('');
1470
+ let decided = false;
1471
+ while (!decided) {
1472
+ const answer = (await ask(' [a]pprove [r]eject [s]kip [d]etail [q]uit > ')).trim().toLowerCase();
1473
+ if (answer === 'a' || answer === 'approve') {
1474
+ const { proposal, validation } = staged;
1475
+ const allOk = validation.schemaOk && validation.keywordOk && validation.parseOk && validation.codegenCompileOk && validation.codegenRunOk;
1476
+ if (!allOk) {
1477
+ console.log(` Cannot approve — validation failed. Use [d]etail to see errors.\n`);
1478
+ continue;
1479
+ }
1480
+ try {
1481
+ const compiledJs = compileCodegenToJS(proposal.codegenSource);
1482
+ const result = graduateNode(proposal, compiledJs);
1483
+ if (result.success) {
1484
+ updateStagedEvolveV4Status(staged.id, 'approved');
1485
+ cleanApprovedEvolveV4(staged.id);
1486
+ console.log(` \u2713 Graduated '${proposal.keyword}'\n`);
1487
+ }
1488
+ else {
1489
+ console.log(` Graduation failed: ${result.error}\n`);
1490
+ }
1491
+ }
1492
+ catch (err) {
1493
+ console.log(` Error: ${err.message}\n`);
1494
+ }
1495
+ decided = true;
1496
+ }
1497
+ else if (answer === 'r' || answer === 'reject') {
1498
+ updateStagedEvolveV4Status(staged.id, 'rejected');
1499
+ cleanRejectedEvolveV4();
1500
+ console.log(` \u2717 Rejected '${staged.proposal.keyword}'\n`);
1501
+ decided = true;
1502
+ }
1503
+ else if (answer === 's' || answer === 'skip') {
1504
+ console.log(` Skipped.\n`);
1505
+ decided = true;
1506
+ }
1507
+ else if (answer === 'd' || answer === 'detail') {
1508
+ const { proposal, validation } = staged;
1509
+ console.log(`\n --- Codegen Source ---`);
1510
+ console.log(proposal.codegenSource);
1511
+ console.log(` --- Instances (${proposal.reason.instances.length}) ---`);
1512
+ for (const inst of proposal.reason.instances.slice(0, 5)) {
1513
+ console.log(` ${inst}`);
1514
+ }
1515
+ if (validation.errors.length > 0) {
1516
+ console.log(` --- Errors ---`);
1517
+ for (const err of validation.errors) {
1518
+ console.log(` ${err}`);
1519
+ }
1520
+ }
1521
+ console.log('');
1522
+ }
1523
+ else if (answer === 'q' || answer === 'quit') {
1524
+ rl.close();
1525
+ process.exit(0);
1526
+ }
1527
+ }
1528
+ }
1529
+ rl.close();
1530
+ console.log(' Review complete.\n');
1531
+ process.exit(0);
1532
+ }
1533
+ // ── kern evolve:test ─────────────────────────────────────────────────
1534
+ if (args[0] === 'evolve:test') {
1535
+ console.log('\n KERN evolve:test — golden test runner\n');
1536
+ const results = runGoldenTests();
1537
+ console.log(formatGoldenTestResults(results));
1538
+ const failed = results.filter(r => !r.pass).length;
1539
+ console.log('');
1540
+ process.exit(failed > 0 ? 1 : 0);
1541
+ }
1542
+ // ── kern evolve:rollback <keyword> [--force] ─────────────────────────
1543
+ if (args[0] === 'evolve:rollback') {
1544
+ const keyword = args[1];
1545
+ if (!keyword || keyword.startsWith('--')) {
1546
+ console.error('Usage: kern evolve:rollback <keyword> [--force]');
1547
+ process.exit(1);
1548
+ }
1549
+ const force = args.includes('--force');
1550
+ const result = rollbackNode(keyword, process.cwd(), force);
1551
+ if (result.success) {
1552
+ console.log(` Rolled back '${keyword}' (moved to .trash/).`);
1553
+ console.log(` Restore with: kern evolve:restore ${keyword}`);
1554
+ }
1555
+ else {
1556
+ console.error(` Failed: ${result.error}`);
1557
+ if (result.usageFiles) {
1558
+ console.error(' Used in:');
1559
+ for (const f of result.usageFiles.slice(0, 5)) {
1560
+ console.error(` ${relative(process.cwd(), f)}`);
1561
+ }
1562
+ }
1563
+ process.exit(1);
1564
+ }
1565
+ process.exit(0);
1566
+ }
1567
+ // ── kern evolve:restore <keyword> ────────────────────────────────────
1568
+ if (args[0] === 'evolve:restore') {
1569
+ const keyword = args[1];
1570
+ if (!keyword) {
1571
+ console.error('Usage: kern evolve:restore <keyword>');
1572
+ process.exit(1);
1573
+ }
1574
+ const result = restoreNode(keyword);
1575
+ if (result.success) {
1576
+ console.log(` Restored '${keyword}'.`);
1577
+ }
1578
+ else {
1579
+ console.error(` Failed: ${result.error}`);
1580
+ process.exit(1);
1581
+ }
1582
+ process.exit(0);
1583
+ }
1584
+ // ── kern evolve:list ─────────────────────────────────────────────────
1585
+ if (args[0] === 'evolve:list') {
1586
+ const manifest = readEvolvedManifest();
1587
+ if (!manifest || Object.keys(manifest.nodes).length === 0) {
1588
+ console.log('\n No evolved nodes graduated. Run \'kern evolve:discover\' to start.\n');
1589
+ process.exit(0);
1590
+ }
1591
+ console.log(`\n KERN evolved nodes — ${Object.keys(manifest.nodes).length} graduated\n`);
1592
+ for (const [keyword, entry] of Object.entries(manifest.nodes)) {
1593
+ console.log(` ${keyword} — ${entry.displayName} (graduated ${entry.graduatedAt.split('T')[0]} by ${entry.graduatedBy})`);
1594
+ }
1595
+ console.log('');
1596
+ process.exit(0);
1597
+ }
1598
+ // ── kern evolve:promote <keyword> ────────────────────────────────────
1599
+ if (args[0] === 'evolve:promote') {
1600
+ const promoteKeyword = args[1];
1601
+ if (!promoteKeyword || promoteKeyword.startsWith('--')) {
1602
+ console.error('Usage: kern evolve:promote <keyword>');
1603
+ console.error(' Reads codegen from .kern/evolved/<keyword>/ and outputs what to add to core.');
1604
+ process.exit(1);
1605
+ }
1606
+ const result = promoteNode(promoteKeyword);
1607
+ if (!result.success) {
1608
+ console.error(` Failed: ${result.error}`);
1609
+ process.exit(1);
1610
+ }
1611
+ const fnName = 'generate' + promoteKeyword.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join('');
1612
+ console.log(`\n KERN evolve:promote — ${promoteKeyword}\n`);
1613
+ console.log(' To promote this node to core, apply these changes:\n');
1614
+ console.log(` 1. Add '${promoteKeyword}' to NODE_TYPES in packages/core/src/spec.ts`);
1615
+ console.log(` 2. Create packages/core/src/generators/${fnName}.ts with:`);
1616
+ console.log(` ──────────────────────────────`);
1617
+ for (const line of (result.codegenTs || '').split('\n').slice(0, 20)) {
1618
+ console.log(` ${line}`);
1619
+ }
1620
+ if ((result.codegenTs || '').split('\n').length > 20) {
1621
+ console.log(` ... (${(result.codegenTs || '').split('\n').length - 20} more lines)`);
1622
+ }
1623
+ console.log(` ──────────────────────────────`);
1624
+ console.log(` 3. Add case '${promoteKeyword}': return ${fnName}(node); to generateCoreNode() in codegen-core.ts`);
1625
+ if (result.goldenKern) {
1626
+ console.log(` 4. Move golden test to packages/core/tests/`);
1627
+ }
1628
+ console.log(` 5. Run: kern evolve:rollback ${promoteKeyword} --force`);
1629
+ console.log('');
1630
+ process.exit(0);
1631
+ }
1632
+ // ── kern evolve:backfill <keyword> --target=<target> [--provider=...] ──
1633
+ if (args[0] === 'evolve:backfill') {
1634
+ const backfillKeyword = args[1];
1635
+ if (!backfillKeyword || backfillKeyword.startsWith('--')) {
1636
+ console.error('Usage: kern evolve:backfill <keyword> --target=<target> [--provider=openai|anthropic|ollama]');
1637
+ process.exit(1);
1638
+ }
1639
+ const backfillTarget = args.find(a => a.startsWith('--target='))?.split('=')[1];
1640
+ if (!backfillTarget) {
1641
+ console.error(' --target=<target> is required');
1642
+ process.exit(1);
1643
+ }
1644
+ const def = readNodeDefinition(backfillKeyword);
1645
+ if (!def) {
1646
+ console.error(` Node '${backfillKeyword}' is not graduated.`);
1647
+ process.exit(1);
1648
+ }
1649
+ // Read current codegen source
1650
+ const codegenTsPath = resolve('.kern', 'evolved', backfillKeyword, 'codegen.ts');
1651
+ if (!existsSync(codegenTsPath)) {
1652
+ console.error(` Missing codegen.ts for '${backfillKeyword}'`);
1653
+ process.exit(1);
1654
+ }
1655
+ const codegenSource = readFileSync(codegenTsPath, 'utf-8');
1656
+ const templateKernPath = resolve('.kern', 'evolved', backfillKeyword, 'template.kern');
1657
+ const kernExample = existsSync(templateKernPath) ? readFileSync(templateKernPath, 'utf-8') : '';
1658
+ const expectedOutputPath = resolve('.kern', 'evolved', backfillKeyword, 'expected-output.ts');
1659
+ const expectedOutput = existsSync(expectedOutputPath) ? readFileSync(expectedOutputPath, 'utf-8') : '';
1660
+ console.log(`\n KERN evolve:backfill — ${backfillKeyword} → ${backfillTarget}\n`);
1661
+ const providerArg = args.find(a => a.startsWith('--provider='))?.split('=')[1];
1662
+ let provider;
1663
+ try {
1664
+ provider = createLLMProvider({ provider: providerArg });
1665
+ }
1666
+ catch (err) {
1667
+ console.error(` ${err.message}`);
1668
+ process.exit(1);
1669
+ }
1670
+ console.log(` Provider: ${provider.name}`);
1671
+ const prompt = buildBackfillPrompt(backfillKeyword, {
1672
+ props: def.props,
1673
+ childTypes: def.childTypes,
1674
+ kernExample,
1675
+ codegenSource,
1676
+ expectedOutput,
1677
+ }, backfillTarget);
1678
+ try {
1679
+ const response = await provider.complete(prompt);
1680
+ // Parse JSON response
1681
+ let json = response.trim();
1682
+ const fenceMatch = json.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
1683
+ if (fenceMatch)
1684
+ json = fenceMatch[1].trim();
1685
+ const objStart = json.indexOf('{');
1686
+ const objEnd = json.lastIndexOf('}');
1687
+ if (objStart !== -1 && objEnd > objStart)
1688
+ json = json.slice(objStart, objEnd + 1);
1689
+ const parsed = JSON.parse(json);
1690
+ if (typeof parsed !== 'object' || parsed === null) {
1691
+ console.error(' LLM response is not a JSON object');
1692
+ process.exit(1);
1693
+ }
1694
+ const targetCodegen = typeof parsed.codegenSource === 'string' ? parsed.codegenSource : undefined;
1695
+ if (!targetCodegen) {
1696
+ console.error(' LLM did not return codegenSource');
1697
+ process.exit(1);
1698
+ }
1699
+ // Write target override
1700
+ const targetsDir = resolve('.kern', 'evolved', backfillKeyword, 'targets');
1701
+ mkdirSync(targetsDir, { recursive: true });
1702
+ writeFileSync(resolve(targetsDir, `${backfillTarget}.js`), targetCodegen);
1703
+ console.log(` Written: .kern/evolved/${backfillKeyword}/targets/${backfillTarget}.js`);
1704
+ if (parsed.expectedOutput) {
1705
+ console.log(` Expected output preview:`);
1706
+ for (const line of parsed.expectedOutput.split('\n').slice(0, 10)) {
1707
+ console.log(` ${line}`);
1708
+ }
1709
+ }
1710
+ console.log(`\n Review the generated codegen before using in production.`);
1711
+ }
1712
+ catch (err) {
1713
+ console.error(` Error: ${err.message}`);
1714
+ process.exit(1);
1715
+ }
1716
+ console.log('');
1717
+ process.exit(0);
1718
+ }
1719
+ // ── kern evolve:prune [--dry-run] [--days=N] ─────────────────────────
1720
+ if (args[0] === 'evolve:prune') {
1721
+ const dryRun = args.includes('--dry-run');
1722
+ const daysArg = args.find(a => a.startsWith('--days='))?.split('=')[1];
1723
+ const thresholdDays = daysArg ? Number(daysArg) : 90;
1724
+ console.log(`\n KERN evolve:prune — removing unused nodes (>${thresholdDays}d)\n`);
1725
+ const results = pruneNodes(process.cwd(), thresholdDays, dryRun);
1726
+ if (results.length === 0) {
1727
+ console.log(' No nodes eligible for pruning.');
1728
+ process.exit(0);
1729
+ }
1730
+ for (const r of results) {
1731
+ if (dryRun) {
1732
+ console.log(` Would prune: ${r.keyword} (${r.daysUnused}d unused)`);
1733
+ }
1734
+ else if (r.pruned) {
1735
+ console.log(` Pruned: ${r.keyword} (${r.daysUnused}d unused) → .trash/`);
1736
+ }
1737
+ else {
1738
+ console.log(` Failed: ${r.keyword} — ${r.error}`);
1739
+ }
1740
+ }
1741
+ if (dryRun) {
1742
+ console.log(`\n Dry run — no changes made. Remove --dry-run to prune.`);
1743
+ }
1744
+ console.log('');
1745
+ process.exit(0);
1746
+ }
1747
+ // ── kern evolve:migrate ──────────────────────────────────────────────
1748
+ if (args[0] === 'evolve:migrate') {
1749
+ console.log(`\n KERN evolve:migrate — checking for keyword collisions\n`);
1750
+ const collisions = detectCollisions(NODE_TYPES);
1751
+ if (collisions.length === 0) {
1752
+ console.log(' No collisions. All evolved nodes are compatible with core.');
1753
+ process.exit(0);
1754
+ }
1755
+ console.log(` Found ${collisions.length} collision(s):\n`);
1756
+ for (const c of collisions) {
1757
+ console.log(` ${c.keyword} (graduated ${c.graduatedAt.split('T')[0]})`);
1758
+ console.log(` This keyword now exists in core NODE_TYPES.`);
1759
+ console.log(` Options:`);
1760
+ console.log(` kern evolve:migrate --rename=${c.keyword} --to=<new-name>`);
1761
+ console.log(` kern evolve:migrate --remove=${c.keyword} (core version supersedes)`);
1762
+ console.log('');
1763
+ }
1764
+ // Handle --rename and --remove flags
1765
+ const renameArg = args.find(a => a.startsWith('--rename='))?.split('=')[1];
1766
+ const toArg = args.find(a => a.startsWith('--to='))?.split('=')[1];
1767
+ const removeArg = args.find(a => a.startsWith('--remove='))?.split('=')[1];
1768
+ if (renameArg && toArg) {
1769
+ const result = renameEvolvedNode(renameArg, toArg);
1770
+ if (result.success) {
1771
+ console.log(` Renamed '${renameArg}' → '${toArg}'`);
1772
+ console.log(` Update your .kern files: replace '${renameArg}' with '${toArg}'.`);
1773
+ }
1774
+ else {
1775
+ console.error(` Rename failed: ${result.error}`);
1776
+ process.exit(1);
1777
+ }
1778
+ }
1779
+ if (removeArg) {
1780
+ const result = rollbackNode(removeArg, process.cwd(), true);
1781
+ if (result.success) {
1782
+ console.log(` Removed evolved '${removeArg}' — core version will be used.`);
1783
+ }
1784
+ else {
1785
+ console.error(` Remove failed: ${result.error}`);
1786
+ process.exit(1);
1787
+ }
1788
+ }
1789
+ console.log('');
1790
+ process.exit(0);
1791
+ }
1792
+ // ── kern evolve:rebuild ─────────────────────────────────────────────
1793
+ if (args[0] === 'evolve:rebuild') {
1794
+ console.log(`\n KERN evolve:rebuild — rebuilding manifest from disk\n`);
1795
+ const result = rebuildEvolvedManifest();
1796
+ if (result.errors.length > 0) {
1797
+ for (const err of result.errors) {
1798
+ console.log(` ⚠ ${err}`);
1799
+ }
1800
+ console.log('');
1801
+ }
1802
+ if (result.rebuilt === 0 && result.errors.length > 0) {
1803
+ console.error(' No nodes rebuilt.');
1804
+ process.exit(1);
1805
+ }
1806
+ console.log(` manifest.json rebuilt with ${result.rebuilt} node(s).`);
1807
+ console.log('');
1808
+ process.exit(0);
1809
+ }
1810
+ // ── kern confidence <file.kern|dir> ──────────────────────────────────
1811
+ if (args[0] === 'confidence') {
1812
+ const confInput = args[1];
1813
+ if (!confInput) {
1814
+ console.error('Usage: kern confidence <file.kern|dir>');
1815
+ console.error(' Builds and displays the confidence graph for .kern file(s).');
1816
+ process.exit(1);
1817
+ }
1818
+ const confPath = resolve(confInput);
1819
+ if (!existsSync(confPath)) {
1820
+ console.error(`Not found: ${confInput}`);
1821
+ process.exit(1);
1822
+ }
1823
+ const { buildConfidenceGraph, buildMultiFileConfidenceGraph, flattenIR, lintMultiFileConfidenceGraph } = await import('@kernlang/review');
1824
+ const confStat = statSync(confPath);
1825
+ const isDir = confStat.isDirectory();
1826
+ // Collect .kern files
1827
+ const kernFiles = isDir ? findKernFiles(confPath) : [confPath];
1828
+ if (kernFiles.length === 0) {
1829
+ console.log(' No .kern files found.');
1830
+ process.exit(0);
1831
+ }
1832
+ // Parse all files, skip those without confidence (fast early exit)
1833
+ const fileMap = new Map();
1834
+ for (const file of kernFiles.sort()) {
1835
+ const source = readFileSync(file, 'utf-8');
1836
+ if (!source.includes('confidence='))
1837
+ continue;
1838
+ const ast = parse(source);
1839
+ fileMap.set(file, flattenIR(ast));
1840
+ }
1841
+ if (fileMap.size === 0) {
1842
+ console.log(`\n No confidence declarations found in ${isDir ? confInput : basename(confInput)}`);
1843
+ process.exit(0);
1844
+ }
1845
+ // Build graph (single-file or multi-file)
1846
+ const graph = fileMap.size === 1
1847
+ ? buildConfidenceGraph([...fileMap.values()][0])
1848
+ : buildMultiFileConfidenceGraph(fileMap);
1849
+ const isMulti = fileMap.size > 1;
1850
+ console.log(`\n Confidence Graph (${graph.nodes.size} nodes, ${graph.topoOrder.length} resolved${isMulti ? `, ${fileMap.size} files` : ''}):\n`);
1851
+ if (isMulti) {
1852
+ // Group by source file
1853
+ const byFile = new Map();
1854
+ for (const cnode of graph.nodes.values()) {
1855
+ const file = cnode.sourceFile || 'unknown';
1856
+ if (!byFile.has(file))
1857
+ byFile.set(file, []);
1858
+ byFile.get(file).push(cnode);
1859
+ }
1860
+ for (const [file, nodes] of byFile) {
1861
+ const rel = relative(process.cwd(), file) || file;
1862
+ console.log(` ${rel} (${nodes.length} nodes):`);
1863
+ for (const cnode of nodes) {
1864
+ const resolvedStr = cnode.resolved !== null ? cnode.resolved.toFixed(2) : 'null';
1865
+ const specStr = cnode.spec.kind === 'literal'
1866
+ ? 'declared'
1867
+ : `from: ${cnode.spec.sources?.join(', ')}, ${cnode.spec.strategy}`;
1868
+ const crossFile = cnode.spec.sources?.some(s => {
1869
+ const src = graph.nodes.get(s);
1870
+ return src && src.sourceFile !== cnode.sourceFile;
1871
+ });
1872
+ const crossTag = crossFile ? ' [cross-file]' : '';
1873
+ const cycleTag = cnode.inCycle ? ' [CYCLE]' : '';
1874
+ console.log(` ${cnode.name.padEnd(20)} ${resolvedStr.padEnd(8)} (${specStr})${crossTag}${cycleTag}`);
1875
+ }
1876
+ console.log('');
1877
+ }
1878
+ }
1879
+ else {
1880
+ for (const [name, cnode] of graph.nodes) {
1881
+ const resolvedStr = cnode.resolved !== null ? cnode.resolved.toFixed(2) : 'null';
1882
+ const specStr = cnode.spec.kind === 'literal'
1883
+ ? 'declared'
1884
+ : `from: ${cnode.spec.sources?.join(', ')}, ${cnode.spec.strategy}`;
1885
+ const cycleTag = cnode.inCycle ? ' [CYCLE]' : '';
1886
+ console.log(` ${name.padEnd(20)} ${resolvedStr.padEnd(8)} (${specStr})${cycleTag}`);
1887
+ }
1888
+ }
1889
+ // Unresolved needs
1890
+ const unresolvedNeeds = [];
1891
+ for (const [name, cnode] of graph.nodes) {
1892
+ for (const need of cnode.needs) {
1893
+ if (!need.resolved) {
1894
+ unresolvedNeeds.push({ name, what: need.what, wouldRaiseTo: need.wouldRaiseTo });
1895
+ }
1896
+ }
1897
+ }
1898
+ if (unresolvedNeeds.length > 0) {
1899
+ console.log(` Unresolved needs (${unresolvedNeeds.length}):`);
1900
+ for (const n of unresolvedNeeds) {
1901
+ const raise = n.wouldRaiseTo !== undefined ? ` → would raise to ${n.wouldRaiseTo}` : '';
1902
+ console.log(` ${n.name}: "${n.what}"${raise}`);
1903
+ }
1904
+ }
1905
+ if (graph.cycles.length > 0) {
1906
+ console.log(`\n Cycles (${graph.cycles.length}):`);
1907
+ for (const cycle of graph.cycles) {
1908
+ console.log(` ${cycle.join(' → ')}`);
1909
+ }
1910
+ }
1911
+ // Duplicates (multi-file only)
1912
+ const dupes = 'duplicates' in graph ? graph.duplicates : [];
1913
+ if (dupes.length > 0) {
1914
+ console.log(`\n Duplicate names (${dupes.length}):`);
1915
+ for (const dup of dupes) {
1916
+ console.log(` ${dup.name}: ${dup.files.map((f) => relative(process.cwd(), f) || f).join(', ')}`);
1917
+ }
1918
+ }
1919
+ console.log('');
1920
+ process.exit(0);
1921
+ }
700
1922
  // ── Standard transpile mode ────────────────────────────────────────────
701
1923
  const inputFile = args.find(a => !a.startsWith('--'));
702
1924
  if (!inputFile) {
@@ -707,7 +1929,16 @@ if (!inputFile) {
707
1929
  console.log(' compile <dir|file> --outdir=<dir> Compile .kern → .ts (core nodes)');
708
1930
  console.log(' scan [--force] [--dry-run] Detect project → generate kern.config.ts');
709
1931
  console.log(' init-templates [--force] [--dry-run] Scan deps → scaffold template .kern files');
710
- console.log(' review <file.ts|dir> [options] Analyze TS infer .kern coverage + review');
1932
+ console.log(' review <file.ts|dir> [options] Static analysis, Cognitive Complexity & CI Gate');
1933
+ console.log(' evolve <dir|file> [options] Detect gaps → propose templates');
1934
+ console.log(' evolve:review [options] Review staged template proposals');
1935
+ console.log(' evolve:review-v4 [options] Review & graduate v4 node proposals');
1936
+ console.log(' evolve:promote <keyword> Show steps to move evolved → core');
1937
+ console.log(' evolve:backfill <kw> --target=<t> LLM generates target-specific codegen');
1938
+ console.log(' evolve:prune [--dry-run] [--days=N] Remove unused nodes (default 90d)');
1939
+ console.log(' evolve:migrate Detect & resolve keyword collisions');
1940
+ console.log(' evolve:rebuild Rebuild manifest.json from disk definitions');
1941
+ console.log(' confidence <file.kern> Display confidence graph for a .kern file');
711
1942
  console.log('');
712
1943
  console.log('Targets:');
713
1944
  console.log(' nextjs Next.js App Router (default)');
@@ -754,6 +1985,8 @@ else {
754
1985
  }
755
1986
  // Load templates before transpile
756
1987
  loadTemplates(config);
1988
+ // Load evolved nodes (v4) — graduated nodes from .kern/evolved/
1989
+ loadEvolvedNodes(process.cwd(), args.includes('--verify'));
757
1990
  // CLI flags override config — target
758
1991
  const cliTarget = args.find(a => a.startsWith('--target='))?.split('=')[1];
759
1992
  if (cliTarget) {
@@ -834,15 +2067,19 @@ const result = target === 'native'
834
2067
  ? transpileTailwind(ast, config)
835
2068
  : target === 'express'
836
2069
  ? transpileExpress(ast, config)
837
- : target === 'cli'
838
- ? transpileCliApp(ast, config)
839
- : target === 'terminal'
840
- ? transpileTerminal(ast, config)
841
- : target === 'vue'
842
- ? transpileVue(ast, config)
843
- : target === 'nuxt'
844
- ? transpileNuxt(ast, config)
845
- : transpileNextjs(ast, config);
2070
+ : target === 'fastapi'
2071
+ ? transpileFastAPI(ast, config)
2072
+ : target === 'cli'
2073
+ ? transpileCliApp(ast, config)
2074
+ : target === 'terminal'
2075
+ ? transpileTerminal(ast, config)
2076
+ : target === 'ink'
2077
+ ? transpileInk(ast, config)
2078
+ : target === 'vue'
2079
+ ? transpileVue(ast, config)
2080
+ : target === 'nuxt'
2081
+ ? transpileNuxt(ast, config)
2082
+ : transpileNextjs(ast, config);
846
2083
  const outDir = resolve(dirname(inputFile), config.output.outDir);
847
2084
  const isStructured = config.structure !== 'flat' && result.artifacts && result.artifacts.length > 0;
848
2085
  if (isStructured) {
@@ -859,8 +2096,10 @@ if (isStructured) {
859
2096
  }
860
2097
  else {
861
2098
  // Flat output: single file
862
- const outExt = (target === 'vue' || target === 'nuxt') ? '.vue'
863
- : (target === 'express' || target === 'cli' || target === 'terminal') ? '.ts' : '.tsx';
2099
+ const outExt = target === 'fastapi' ? '.py'
2100
+ : (target === 'vue' || target === 'nuxt') ? '.vue'
2101
+ : (target === 'express' || target === 'cli' || target === 'terminal') ? '.ts'
2102
+ : '.tsx';
864
2103
  const outFile = resolve(outDir, `${name}${outExt}`);
865
2104
  mkdirSync(dirname(outFile), { recursive: true });
866
2105
  writeFileSync(outFile, result.code);
@@ -873,7 +2112,7 @@ else {
873
2112
  }
874
2113
  console.log(`Transpiled: ${inputFile} → ${outFile}`);
875
2114
  }
876
- const targetNames = { native: 'React Native', web: 'React (inline)', tailwind: 'React + Tailwind', nextjs: 'Next.js App Router', express: 'Express TypeScript', cli: 'Commander.js CLI', terminal: 'ANSI Terminal', vue: 'Vue 3 SFC', nuxt: 'Nuxt 3' };
2115
+ const targetNames = { native: 'React Native', web: 'React (inline)', tailwind: 'React + Tailwind', nextjs: 'Next.js App Router', express: 'Express TypeScript', fastapi: 'FastAPI Python', cli: 'Commander.js CLI', terminal: 'ANSI Terminal', ink: 'Ink (React for Terminals)', vue: 'Vue 3 SFC', nuxt: 'Nuxt 3' };
877
2116
  console.log(`Target: ${targetNames[target] || target}`);
878
2117
  if (config.structure !== 'flat') {
879
2118
  const structureNames = { bulletproof: 'Bulletproof React', atomic: 'Atomic Design', kern: 'KERN Native' };
@@ -934,6 +2173,26 @@ function minifyKern(node) {
934
2173
  }
935
2174
  return head;
936
2175
  }
2176
+ function collectTsFilesFlat(dirPath, recursive) {
2177
+ const files = [];
2178
+ for (const entry of readdirSync(dirPath)) {
2179
+ const full = resolve(dirPath, entry);
2180
+ const s = statSync(full);
2181
+ if (s.isDirectory() && recursive && !entry.startsWith('.') && entry !== 'node_modules' && entry !== 'dist') {
2182
+ files.push(...collectTsFilesFlat(full, true));
2183
+ }
2184
+ else if ((entry.endsWith('.ts') || entry.endsWith('.tsx')) && !entry.endsWith('.d.ts') && !entry.endsWith('.test.ts')) {
2185
+ files.push(full);
2186
+ }
2187
+ else if (entry.endsWith('.kern')) {
2188
+ files.push(full);
2189
+ }
2190
+ else if (entry.endsWith('.py') && !entry.startsWith('test_') && !entry.endsWith('_test.py')) {
2191
+ files.push(full);
2192
+ }
2193
+ }
2194
+ return files;
2195
+ }
937
2196
  function prettyKern(node, indent = '') {
938
2197
  const type = node.type;
939
2198
  const props = node.props || {};