@sun-asterisk/sungen 3.1.2-beta.99 → 3.2.0-beta.141

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.
Files changed (192) hide show
  1. package/dist/capabilities/context.d.ts +17 -0
  2. package/dist/capabilities/context.d.ts.map +1 -1
  3. package/dist/capabilities/discover.d.ts.map +1 -1
  4. package/dist/capabilities/discover.js +70 -9
  5. package/dist/capabilities/discover.js.map +1 -1
  6. package/dist/capabilities/registry.d.ts +3 -1
  7. package/dist/capabilities/registry.d.ts.map +1 -1
  8. package/dist/capabilities/registry.js.map +1 -1
  9. package/dist/capabilities/sensor.d.ts +3 -0
  10. package/dist/capabilities/sensor.d.ts.map +1 -1
  11. package/dist/cli/commands/audit.d.ts.map +1 -1
  12. package/dist/cli/commands/audit.js +17 -11
  13. package/dist/cli/commands/audit.js.map +1 -1
  14. package/dist/cli/commands/capability.d.ts.map +1 -1
  15. package/dist/cli/commands/capability.js +57 -5
  16. package/dist/cli/commands/capability.js.map +1 -1
  17. package/dist/cli/commands/context.d.ts +9 -0
  18. package/dist/cli/commands/context.d.ts.map +1 -0
  19. package/dist/cli/commands/context.js +91 -0
  20. package/dist/cli/commands/context.js.map +1 -0
  21. package/dist/cli/commands/delivery.d.ts.map +1 -1
  22. package/dist/cli/commands/delivery.js +42 -30
  23. package/dist/cli/commands/delivery.js.map +1 -1
  24. package/dist/cli/commands/generate.d.ts.map +1 -1
  25. package/dist/cli/commands/generate.js +28 -5
  26. package/dist/cli/commands/generate.js.map +1 -1
  27. package/dist/cli/commands/ledger.d.ts.map +1 -1
  28. package/dist/cli/commands/ledger.js +15 -5
  29. package/dist/cli/commands/ledger.js.map +1 -1
  30. package/dist/cli/commands/manifest.d.ts.map +1 -1
  31. package/dist/cli/commands/manifest.js +10 -9
  32. package/dist/cli/commands/manifest.js.map +1 -1
  33. package/dist/cli/commands/repair.d.ts +8 -0
  34. package/dist/cli/commands/repair.d.ts.map +1 -0
  35. package/dist/cli/commands/repair.js +97 -0
  36. package/dist/cli/commands/repair.js.map +1 -0
  37. package/dist/cli/commands/script-check.d.ts.map +1 -1
  38. package/dist/cli/commands/script-check.js +13 -9
  39. package/dist/cli/commands/script-check.js.map +1 -1
  40. package/dist/cli/commands/trace.d.ts.map +1 -1
  41. package/dist/cli/commands/trace.js +7 -4
  42. package/dist/cli/commands/trace.js.map +1 -1
  43. package/dist/cli/index.js +4 -0
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/generators/test-generator/code-generator.d.ts +7 -0
  46. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  47. package/dist/generators/test-generator/code-generator.js +112 -42
  48. package/dist/generators/test-generator/code-generator.js.map +1 -1
  49. package/dist/harness/annotation-overrides.d.ts +3 -1
  50. package/dist/harness/annotation-overrides.d.ts.map +1 -1
  51. package/dist/harness/annotation-overrides.js +3 -1
  52. package/dist/harness/annotation-overrides.js.map +1 -1
  53. package/dist/harness/audit.d.ts +9 -1
  54. package/dist/harness/audit.d.ts.map +1 -1
  55. package/dist/harness/audit.js +114 -12
  56. package/dist/harness/audit.js.map +1 -1
  57. package/dist/harness/capability-plan.d.ts +14 -0
  58. package/dist/harness/capability-plan.d.ts.map +1 -1
  59. package/dist/harness/capability-plan.js +63 -1
  60. package/dist/harness/capability-plan.js.map +1 -1
  61. package/dist/harness/data-driven-lint.d.ts.map +1 -1
  62. package/dist/harness/data-driven-lint.js +23 -0
  63. package/dist/harness/data-driven-lint.js.map +1 -1
  64. package/dist/harness/flow-check.d.ts +9 -0
  65. package/dist/harness/flow-check.d.ts.map +1 -1
  66. package/dist/harness/flow-check.js +13 -6
  67. package/dist/harness/flow-check.js.map +1 -1
  68. package/dist/harness/intent.d.ts +6 -0
  69. package/dist/harness/intent.d.ts.map +1 -1
  70. package/dist/harness/intent.js +20 -4
  71. package/dist/harness/intent.js.map +1 -1
  72. package/dist/harness/ledger.d.ts.map +1 -1
  73. package/dist/harness/ledger.js +3 -2
  74. package/dist/harness/ledger.js.map +1 -1
  75. package/dist/harness/manifest.d.ts.map +1 -1
  76. package/dist/harness/manifest.js +3 -2
  77. package/dist/harness/manifest.js.map +1 -1
  78. package/dist/harness/parse.d.ts +1 -0
  79. package/dist/harness/parse.d.ts.map +1 -1
  80. package/dist/harness/parse.js +3 -0
  81. package/dist/harness/parse.js.map +1 -1
  82. package/dist/harness/quality-gates.js +1 -1
  83. package/dist/harness/quality-gates.js.map +1 -1
  84. package/dist/harness/query-catalog.d.ts.map +1 -1
  85. package/dist/harness/query-catalog.js +0 -0
  86. package/dist/harness/query-catalog.js.map +1 -1
  87. package/dist/harness/repair.d.ts +20 -0
  88. package/dist/harness/repair.d.ts.map +1 -0
  89. package/dist/harness/repair.js +111 -0
  90. package/dist/harness/repair.js.map +1 -0
  91. package/dist/harness/script-check.d.ts +3 -1
  92. package/dist/harness/script-check.d.ts.map +1 -1
  93. package/dist/harness/script-check.js +22 -8
  94. package/dist/harness/script-check.js.map +1 -1
  95. package/dist/harness/sensors.d.ts +40 -0
  96. package/dist/harness/sensors.d.ts.map +1 -1
  97. package/dist/harness/sensors.js +54 -2
  98. package/dist/harness/sensors.js.map +1 -1
  99. package/dist/harness/trace.d.ts.map +1 -1
  100. package/dist/harness/trace.js +4 -3
  101. package/dist/harness/trace.js.map +1 -1
  102. package/dist/harness/unit-paths.d.ts +3 -0
  103. package/dist/harness/unit-paths.d.ts.map +1 -0
  104. package/dist/harness/unit-paths.js +52 -0
  105. package/dist/harness/unit-paths.js.map +1 -0
  106. package/dist/index.d.ts +4 -2
  107. package/dist/index.d.ts.map +1 -1
  108. package/dist/index.js +5 -1
  109. package/dist/index.js.map +1 -1
  110. package/dist/orchestrator/ai-rules-updater.d.ts.map +1 -1
  111. package/dist/orchestrator/ai-rules-updater.js +2 -0
  112. package/dist/orchestrator/ai-rules-updater.js.map +1 -1
  113. package/dist/orchestrator/context-discovery.d.ts +12 -0
  114. package/dist/orchestrator/context-discovery.d.ts.map +1 -0
  115. package/dist/orchestrator/context-discovery.js +46 -0
  116. package/dist/orchestrator/context-discovery.js.map +1 -0
  117. package/dist/orchestrator/templates/ai-instructions/claude-agent-reviewer.md +7 -1
  118. package/dist/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +6 -2
  119. package/dist/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +18 -1
  120. package/dist/orchestrator/templates/ai-instructions/claude-skill-api-design.md +62 -0
  121. package/dist/orchestrator/templates/ai-instructions/claude-skill-harness-audit.md +2 -1
  122. package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +16 -2
  123. package/dist/orchestrator/templates/ai-instructions/claude-skill-viewpoint.md +14 -0
  124. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +6 -2
  125. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +11 -1
  126. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-api-design.md +62 -0
  127. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-harness-audit.md +2 -1
  128. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +16 -2
  129. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-viewpoint.md +14 -0
  130. package/dist/orchestrator/templates/specs-api.d.ts +38 -2
  131. package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
  132. package/dist/orchestrator/templates/specs-api.js +65 -22
  133. package/dist/orchestrator/templates/specs-api.js.map +1 -1
  134. package/dist/orchestrator/templates/specs-api.ts +71 -18
  135. package/dist/orchestrator/templates/specs-db.d.ts +3 -0
  136. package/dist/orchestrator/templates/specs-db.d.ts.map +1 -1
  137. package/dist/orchestrator/templates/specs-db.js +78 -1
  138. package/dist/orchestrator/templates/specs-db.js.map +1 -1
  139. package/dist/orchestrator/templates/specs-db.ts +78 -1
  140. package/dist/orchestrator/templates/specs-test-data.ts +2 -1
  141. package/package.json +2 -2
  142. package/src/capabilities/context.ts +19 -0
  143. package/src/capabilities/discover.ts +27 -7
  144. package/src/capabilities/registry.ts +3 -1
  145. package/src/capabilities/sensor.ts +1 -1
  146. package/src/cli/commands/audit.ts +15 -9
  147. package/src/cli/commands/capability.ts +53 -5
  148. package/src/cli/commands/context.ts +52 -0
  149. package/src/cli/commands/delivery.ts +40 -31
  150. package/src/cli/commands/generate.ts +30 -5
  151. package/src/cli/commands/ledger.ts +13 -5
  152. package/src/cli/commands/manifest.ts +9 -7
  153. package/src/cli/commands/repair.ts +57 -0
  154. package/src/cli/commands/script-check.ts +12 -8
  155. package/src/cli/commands/trace.ts +7 -4
  156. package/src/cli/index.ts +4 -0
  157. package/src/generators/test-generator/code-generator.ts +115 -40
  158. package/src/harness/annotation-overrides.ts +3 -1
  159. package/src/harness/audit.ts +115 -15
  160. package/src/harness/capability-plan.ts +51 -1
  161. package/src/harness/data-driven-lint.ts +20 -0
  162. package/src/harness/flow-check.ts +15 -6
  163. package/src/harness/intent.ts +25 -4
  164. package/src/harness/ledger.ts +3 -2
  165. package/src/harness/manifest.ts +3 -2
  166. package/src/harness/parse.ts +4 -0
  167. package/src/harness/quality-gates.ts +1 -1
  168. package/src/harness/query-catalog.ts +0 -0
  169. package/src/harness/repair.ts +75 -0
  170. package/src/harness/script-check.ts +25 -8
  171. package/src/harness/sensors.ts +71 -2
  172. package/src/harness/trace.ts +4 -3
  173. package/src/harness/unit-paths.ts +14 -0
  174. package/src/index.ts +4 -2
  175. package/src/orchestrator/ai-rules-updater.ts +2 -0
  176. package/src/orchestrator/context-discovery.ts +50 -0
  177. package/src/orchestrator/templates/ai-instructions/claude-agent-reviewer.md +7 -1
  178. package/src/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +6 -2
  179. package/src/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +18 -1
  180. package/src/orchestrator/templates/ai-instructions/claude-skill-api-design.md +62 -0
  181. package/src/orchestrator/templates/ai-instructions/claude-skill-harness-audit.md +2 -1
  182. package/src/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +16 -2
  183. package/src/orchestrator/templates/ai-instructions/claude-skill-viewpoint.md +14 -0
  184. package/src/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +6 -2
  185. package/src/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +11 -1
  186. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-api-design.md +62 -0
  187. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-harness-audit.md +2 -1
  188. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +16 -2
  189. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-viewpoint.md +14 -0
  190. package/src/orchestrator/templates/specs-api.ts +71 -18
  191. package/src/orchestrator/templates/specs-db.ts +78 -1
  192. package/src/orchestrator/templates/specs-test-data.ts +2 -1
@@ -13,6 +13,8 @@
13
13
  * A driver that isn't installed is simply skipped (capabilities are opt-in). Errors thrown *inside* a
14
14
  * present driver propagate — only a genuinely absent package is swallowed (resolve-then-require).
15
15
  */
16
+ import { createRequire } from 'module';
17
+ import * as path from 'path';
16
18
  import { capabilityRegistry } from './registry';
17
19
  import { LOCAL_DRIVERS, registerCoreCapability } from './builtins';
18
20
 
@@ -24,14 +26,32 @@ import { LOCAL_DRIVERS, registerCoreCapability } from './builtins';
24
26
  const EXTERNAL_DRIVERS = ['@sungen/driver-ui', '@sungen/driver-db', '@sungen/driver-api'];
25
27
 
26
28
  function loadExternalDriver(name: string): void {
27
- try {
28
- require.resolve(name);
29
- } catch {
30
- return; // not installed capabilities are opt-in, so skip silently
29
+ // Resolve from the user's PROJECT first, then from core's own location. Opt-in drivers
30
+ // (`sungen capability add db|api`) install into the PROJECT's node_modules — a globally-installed
31
+ // core (`npm i -g`) wouldn't see them via a core-anchored `require`. The bundled @sungen/driver-ui
32
+ // is a core dependency (co-located with core), so the second anchor finds it.
33
+ for (const anchor of [path.join(process.cwd(), 'package.json'), __filename]) {
34
+ let req: NodeRequire;
35
+ try { req = createRequire(anchor); } catch { continue; }
36
+ let resolved: string;
37
+ try { resolved = req.resolve(name); } catch { continue; } // not installed at this anchor → try next
38
+ try {
39
+ const mod = req(resolved);
40
+ const register: ((r: typeof capabilityRegistry) => void) | undefined = mod?.sungenDriver?.register ?? mod?.register;
41
+ if (typeof register === 'function') register(capabilityRegistry);
42
+ } catch (e) {
43
+ // A present-but-BROKEN driver (e.g. a stale/incompatible leftover whose own core dep doesn't
44
+ // resolve) must never crash the whole CLI — `sungen --version`, `capability remove`, etc. must
45
+ // still work. Warn + skip so the user can recover.
46
+ console.warn(
47
+ `⚠ sungen: capability driver "${name}" is installed but failed to load — skipping.\n` +
48
+ ` ${(e as Error)?.message || e}\n` +
49
+ ` Fix: reinstall it (\`sungen capability add ${name.replace(/^@sungen\/driver-/, '')}\`) or remove it.`,
50
+ );
51
+ }
52
+ return; // found at this anchor (loaded or warned) — don't fall through to other anchors
31
53
  }
32
- const mod = require(name); // present: let any error thrown inside the driver propagate (don't mask)
33
- const register: ((r: typeof capabilityRegistry) => void) | undefined = mod?.sungenDriver?.register ?? mod?.register;
34
- if (typeof register === 'function') register(capabilityRegistry);
54
+ // not installed at any anchor capabilities are opt-in, so skip silently
35
55
  }
36
56
 
37
57
  export function discoverAndRegisterCapabilities(): void {
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import type { StepPattern } from '../generators/test-generator/patterns/types';
15
15
  import type { Sensor } from './sensor';
16
- import type { DiscoveryProvider, ContextMapper } from './context';
16
+ import type { DiscoveryProvider, ContextMapper, RepairProvider } from './context';
17
17
 
18
18
  export interface CapabilityDescriptor {
19
19
  /** Stable id: 'ui' | 'db' | 'api' | 'core' | … */
@@ -29,6 +29,8 @@ export interface CapabilityDescriptor {
29
29
  /** Orchestration phase hooks — declared in R1, consumed by the pipeline from R2 onward. */
30
30
  discovery?: DiscoveryProvider; // sources → Context slice
31
31
  contextMapper?: ContextMapper; // Context → generation units + modes
32
+ /** Repair phase: the capability's deterministic fix catalog (audit/runtime finding → fix). */
33
+ repair?: RepairProvider;
32
34
  /** Provider for this capability's viewpoint catalog (UI: the 17-pattern universal catalog). */
33
35
  viewpoints?: () => unknown;
34
36
  /**
@@ -41,7 +41,7 @@ export interface GateInput {
41
41
  screenName: string;
42
42
  cwd: string;
43
43
  featureText: string;
44
- scenarios: Array<{ name: string; queryRefs?: string[]; apiRefs?: string[] }>;
44
+ scenarios: Array<{ name: string; queryRefs?: string[]; apiRefs?: string[]; casesDataset?: string; stepsText?: string; manual?: boolean }>;
45
45
  /** UI: universal-viewpoint theme gaps the coverage gate found (generic string list). */
46
46
  universalGaps?: string[];
47
47
  }
@@ -2,12 +2,16 @@ import { Command } from 'commander';
2
2
  import * as path from 'path';
3
3
  import * as fs from 'fs';
4
4
  import { runAudit, AuditReport } from '../../harness/audit';
5
+ import { reportSlug } from '../../harness/unit-paths';
5
6
 
6
7
  function findScreenDir(name: string): string | null {
7
- const screen = path.join(process.cwd(), 'qa', 'screens', name);
8
- if (fs.existsSync(screen)) return screen;
9
- const flow = path.join(process.cwd(), 'qa', 'flows', name);
10
- if (fs.existsSync(flow)) return flow;
8
+ // `name` may be a bare screen/flow/area, or an api unit id (`api/<area>`, `api/flows/<flow>`).
9
+ const candidates = [
10
+ path.join(process.cwd(), 'qa', 'screens', name),
11
+ path.join(process.cwd(), 'qa', 'flows', name),
12
+ path.join(process.cwd(), 'qa', 'api', name), // qa/api/<area> or qa/api/flows/<flow>
13
+ ];
14
+ for (const c of candidates) if (fs.existsSync(c)) return c;
11
15
  return null;
12
16
  }
13
17
 
@@ -88,20 +92,22 @@ export function registerAuditCommand(program: Command): void {
88
92
  .command('audit')
89
93
  .description('Harness: measure test-design quality (viewpoint gate, depth, balance, duplicates, traceability)')
90
94
  .option('-s, --screen <name>', 'Screen or flow name to audit')
95
+ .option('--api <name>', 'API-first area or api flow to audit (e.g. orders, flows/signup)')
96
+ .option('--area <name>', 'Alias of --api — an API-first area (qa/api/<name>)')
91
97
  .option('--json', 'Output the raw JSON report')
92
98
  .action((options) => {
93
99
  try {
94
- const name = options.screen;
95
- if (!name) throw new Error('Provide --screen <name>');
100
+ const name = options.screen || options.api || options.area;
101
+ if (!name) throw new Error('Provide --screen <name> (or --api <area>)');
96
102
  const dir = findScreenDir(name);
97
- if (!dir) throw new Error(`Screen/flow not found: qa/screens/${name} or qa/flows/${name}`);
103
+ if (!dir) throw new Error(`Not found: qa/screens/${name}, qa/flows/${name}, or qa/api/${name}`);
98
104
 
99
105
  const report = runAudit(dir, name);
100
106
 
101
- // Persist report under .sungen/reports/
107
+ // Persist report under .sungen/reports/ (flat slug: api flow `flows/x` → `flows-x-audit.json`).
102
108
  const outDir = path.join(process.cwd(), '.sungen', 'reports');
103
109
  fs.mkdirSync(outDir, { recursive: true });
104
- const outPath = path.join(outDir, `${name}-audit.json`);
110
+ const outPath = path.join(outDir, `${reportSlug(name)}-audit.json`);
105
111
  fs.writeFileSync(outPath, JSON.stringify(report, null, 2), 'utf-8');
106
112
 
107
113
  if (options.json) {
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { spawnSync } from 'child_process';
3
+ import { createRequire } from 'module';
3
4
  import * as fs from 'fs';
4
5
  import * as path from 'path';
5
6
  import {
@@ -8,6 +9,42 @@ import {
8
9
  import { buildPlan, MANUAL_REASONS } from '../../harness/capability-plan';
9
10
  import { adapterRegistry } from '../../generators/test-generator/adapters';
10
11
 
12
+ /** The running CLI's (core) version — opt-in drivers install in lockstep with it (not `@latest`). */
13
+ function coreVersion(): string {
14
+ try { return require('../../../package.json').version || 'latest'; } catch { return 'latest'; }
15
+ }
16
+
17
+ /**
18
+ * Verify a capability driver (db/api/…) installed into the PROJECT exposes the capability SPI entry
19
+ * (`sungenDriver.register`). Resolved from the project's node_modules so it works with a globally
20
+ * installed core. Capability drivers are NOT codegen adapters — don't route them through loadDriver.
21
+ */
22
+ function verifyCapabilityDriver(pkg: string, cwd: string): void {
23
+ const req = createRequire(path.join(cwd, 'package.json'));
24
+ // 1) Installed? (resolve only — distinguishes "not installed" from "installed but broken")
25
+ try {
26
+ req.resolve(pkg);
27
+ } catch {
28
+ throw new Error(`${pkg} is not installed in this project — run \`npm install -D ${pkg}\` here (a package.json must exist in this directory).`);
29
+ }
30
+ // 2) Loadable? Surface the REAL error — a failure here is almost always a stale/wrong
31
+ // @sun-asterisk/sungen on the module path (e.g. leftover node_modules), NOT a missing driver.
32
+ let mod: any;
33
+ try {
34
+ mod = req(pkg);
35
+ } catch (e: any) {
36
+ throw new Error(
37
+ `${pkg} is installed but failed to load:\n ${e?.message || e}\n` +
38
+ ' This usually means a stale @sun-asterisk/sungen is on the module path. Run in a clean,\n' +
39
+ ' dedicated project directory and remove stray installs (e.g. rm -rf ~/node_modules).',
40
+ );
41
+ }
42
+ const register = mod?.sungenDriver?.register ?? mod?.register;
43
+ if (typeof register !== 'function') {
44
+ throw new Error(`${pkg} is installed but is not a sungen capability driver (missing sungenDriver.register).`);
45
+ }
46
+ }
47
+
11
48
  function findScreenDir(name: string): string | null {
12
49
  const s = path.join(process.cwd(), 'qa', 'screens', name);
13
50
  if (fs.existsSync(s)) return s;
@@ -111,16 +148,27 @@ export function registerCapabilityCommand(program: Command): void {
111
148
  const bundled = adapterRegistry.hasAdapter(adapterName); // e.g. `web` is bundled in Phase 2a
112
149
 
113
150
  if (!bundled && !o.skipInstall) {
114
- console.log(`📦 Installing ${meta.package} (dev dependency)...`);
115
- const r = spawnSync('npm', ['install', '-D', meta.package], { stdio: 'inherit', shell: true });
116
- if (r.status !== 0) throw new Error(`npm install -D ${meta.package} failed.`);
151
+ // Pin the driver to the RUNNING core's exact version (lockstep). Without this, plain
152
+ // `npm install @sungen/driver-x` resolves the package's `latest` dist-tag which on a
153
+ // beta channel can lag (e.g. a prerelease first-published-as-latest), pulling a driver
154
+ // whose core dep resolves to the wrong @sun-asterisk/sungen and fails to load.
155
+ const spec = `${meta.package}@${coreVersion()}`;
156
+ console.log(`📦 Installing ${spec} (dev dependency)...`);
157
+ const r = spawnSync('npm', ['install', '-D', spec], { stdio: 'inherit', shell: true });
158
+ if (r.status !== 0) throw new Error(`npm install -D ${spec} failed.`);
117
159
  } else if (bundled) {
118
160
  console.log(`✓ ${driver} is built-in (no install needed).`);
119
161
  }
120
162
 
121
- // Verify it can load (skip for bundled — already registered).
163
+ // Verify it loaded (skip for bundled — already registered). Two driver shapes:
164
+ // - platform driver (web/mobile): a codegen adapter, loaded via the adapter registry (mod.activate)
165
+ // - capability driver (db/api/…): a capability-SPI package (mod.sungenDriver.register) — NOT an adapter
122
166
  if (!bundled) {
123
- adapterRegistry.loadDriver(adapterName, meta.package, cwd); // throws if not loadable
167
+ if (meta.kind === 'platform') {
168
+ adapterRegistry.loadDriver(adapterName, meta.package, cwd); // throws if not loadable
169
+ } else {
170
+ verifyCapabilityDriver(meta.package, cwd); // capability SPI entry, resolved from the project
171
+ }
124
172
  }
125
173
 
126
174
  const profile = readCapabilities(cwd);
@@ -0,0 +1,52 @@
1
+ import { Command } from 'commander';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { discoverUnitContext } from '../../orchestrator/context-discovery';
5
+
6
+ /**
7
+ * `sungen context` — run the Discover → Contextualize phase (AO-3) for a unit and emit the
8
+ * normalized Context + generation-unit work-list. The `/sungen:design` loop calls this first so it
9
+ * knows WHAT to generate (the endpoints/screens + their modes) before authoring scenarios. Pure +
10
+ * deterministic; writes `.sungen/reports/<unit>-context.json`.
11
+ */
12
+ export function registerContextCommand(program: Command): void {
13
+ program
14
+ .command('context')
15
+ .description('Discover + contextualize a unit (screen/flow/api area) → Context + generation units')
16
+ .option('-s, --screen <name>', 'Screen name')
17
+ .option('--flow <name>', 'Flow name (resolved as flows/<name>)')
18
+ .option('--api <name>', 'API-first area or api flow (resolved as api/<name>)')
19
+ .option('--area <name>', 'Alias of --api — an API-first area (qa/api/<name>)')
20
+ .option('--json', 'Print the raw JSON')
21
+ .action(async (o: { screen?: string; flow?: string; api?: string; area?: string; json?: boolean }) => {
22
+ try {
23
+ const unitId = (o.api || o.area) ? `api/${o.api || o.area}` : o.flow ? `flows/${o.flow}` : o.screen;
24
+ if (!unitId) throw new Error('Provide --screen <name>, --flow <name>, or --api <area>.');
25
+ const cwd = process.cwd();
26
+ const result = await discoverUnitContext(unitId, cwd);
27
+
28
+ const outDir = path.join(cwd, '.sungen', 'reports');
29
+ fs.mkdirSync(outDir, { recursive: true });
30
+ const slug = unitId.replace(/\//g, '-');
31
+ const outPath = path.join(outDir, `${slug}-context.json`);
32
+ fs.writeFileSync(outPath, JSON.stringify(result, null, 2), 'utf-8');
33
+ if (o.json) { console.log(JSON.stringify(result, null, 2)); return; }
34
+
35
+ const L = console.log;
36
+ const facts = result.context.facts as { endpoints?: Array<{ name: string; method: string; path: string }> };
37
+ L(`\n━━━ Context: ${unitId} (capability: ${result.capability}) ━━━`);
38
+ const srcKeys = Object.keys(result.context.sources);
39
+ L(` Sources: ${srcKeys.length ? srcKeys.join(', ') : '(none)'}`);
40
+ if (facts.endpoints?.length) {
41
+ L(` Endpoints (${facts.endpoints.length}):`);
42
+ for (const e of facts.endpoints) L(` • ${e.method} ${e.path} (@api:${e.name})`);
43
+ }
44
+ const byMode = result.units.reduce<Record<string, number>>((m, u) => { m[u.mode] = (m[u.mode] || 0) + 1; return m; }, {});
45
+ L(` Generation units: ${result.units.length} — ${Object.entries(byMode).map(([m, n]) => `${m}×${n}`).join(' · ') || '(none)'}`);
46
+ L(`\n Report: ${path.relative(cwd, outPath)}\n`);
47
+ } catch (e) {
48
+ console.error('Error:', e instanceof Error ? e.message : e);
49
+ process.exit(1);
50
+ }
51
+ });
52
+ }
@@ -52,45 +52,54 @@ function log(msg: string): void {
52
52
  * `name` is kept as an alias of `featureBaseName` so existing callers/labels
53
53
  * (preflight table, summary) read naturally — every visible row is per-feature.
54
54
  */
55
+ type UnitKind = 'screen' | 'flow' | 'api';
56
+ /** qa/ subfolder for a unit kind. */
57
+ const qaParent = (kind: UnitKind): string => (kind === 'flow' ? 'flows' : kind === 'api' ? 'api' : 'screens');
58
+
55
59
  interface DeliveryTarget {
56
60
  screen: string;
57
61
  featureBaseName: string;
58
62
  /** Alias of `featureBaseName` — preserves the old `target.name` call sites. */
59
63
  name: string;
64
+ kind: UnitKind;
65
+ /** Back-compat: flows kept distinct labels/paths before api was added. */
60
66
  isFlow: boolean;
61
67
  }
62
68
 
63
- function makeTarget(screen: string, featureBaseName: string, isFlow: boolean): DeliveryTarget {
64
- return { screen, featureBaseName, name: featureBaseName, isFlow };
69
+ function makeTarget(screen: string, featureBaseName: string, kind: UnitKind): DeliveryTarget {
70
+ return { screen, featureBaseName, name: featureBaseName, kind, isFlow: kind === 'flow' };
65
71
  }
66
72
 
67
73
  /**
68
- * List all `.feature` files inside a screen/flow as separate targets.
74
+ * List all `.feature` files inside a screen/flow/api unit as separate targets.
69
75
  * Returns empty array when the directory has no features yet.
70
76
  */
71
- function listFeatureTargets(cwd: string, screen: string, isFlow: boolean): DeliveryTarget[] {
72
- const featuresDir = path.join(cwd, 'qa', isFlow ? 'flows' : 'screens', screen, 'features');
77
+ function listFeatureTargets(cwd: string, screen: string, kind: UnitKind): DeliveryTarget[] {
78
+ const featuresDir = path.join(cwd, 'qa', qaParent(kind), screen, 'features');
73
79
  if (!fs.existsSync(featuresDir)) return [];
74
80
  return fs.readdirSync(featuresDir)
75
81
  .filter((f) => f.endsWith('.feature'))
76
- .map((f) => makeTarget(screen, f.slice(0, -'.feature'.length), isFlow))
82
+ .map((f) => makeTarget(screen, f.slice(0, -'.feature'.length), kind))
77
83
  .sort((a, b) => a.featureBaseName.localeCompare(b.featureBaseName));
78
84
  }
79
85
 
80
86
  function listAllTargets(cwd: string): DeliveryTarget[] {
81
87
  const targets: DeliveryTarget[] = [];
82
-
83
- const screensDir = path.join(cwd, 'qa', 'screens');
84
- if (fs.existsSync(screensDir)) {
85
- for (const d of fs.readdirSync(screensDir, { withFileTypes: true })) {
86
- if (d.isDirectory()) targets.push(...listFeatureTargets(cwd, d.name, false));
88
+ const scan = (kind: UnitKind, skip: (n: string) => boolean = () => false) => {
89
+ const root = path.join(cwd, 'qa', qaParent(kind));
90
+ if (!fs.existsSync(root)) return;
91
+ for (const d of fs.readdirSync(root, { withFileTypes: true })) {
92
+ if (d.isDirectory() && !skip(d.name)) targets.push(...listFeatureTargets(cwd, d.name, kind));
87
93
  }
88
- }
89
-
90
- const flowsDir = path.join(cwd, 'qa', 'flows');
91
- if (fs.existsSync(flowsDir)) {
92
- for (const d of fs.readdirSync(flowsDir, { withFileTypes: true })) {
93
- if (d.isDirectory()) targets.push(...listFeatureTargets(cwd, d.name, true));
94
+ };
95
+ scan('screen');
96
+ scan('flow');
97
+ scan('api', (n) => n === 'flows'); // api areas: qa/api/<area>
98
+ // api flows: qa/api/flows/<flow> → screen `flows/<flow>`, kind 'api' (paths compose to qa/api/flows/… + specs/generated/api/flows/…)
99
+ const apiFlowsRoot = path.join(cwd, 'qa', 'api', 'flows');
100
+ if (fs.existsSync(apiFlowsRoot)) {
101
+ for (const d of fs.readdirSync(apiFlowsRoot, { withFileTypes: true })) {
102
+ if (d.isDirectory()) targets.push(...listFeatureTargets(cwd, path.posix.join('flows', d.name), 'api'));
94
103
  }
95
104
  }
96
105
 
@@ -108,28 +117,27 @@ function listAllTargets(cwd: string): DeliveryTarget[] {
108
117
  * feature file with the basename across all screens & flows.
109
118
  */
110
119
  function resolveTargetsFromArg(cwd: string, name: string): DeliveryTarget[] {
111
- if (fs.existsSync(path.join(cwd, 'qa', 'flows', name))) {
112
- return listFeatureTargets(cwd, name, true);
113
- }
114
- if (fs.existsSync(path.join(cwd, 'qa', 'screens', name))) {
115
- return listFeatureTargets(cwd, name, false);
116
- }
117
- // Treat as feature basename — find the parent screen/flow that hosts it.
120
+ if (fs.existsSync(path.join(cwd, 'qa', 'flows', name))) return listFeatureTargets(cwd, name, 'flow');
121
+ if (fs.existsSync(path.join(cwd, 'qa', 'screens', name))) return listFeatureTargets(cwd, name, 'screen');
122
+ if (fs.existsSync(path.join(cwd, 'qa', 'api', name))) return listFeatureTargets(cwd, name, 'api');
123
+ // `flows/<flow>` or a bare api-flow name → qa/api/flows/<flow>
124
+ const apiFlow = name.startsWith('flows/') ? name : path.posix.join('flows', name);
125
+ if (fs.existsSync(path.join(cwd, 'qa', 'api', apiFlow))) return listFeatureTargets(cwd, apiFlow, 'api');
126
+ // Treat as feature basename — find the parent unit that hosts it.
118
127
  const candidates = listAllTargets(cwd).filter((t) => t.featureBaseName === name);
119
128
  if (candidates.length > 0) return candidates;
120
129
  // Fallback: treat as screen name even if directory missing (lets preflight
121
130
  // surface the "feature file missing" error with the right path).
122
- return [makeTarget(name, name, false)];
131
+ return [makeTarget(name, name, 'screen')];
123
132
  }
124
133
 
125
134
  function qaDir(cwd: string, target: DeliveryTarget): string {
126
- return path.join(cwd, 'qa', target.isFlow ? 'flows' : 'screens', target.screen);
135
+ return path.join(cwd, 'qa', qaParent(target.kind), target.screen);
127
136
  }
128
137
 
129
138
  function generatedDir(cwd: string, target: DeliveryTarget): string {
130
- return target.isFlow
131
- ? path.join(cwd, 'specs', 'generated', 'flows', target.screen)
132
- : path.join(cwd, 'specs', 'generated', target.screen);
139
+ const sub = target.kind === 'flow' ? path.join('flows', target.screen) : target.kind === 'api' ? path.join('api', target.screen) : target.screen;
140
+ return path.join(cwd, 'specs', 'generated', sub);
133
141
  }
134
142
 
135
143
  // ----------------------------------------------------------------------------
@@ -262,7 +270,8 @@ function runPreflight(cwd: string, target: DeliveryTarget): PreflightCheck {
262
270
 
263
271
  const featureOk = checkFeatureReal(featureFile);
264
272
  const testDataOk = checkTestDataHasVars(testDataFile);
265
- const selectorsOk = checkSelectorsHasEntries(selectorsFile, target.featureBaseName);
273
+ // API units have no DOM → no selectors; the catalog (apis.yaml) is the contract. N/A, not missing.
274
+ const selectorsOk = target.kind === 'api' ? true : checkSelectorsHasEntries(selectorsFile, target.featureBaseName);
266
275
  const specOk = fs.existsSync(specFile);
267
276
  const resultsOk = resultsFile !== null;
268
277
 
@@ -284,7 +293,7 @@ function runPreflight(cwd: string, target: DeliveryTarget): PreflightCheck {
284
293
  }
285
294
  if (!specOk) {
286
295
  missing.push(`compiled .spec.ts missing: ${path.relative(cwd, specFile)}`);
287
- suggestions.push(target.isFlow ? `sungen generate --flow ${target.screen}` : `sungen generate --screen ${target.screen}`);
296
+ suggestions.push(`sungen generate --${target.kind === 'flow' ? 'flow' : target.kind === 'api' ? 'api' : 'screen'} ${target.screen}`);
288
297
  }
289
298
  if (!resultsOk) {
290
299
  const env = process.env.SUNGEN_ENV;
@@ -44,12 +44,21 @@ function findFeatureFilesForFlow(flowName: string): string[] {
44
44
  }
45
45
 
46
46
  /**
47
- * Find all feature files across all screens and flows
47
+ * Find feature files for a specific API-first unit — an area (`orders`) or an api flow
48
+ * (`flows/<flow>`). Catalog/test-data live alongside under qa/api/<name>/.
49
+ */
50
+ function findFeatureFilesForApi(name: string): string[] {
51
+ return findFeatureFiles(path.join(process.cwd(), 'qa', 'api', name, 'features'));
52
+ }
53
+
54
+ /**
55
+ * Find all feature files across all screens, flows, and API-first areas/flows.
48
56
  */
49
57
  function findAllFeatureFiles(): string[] {
50
58
  const allFiles: string[] = [];
51
59
  const screensDir = path.join(process.cwd(), 'qa', 'screens');
52
60
  const flowsDir = path.join(process.cwd(), 'qa', 'flows');
61
+ const apiDir = path.join(process.cwd(), 'qa', 'api');
53
62
 
54
63
  if (fs.existsSync(screensDir)) {
55
64
  const screenDirs = fs.readdirSync(screensDir, { withFileTypes: true })
@@ -73,6 +82,9 @@ function findAllFeatureFiles(): string[] {
73
82
  }
74
83
  }
75
84
 
85
+ // API-first: every .feature under qa/api/** (areas qa/api/<area>/features + flows qa/api/flows/<flow>/features).
86
+ if (fs.existsSync(apiDir)) allFiles.push(...findFeatureFiles(apiDir));
87
+
76
88
  return allFiles;
77
89
  }
78
90
 
@@ -82,19 +94,32 @@ export function registerGenerateCommand(program: Command): void {
82
94
  .description('Generate Playwright test code from Gherkin features')
83
95
  .option('-s, --screen <name>', 'Generate tests for a specific screen')
84
96
  .option('--flow <name>', 'Generate tests for a specific flow')
85
- .option('--all', 'Generate tests for all screens and flows')
97
+ .option('--api <name>', 'Generate tests for an API-first area or api flow (e.g. orders, flows/signup)')
98
+ .option('--area <name>', 'Alias of --api — an API-first area (qa/api/<name>)')
99
+ .option('--all', 'Generate tests for all screens, flows, and API areas')
86
100
  .option('--framework <name>', 'Override the platform driver (else read from qa/capabilities.yaml)')
87
101
  .option('--inline-data', 'Hardcode test data at compile time instead of runtime loading')
88
102
  .action(async (options) => {
89
103
  try {
90
104
  const screenName = options.screen;
91
105
  const flowName = options.flow;
106
+ const apiName = options.api || options.area;
92
107
 
93
108
  // Find feature files
94
109
  let featureFiles: string[];
95
110
  let qaSourceDir: string;
96
111
 
97
- if (screenName) {
112
+ if (apiName) {
113
+ featureFiles = findFeatureFilesForApi(apiName);
114
+ if (featureFiles.length === 0) {
115
+ throw new Error(
116
+ `No feature files found for API unit: ${apiName}\n` +
117
+ `Looked in: qa/api/${apiName}/features/ (scaffold with \`sungen api add --area ${apiName}\` or \`--flow\`)`
118
+ );
119
+ }
120
+ qaSourceDir = path.join(process.cwd(), 'qa', 'api');
121
+ console.log(`\nGenerating tests: api/${apiName}\n`);
122
+ } else if (screenName) {
98
123
  featureFiles = findFeatureFilesForScreen(screenName);
99
124
  if (featureFiles.length === 0) {
100
125
  throw new Error(
@@ -117,10 +142,10 @@ export function registerGenerateCommand(program: Command): void {
117
142
  } else {
118
143
  featureFiles = findAllFeatureFiles();
119
144
  if (featureFiles.length === 0) {
120
- throw new Error('No feature files found in qa/screens/ or qa/flows/');
145
+ throw new Error('No feature files found in qa/screens/, qa/flows/, or qa/api/');
121
146
  }
122
147
  qaSourceDir = path.join(process.cwd(), 'qa');
123
- console.log(`\nGenerating tests for all screens and flows\n`);
148
+ console.log(`\nGenerating tests for all screens, flows, and API areas\n`);
124
149
  }
125
150
 
126
151
  // Output directory
@@ -9,7 +9,9 @@ export function registerLedgerCommand(program: Command): void {
9
9
  ledger
10
10
  .command('record')
11
11
  .description('Append a step event to the ledger')
12
- .requiredOption('-s, --screen <name>', 'Screen or flow name')
12
+ .option('-s, --screen <name>', 'Screen or flow name')
13
+ .option('--api <name>', 'API-first area or api flow (qa/api/<name>)')
14
+ .option('--area <name>', 'Alias of --api — an API-first area (qa/api/<name>)')
13
15
  .requiredOption('--step <name>', 'Step name (discovery | viewpoint | gherkin | audit | repair:1 ...)')
14
16
  .option('--run <id>', 'Run id — groups all phases of one create-test invocation (else auto-segmented by time gap)')
15
17
  .option('--model <id>', 'Model id')
@@ -19,10 +21,12 @@ export function registerLedgerCommand(program: Command): void {
19
21
  .option('--note <text>', 'Free note')
20
22
  .action((o) => {
21
23
  try {
22
- recordEvent(o.screen, {
24
+ const name = o.screen || o.api || o.area;
25
+ if (!name) throw new Error('Provide --screen <name> (or --api <area>)');
26
+ recordEvent(name, {
23
27
  step: o.step, runId: o.run, model: o.model, tokensIn: o.tokensIn, tokensOut: o.tokensOut, ms: o.ms, note: o.note,
24
28
  });
25
- console.log(`✓ ledger: ${o.screen} · ${o.step}`);
29
+ console.log(`✓ ledger: ${name} · ${o.step}`);
26
30
  } catch (e) {
27
31
  console.error('Error:', e instanceof Error ? e.message : e);
28
32
  process.exit(1);
@@ -32,12 +36,16 @@ export function registerLedgerCommand(program: Command): void {
32
36
  ledger
33
37
  .command('report')
34
38
  .description('Summarise ledger + efficiency verdicts (pulls audit score if present)')
35
- .requiredOption('-s, --screen <name>', 'Screen or flow name')
39
+ .option('-s, --screen <name>', 'Screen or flow name')
40
+ .option('--api <name>', 'API-first area or api flow (qa/api/<name>)')
41
+ .option('--area <name>', 'Alias of --api — an API-first area (qa/api/<name>)')
36
42
  .option('--all-runs', 'Aggregate ALL runs (default: latest run only)')
37
43
  .option('--json', 'Output raw JSON')
38
44
  .action((o) => {
39
45
  try {
40
- const r = buildReport(o.screen, { allRuns: o.allRuns });
46
+ const name = o.screen || o.api || o.area;
47
+ if (!name) throw new Error('Provide --screen <name> (or --api <area>)');
48
+ const r = buildReport(name, { allRuns: o.allRuns });
41
49
  if (o.json) { console.log(JSON.stringify(r, null, 2)); return; }
42
50
  const scope = r.runScope === 'all' ? `all ${r.runs} runs` : `latest run${r.runs > 1 ? ` of ${r.runs}` : ''}`;
43
51
  console.log(`\n━━━ Usage Ledger: ${r.screen} (${r.events} events · ${scope}) ━━━`);
@@ -4,10 +4,10 @@ import * as fs from 'fs';
4
4
  import { buildManifest, diffManifest, loadManifest, saveManifest } from '../../harness/manifest';
5
5
 
6
6
  function findScreenDir(name: string): string | null {
7
- const screen = path.join(process.cwd(), 'qa', 'screens', name);
8
- if (fs.existsSync(screen)) return screen;
9
- const flow = path.join(process.cwd(), 'qa', 'flows', name);
10
- if (fs.existsSync(flow)) return flow;
7
+ for (const p of ['screens', 'flows', 'api']) {
8
+ const d = path.join(process.cwd(), 'qa', p, name);
9
+ if (fs.existsSync(d)) return d;
10
+ }
11
11
  return null;
12
12
  }
13
13
 
@@ -16,14 +16,16 @@ export function registerManifestCommand(program: Command): void {
16
16
  .command('manifest')
17
17
  .description('Spec-fingerprint manifest: build a scenario↔spec-section map, or diff to plan keep/regenerate/retire')
18
18
  .option('-s, --screen <name>', 'Screen or flow name')
19
+ .option('--api <name>', 'API-first area or api flow (qa/api/<name>)')
20
+ .option('--area <name>', 'Alias of --api — an API-first area (qa/api/<name>)')
19
21
  .option('--diff', 'Compare current spec vs stored manifest → change plan')
20
22
  .option('--json', 'Output raw JSON')
21
23
  .action((options) => {
22
24
  try {
23
- const name = options.screen;
24
- if (!name) throw new Error('Provide --screen <name>');
25
+ const name = options.screen || options.api || options.area;
26
+ if (!name) throw new Error('Provide --screen <name> (or --api <area>)');
25
27
  const dir = findScreenDir(name);
26
- if (!dir) throw new Error(`Screen/flow not found: ${name}`);
28
+ if (!dir) throw new Error(`Not found: qa/screens|flows|api/${name}`);
27
29
 
28
30
  if (options.diff) {
29
31
  const manifest = loadManifest(name);
@@ -0,0 +1,57 @@
1
+ import { Command } from 'commander';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { planRepair } from '../../harness/repair';
5
+
6
+ /**
7
+ * `sungen repair` (#343) — turn a unit's audit findings + Playwright failures into a concrete fix
8
+ * plan, using the unit-capability's fix catalog (the `repair` SPI). Deterministic; the `/sungen:design`
9
+ * + `/sungen:run-test` repair loops apply the same proposals. Run `sungen audit`/the tests first.
10
+ */
11
+ export function registerRepairCommand(program: Command): void {
12
+ program
13
+ .command('repair')
14
+ .description('Propose concrete fixes for a unit from its audit findings + test failures (capability fix catalog)')
15
+ .option('-s, --screen <name>', 'Screen or flow name')
16
+ .option('--api <name>', 'API-first area or api flow (qa/api/<name>)')
17
+ .option('--area <name>', 'Alias of --api — an API-first area (qa/api/<name>)')
18
+ .option('--json', 'Output raw JSON')
19
+ .action((o: { screen?: string; api?: string; area?: string; json?: boolean }) => {
20
+ try {
21
+ const name = o.screen || o.api || o.area;
22
+ if (!name) throw new Error('Provide --screen <name> (or --api <area>)');
23
+ const cwd = process.cwd();
24
+ // Capability-resolution id + the generated dir, per kind.
25
+ const kind = fs.existsSync(path.join(cwd, 'qa', 'api', name)) || o.api ? 'api'
26
+ : fs.existsSync(path.join(cwd, 'qa', 'flows', name)) ? 'flow' : 'screen';
27
+ const unitId = kind === 'api' ? `api/${name}` : kind === 'flow' ? `flows/${name}` : name;
28
+ const generatedDir = path.join(cwd, 'specs', 'generated', kind === 'api' ? path.join('api', name) : kind === 'flow' ? path.join('flows', name) : name);
29
+
30
+ const plan = planRepair(unitId, name, cwd, generatedDir);
31
+ if (o.json) { console.log(JSON.stringify(plan, null, 2)); return; }
32
+
33
+ const L = console.log;
34
+ L(`\n━━━ Repair plan: ${name} (capability: ${plan.capability ?? 'none'}) ━━━`);
35
+ if (!plan.rulesAvailable) L(` (this capability ships no repair rules)`);
36
+ if (!plan.proposals.length && !plan.unmatched.length) {
37
+ L(` ✓ Nothing to repair — run \`sungen audit --${kind === 'api' ? 'api' : 'screen'} ${name}\` + the tests first, or the unit is clean.\n`);
38
+ return;
39
+ }
40
+ if (plan.proposals.length) {
41
+ L(`\n Proposed fixes (${plan.proposals.length}):`);
42
+ for (const p of plan.proposals) {
43
+ L(` • [${p.ruleId}] ${p.source === 'runtime' ? '(test) ' : ''}${p.signal}`);
44
+ L(` → ${p.fix}`);
45
+ }
46
+ }
47
+ if (plan.unmatched.length) {
48
+ L(`\n No known rule (review manually):`);
49
+ for (const u of plan.unmatched.slice(0, 10)) L(` - ${u}`);
50
+ }
51
+ L('');
52
+ } catch (e) {
53
+ console.error('Error:', e instanceof Error ? e.message : e);
54
+ process.exit(1);
55
+ }
56
+ });
57
+ }