@fragments-sdk/cli 0.11.1 → 0.13.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.
Files changed (89) hide show
  1. package/dist/ai-client-I6MDWNYA.js +21 -0
  2. package/dist/bin.js +419 -410
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-HRFUSSZI.js → chunk-3SOAPJDX.js} +2 -2
  5. package/dist/{chunk-D5PYOXEI.js → chunk-4K7EAQ5L.js} +148 -13
  6. package/dist/{chunk-D5PYOXEI.js.map → chunk-4K7EAQ5L.js.map} +1 -1
  7. package/dist/chunk-DXX6HADE.js +443 -0
  8. package/dist/chunk-DXX6HADE.js.map +1 -0
  9. package/dist/chunk-EYXVAMEX.js +626 -0
  10. package/dist/chunk-EYXVAMEX.js.map +1 -0
  11. package/dist/{chunk-ZM4ZQZWZ.js → chunk-FO6EBJWP.js} +39 -37
  12. package/dist/chunk-FO6EBJWP.js.map +1 -0
  13. package/dist/{chunk-OQO55NKV.js → chunk-QM7SVOGF.js} +120 -12
  14. package/dist/chunk-QM7SVOGF.js.map +1 -0
  15. package/dist/{chunk-5G3VZH43.js → chunk-RF3C6LGA.js} +281 -351
  16. package/dist/chunk-RF3C6LGA.js.map +1 -0
  17. package/dist/{chunk-WXSR2II7.js → chunk-SM674YAS.js} +58 -6
  18. package/dist/chunk-SM674YAS.js.map +1 -0
  19. package/dist/chunk-SXTKFDCR.js +104 -0
  20. package/dist/chunk-SXTKFDCR.js.map +1 -0
  21. package/dist/{chunk-PW7QTQA6.js → chunk-UV5JQV3R.js} +2 -2
  22. package/dist/core/index.js +13 -1
  23. package/dist/{discovery-NEOY4MPN.js → discovery-VSGC76JN.js} +3 -3
  24. package/dist/{generate-FBHSXR3D.js → generate-QZXOXYFW.js} +4 -4
  25. package/dist/index.js +7 -6
  26. package/dist/index.js.map +1 -1
  27. package/dist/init-XK6PRUE5.js +636 -0
  28. package/dist/init-XK6PRUE5.js.map +1 -0
  29. package/dist/mcp-bin.js +2 -2
  30. package/dist/{scan-CJF2DOQW.js → scan-CHQHXWVD.js} +6 -6
  31. package/dist/scan-generate-U3RFVDTX.js +1115 -0
  32. package/dist/scan-generate-U3RFVDTX.js.map +1 -0
  33. package/dist/{service-TQYWY65E.js → service-MMEKG4MZ.js} +3 -3
  34. package/dist/{snapshot-SV2JOFZH.js → snapshot-53TUR3HW.js} +2 -2
  35. package/dist/{static-viewer-NUBFPKWH.js → static-viewer-KKCR4KXR.js} +3 -3
  36. package/dist/static-viewer-KKCR4KXR.js.map +1 -0
  37. package/dist/{test-Z5LVO724.js → test-5UCKXYSC.js} +4 -4
  38. package/dist/{tokens-CE46OTMD.js → tokens-L46MK5AW.js} +5 -5
  39. package/dist/{viewer-DLLJIMCK.js → viewer-M2EQQSGE.js} +14 -14
  40. package/dist/viewer-M2EQQSGE.js.map +1 -0
  41. package/package.json +11 -9
  42. package/src/ai-client.ts +156 -0
  43. package/src/bin.ts +99 -2
  44. package/src/build.ts +95 -33
  45. package/src/commands/__tests__/drift-sync.test.ts +252 -0
  46. package/src/commands/__tests__/scan-generate.test.ts +497 -45
  47. package/src/commands/enhance.ts +11 -35
  48. package/src/commands/govern.ts +122 -0
  49. package/src/commands/init.ts +288 -260
  50. package/src/commands/scan-generate.ts +740 -139
  51. package/src/commands/scan.ts +37 -32
  52. package/src/commands/setup.ts +143 -52
  53. package/src/commands/sync.ts +357 -0
  54. package/src/commands/validate.ts +43 -1
  55. package/src/core/component-extractor.test.ts +282 -0
  56. package/src/core/component-extractor.ts +1030 -0
  57. package/src/core/discovery.ts +93 -7
  58. package/src/service/enhance/props-extractor.ts +235 -13
  59. package/src/validators.ts +236 -0
  60. package/src/viewer/vite-plugin.ts +1 -1
  61. package/dist/chunk-5G3VZH43.js.map +0 -1
  62. package/dist/chunk-OQO55NKV.js.map +0 -1
  63. package/dist/chunk-WXSR2II7.js.map +0 -1
  64. package/dist/chunk-ZM4ZQZWZ.js.map +0 -1
  65. package/dist/init-UFGK5TCN.js +0 -867
  66. package/dist/init-UFGK5TCN.js.map +0 -1
  67. package/dist/scan-generate-SJAN5MVI.js +0 -691
  68. package/dist/scan-generate-SJAN5MVI.js.map +0 -1
  69. package/dist/viewer-DLLJIMCK.js.map +0 -1
  70. package/src/ai.ts +0 -266
  71. package/src/commands/init-framework.ts +0 -414
  72. package/src/mcp/bin.ts +0 -36
  73. package/src/migrate/bin.ts +0 -114
  74. package/src/theme/index.ts +0 -77
  75. package/src/viewer/bin.ts +0 -86
  76. package/src/viewer/cli/health.ts +0 -256
  77. package/src/viewer/cli/index.ts +0 -33
  78. package/src/viewer/cli/scan.ts +0 -124
  79. package/src/viewer/cli/utils.ts +0 -174
  80. /package/dist/{discovery-NEOY4MPN.js.map → ai-client-I6MDWNYA.js.map} +0 -0
  81. /package/dist/{chunk-HRFUSSZI.js.map → chunk-3SOAPJDX.js.map} +0 -0
  82. /package/dist/{chunk-PW7QTQA6.js.map → chunk-UV5JQV3R.js.map} +0 -0
  83. /package/dist/{scan-CJF2DOQW.js.map → discovery-VSGC76JN.js.map} +0 -0
  84. /package/dist/{generate-FBHSXR3D.js.map → generate-QZXOXYFW.js.map} +0 -0
  85. /package/dist/{service-TQYWY65E.js.map → scan-CHQHXWVD.js.map} +0 -0
  86. /package/dist/{static-viewer-NUBFPKWH.js.map → service-MMEKG4MZ.js.map} +0 -0
  87. /package/dist/{snapshot-SV2JOFZH.js.map → snapshot-53TUR3HW.js.map} +0 -0
  88. /package/dist/{test-Z5LVO724.js.map → test-5UCKXYSC.js.map} +0 -0
  89. /package/dist/{tokens-CE46OTMD.js.map → tokens-L46MK5AW.js.map} +0 -0
@@ -1,5 +1,5 @@
1
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
- import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
1
+ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
2
+ import { mkdtemp, writeFile, mkdir, rm, readFile as rf } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import {
@@ -7,8 +7,29 @@ import {
7
7
  detectCompoundComponentsFromSource,
8
8
  calculateFieldConfidence,
9
9
  scanGenerate,
10
+ parseEnrichmentResponse,
11
+ buildEnrichmentSystemPrompt,
12
+ buildEnrichmentUserPrompt,
13
+ buildEnrichedUsageBlock,
14
+ type EnrichmentResult,
10
15
  } from "../scan-generate.js";
11
- import type { ExtractedProp } from "../../service/enhance/props-extractor.js";
16
+ import type { ComponentMeta } from "../../core/component-extractor.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Helper to build a minimal ComponentMeta for tests
20
+ // ---------------------------------------------------------------------------
21
+
22
+ function makeMeta(overrides: Partial<ComponentMeta> = {}): ComponentMeta {
23
+ return {
24
+ name: overrides.name ?? 'Test',
25
+ filePath: overrides.filePath ?? '/test.tsx',
26
+ description: overrides.description ?? '',
27
+ props: overrides.props ?? {},
28
+ composition: overrides.composition ?? null,
29
+ exports: overrides.exports ?? [],
30
+ dependencies: overrides.dependencies ?? [],
31
+ };
32
+ }
12
33
 
13
34
  // ---------------------------------------------------------------------------
14
35
  // JSDoc Extraction
@@ -121,44 +142,27 @@ export function Button() {
121
142
  // ---------------------------------------------------------------------------
122
143
 
123
144
  describe("calculateFieldConfidence", () => {
124
- const makeProps = (names: string[]): ExtractedProp[] =>
125
- names.map((name) => ({
126
- name,
127
- type: "string",
128
- propType: { type: "string" as const },
129
- description: "",
130
- required: false,
131
- }));
132
-
133
145
  it("gives +30 for extracted props", () => {
134
146
  const result = calculateFieldConfidence({
135
147
  component: { name: "Foo", sourcePath: "/foo.tsx", relativePath: "foo.tsx" },
136
- props: {
137
- filePath: "/foo.tsx",
138
- componentName: "Foo",
139
- props: makeProps(["children"]),
140
- success: true,
141
- warnings: [],
142
- },
143
- jsDoc: null,
144
- compoundChildren: [],
148
+ meta: makeMeta({
149
+ props: {
150
+ children: { name: "children", type: "ReactNode", typeKind: "node", required: true, source: "local" },
151
+ },
152
+ }),
145
153
  storyVariants: [],
146
154
  });
147
- // +30 (props) + 0 (no JSDoc → TODO) + 0 (category fallback TODO) + 0 (no stories) + 10 (all resolved) + 0 (no defaults) = 40
148
- // But "children" prop → category "Layout" (+10)
149
- // So: 30 + 10 + 10 = 50
155
+ // +30 (props) + 0 (no description → TODO) + 10 (children → category "Layout") + 10 (all resolved) + 0 (no stories) + 0 (no composition) + 0 (no defaults) = 50
150
156
  expect(result.score).toBe(50);
151
157
  });
152
158
 
153
159
  it("gives +15 for JSDoc description", () => {
154
160
  const result = calculateFieldConfidence({
155
161
  component: { name: "Foo", sourcePath: "/foo.tsx", relativePath: "foo.tsx" },
156
- props: null,
157
- jsDoc: "A component",
158
- compoundChildren: [],
162
+ meta: makeMeta({ description: "A component" }),
159
163
  storyVariants: [],
160
164
  });
161
- // +15 (JSDoc), no props, category fallback → TODO, no stories
165
+ // +15 (description), no props, category fallback → TODO, no stories
162
166
  expect(result.score).toBe(15);
163
167
  expect(result.todoFields).not.toContain("meta.description");
164
168
  expect(result.todoFields).toContain("meta.category");
@@ -167,38 +171,40 @@ describe("calculateFieldConfidence", () => {
167
171
  it("gives +25 for story variants", () => {
168
172
  const result = calculateFieldConfidence({
169
173
  component: { name: "Foo", sourcePath: "/foo.tsx", relativePath: "foo.tsx" },
170
- props: null,
171
- jsDoc: null,
172
- compoundChildren: [],
174
+ meta: null,
173
175
  storyVariants: [{ name: "Primary", args: {} }],
174
176
  });
175
177
  // +25 (stories)
176
178
  expect(result.score).toBe(25);
177
179
  });
178
180
 
179
- it("gives +5 for compound children", () => {
181
+ it("gives +10 for compound composition with parts", () => {
180
182
  const result = calculateFieldConfidence({
181
183
  component: { name: "Foo", sourcePath: "/foo.tsx", relativePath: "foo.tsx" },
182
- props: null,
183
- jsDoc: null,
184
- compoundChildren: ["Header", "Body"],
184
+ meta: makeMeta({
185
+ composition: {
186
+ pattern: "compound",
187
+ parts: [
188
+ { name: "Header", props: {} },
189
+ { name: "Body", props: {} },
190
+ ],
191
+ required: [],
192
+ },
193
+ }),
185
194
  storyVariants: [],
186
195
  });
187
- expect(result.score).toBe(5);
196
+ expect(result.score).toBe(10);
188
197
  });
189
198
 
190
199
  it("always includes usage.when and usage.whenNot in TODOs", () => {
191
200
  const result = calculateFieldConfidence({
192
201
  component: { name: "Button", sourcePath: "/button.tsx", relativePath: "button.tsx" },
193
- props: {
194
- filePath: "/button.tsx",
195
- componentName: "Button",
196
- props: makeProps(["onClick"]),
197
- success: true,
198
- warnings: [],
199
- },
200
- jsDoc: "A button",
201
- compoundChildren: [],
202
+ meta: makeMeta({
203
+ description: "A button",
204
+ props: {
205
+ onClick: { name: "onClick", type: "() => void", typeKind: "function", required: false, source: "local" },
206
+ },
207
+ }),
202
208
  storyVariants: [{ name: "Primary", args: {} }],
203
209
  });
204
210
  expect(result.todoFields).toContain("usage.when");
@@ -291,6 +297,10 @@ export function Button({ children, variant = 'primary', disabled, onClick }: But
291
297
  // Check props block has content
292
298
  expect(content).toContain("children:");
293
299
  expect(content).toContain("variant:");
300
+
301
+ // Check _generated provenance block
302
+ expect(content).toContain("_generated:");
303
+ expect(content).toContain("source: 'ai'");
294
304
  });
295
305
 
296
306
  it("skips existing fragments without --force", async () => {
@@ -305,4 +315,446 @@ export function Button({ children, variant = 'primary', disabled, onClick }: But
305
315
  expect(result.skipped[0].reason).toBe("Fragment already exists");
306
316
  expect(result.generated.length).toBe(0);
307
317
  });
318
+
319
+ it("generates a contract block with propsSummary", async () => {
320
+ const result = await scanGenerate({
321
+ scanPath: join(tmpDir, "components"),
322
+ force: true,
323
+ });
324
+
325
+ expect(result.success).toBe(true);
326
+
327
+ const { readFile: rf } = await import("node:fs/promises");
328
+ const content = await rf(
329
+ join(tmpDir, "components", "Button", "Button.fragment.tsx"),
330
+ "utf-8"
331
+ );
332
+
333
+ // Check contract block exists
334
+ expect(content).toContain("contract:");
335
+ expect(content).toContain("propsSummary:");
336
+
337
+ // Check propsSummary has entries for local props
338
+ expect(content).toContain("variant:");
339
+ expect(content).toContain("disabled:");
340
+ });
341
+ });
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Integration: compound component contract generation
345
+ // ---------------------------------------------------------------------------
346
+
347
+ describe("scanGenerate compound contract", () => {
348
+ let tmpDir: string;
349
+
350
+ beforeAll(async () => {
351
+ tmpDir = await mkdtemp(join(tmpdir(), "scan-compound-test-"));
352
+
353
+ const compDir = join(tmpDir, "components", "Card");
354
+ await mkdir(compDir, { recursive: true });
355
+
356
+ await writeFile(
357
+ join(compDir, "Card.tsx"),
358
+ `import React from 'react';
359
+
360
+ /** A card container for grouped content. */
361
+ export function Card({ children }: { children: React.ReactNode }) {
362
+ return <div>{children}</div>;
363
+ }
364
+
365
+ export function CardHeader({ children }: { children: React.ReactNode }) {
366
+ return <header>{children}</header>;
367
+ }
368
+
369
+ export function CardBody({ children }: { children: React.ReactNode }) {
370
+ return <main>{children}</main>;
371
+ }
372
+
373
+ export function CardFooter({ children }: { children: React.ReactNode }) {
374
+ return <footer>{children}</footer>;
375
+ }
376
+ `,
377
+ "utf-8"
378
+ );
379
+ });
380
+
381
+ afterAll(async () => {
382
+ await rm(tmpDir, { recursive: true, force: true });
383
+ });
384
+
385
+ it("generates compoundChildren in contract for compound components", async () => {
386
+ const result = await scanGenerate({
387
+ scanPath: join(tmpDir, "components"),
388
+ force: true,
389
+ });
390
+
391
+ expect(result.success).toBe(true);
392
+ expect(result.generated.length).toBe(1);
393
+ expect(result.generated[0].name).toBe("Card");
394
+
395
+ const content = await rf(
396
+ join(tmpDir, "components", "Card", "Card.fragment.tsx"),
397
+ "utf-8"
398
+ );
399
+
400
+ // Should have contract with compoundChildren
401
+ expect(content).toContain("contract:");
402
+ expect(content).toContain("compoundChildren:");
403
+ expect(content).toContain("Header:");
404
+ expect(content).toContain("Body:");
405
+ expect(content).toContain("Footer:");
406
+
407
+ // Should have canonicalUsage
408
+ expect(content).toContain("canonicalUsage:");
409
+ expect(content).toContain("Card.Header");
410
+ expect(content).toContain("Card.Body");
411
+ expect(content).toContain("Card.Footer");
412
+ });
413
+ });
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Enrichment: parseEnrichmentResponse
417
+ // ---------------------------------------------------------------------------
418
+
419
+ describe("parseEnrichmentResponse", () => {
420
+ it("parses valid JSON response", () => {
421
+ const result = parseEnrichmentResponse(JSON.stringify({
422
+ when: ["Display grouped content", "Card-based layouts"],
423
+ whenNot: ["Simple text — use a div"],
424
+ guidelines: ["Use Card.Header for titles"],
425
+ a11yRules: ["Must have accessible name"],
426
+ scenarioTags: ["layout.container.card"],
427
+ tags: ["card", "container"],
428
+ }));
429
+
430
+ expect(result.when).toEqual(["Display grouped content", "Card-based layouts"]);
431
+ expect(result.whenNot).toEqual(["Simple text — use a div"]);
432
+ expect(result.guidelines).toEqual(["Use Card.Header for titles"]);
433
+ expect(result.a11yRules).toEqual(["Must have accessible name"]);
434
+ expect(result.scenarioTags).toEqual(["layout.container.card"]);
435
+ expect(result.tags).toEqual(["card", "container"]);
436
+ });
437
+
438
+ it("handles ```json fences", () => {
439
+ const text = '```json\n{"when": ["Use for actions"], "whenNot": [], "guidelines": [], "a11yRules": [], "scenarioTags": [], "tags": []}\n```';
440
+ const result = parseEnrichmentResponse(text);
441
+ expect(result.when).toEqual(["Use for actions"]);
442
+ });
443
+
444
+ it("returns safe empty defaults on malformed JSON", () => {
445
+ const result = parseEnrichmentResponse("this is not json at all");
446
+ expect(result.when).toEqual([]);
447
+ expect(result.whenNot).toEqual([]);
448
+ expect(result.guidelines).toEqual([]);
449
+ expect(result.a11yRules).toEqual([]);
450
+ expect(result.scenarioTags).toEqual([]);
451
+ expect(result.tags).toEqual([]);
452
+ });
453
+
454
+ it("truncates arrays exceeding max length", () => {
455
+ const result = parseEnrichmentResponse(JSON.stringify({
456
+ when: ["a", "b", "c", "d", "e", "f", "g"],
457
+ whenNot: ["a", "b", "c", "d", "e"],
458
+ guidelines: ["a", "b", "c", "d"],
459
+ a11yRules: ["a", "b", "c", "d", "e"],
460
+ scenarioTags: ["a", "b", "c", "d", "e", "f"],
461
+ tags: ["a", "b", "c", "d", "e", "f"],
462
+ }));
463
+
464
+ expect(result.when).toHaveLength(5);
465
+ expect(result.whenNot).toHaveLength(4);
466
+ expect(result.guidelines).toHaveLength(3);
467
+ expect(result.a11yRules).toHaveLength(4);
468
+ expect(result.scenarioTags).toHaveLength(5);
469
+ expect(result.tags).toHaveLength(5);
470
+ });
471
+
472
+ it("filters out non-string array items", () => {
473
+ const result = parseEnrichmentResponse(JSON.stringify({
474
+ when: ["valid", 42, null, "also valid"],
475
+ whenNot: [],
476
+ guidelines: [],
477
+ a11yRules: [],
478
+ scenarioTags: [],
479
+ tags: [],
480
+ }));
481
+
482
+ expect(result.when).toEqual(["valid", "also valid"]);
483
+ });
484
+ });
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Enrichment: calculateFieldConfidence with enrichment
488
+ // ---------------------------------------------------------------------------
489
+
490
+ describe("calculateFieldConfidence with enrichment", () => {
491
+ const enrichment: EnrichmentResult = {
492
+ when: ["Use for grouped content"],
493
+ whenNot: ["Simple text grouping"],
494
+ guidelines: ["Always include a header"],
495
+ a11yRules: ["Use semantic landmarks"],
496
+ scenarioTags: ["layout.card"],
497
+ tags: ["card", "container"],
498
+ };
499
+
500
+ it("gives higher score with enrichment", () => {
501
+ const withoutEnrich = calculateFieldConfidence({
502
+ component: { name: "Card", sourcePath: "/card.tsx", relativePath: "card.tsx" },
503
+ meta: makeMeta({ description: "A card" }),
504
+ storyVariants: [],
505
+ });
506
+
507
+ const withEnrich = calculateFieldConfidence(
508
+ {
509
+ component: { name: "Card", sourcePath: "/card.tsx", relativePath: "card.tsx" },
510
+ meta: makeMeta({ description: "A card" }),
511
+ storyVariants: [],
512
+ },
513
+ enrichment
514
+ );
515
+
516
+ expect(withEnrich.score).toBeGreaterThan(withoutEnrich.score);
517
+ });
518
+
519
+ it("removes usage.when and usage.whenNot from todoFields when enriched", () => {
520
+ const result = calculateFieldConfidence(
521
+ {
522
+ component: { name: "Card", sourcePath: "/card.tsx", relativePath: "card.tsx" },
523
+ meta: makeMeta({ description: "A card" }),
524
+ storyVariants: [],
525
+ },
526
+ enrichment
527
+ );
528
+
529
+ expect(result.todoFields).not.toContain("usage.when");
530
+ expect(result.todoFields).not.toContain("usage.whenNot");
531
+ });
532
+
533
+ it("still includes usage TODOs without enrichment (backward compat)", () => {
534
+ const result = calculateFieldConfidence({
535
+ component: { name: "Card", sourcePath: "/card.tsx", relativePath: "card.tsx" },
536
+ meta: makeMeta({ description: "A card" }),
537
+ storyVariants: [],
538
+ });
539
+
540
+ expect(result.todoFields).toContain("usage.when");
541
+ expect(result.todoFields).toContain("usage.whenNot");
542
+ });
543
+ });
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // Enrichment: buildEnrichmentSystemPrompt / buildEnrichmentUserPrompt
547
+ // ---------------------------------------------------------------------------
548
+
549
+ describe("enrichment prompts", () => {
550
+ it("buildEnrichmentSystemPrompt returns a non-empty system prompt", () => {
551
+ const prompt = buildEnrichmentSystemPrompt();
552
+ expect(prompt).toContain("senior frontend architect");
553
+ expect(prompt).toContain("when");
554
+ expect(prompt).toContain("whenNot");
555
+ expect(prompt).toContain("a11yRules");
556
+ expect(prompt).toContain("scenarioTags");
557
+ });
558
+
559
+ it("buildEnrichmentUserPrompt includes component metadata", () => {
560
+ const prompt = buildEnrichmentUserPrompt("Button", {
561
+ component: { name: "Button", sourcePath: "/button.tsx", relativePath: "button.tsx" },
562
+ meta: makeMeta({
563
+ name: "Button",
564
+ description: "A clickable button",
565
+ props: {
566
+ variant: {
567
+ name: "variant",
568
+ type: "string",
569
+ typeKind: "enum",
570
+ required: false,
571
+ source: "local",
572
+ values: ["primary", "secondary"],
573
+ },
574
+ },
575
+ }),
576
+ storyVariants: [{ name: "Primary", args: { variant: "primary" } }],
577
+ });
578
+
579
+ expect(prompt).toContain("Component: Button");
580
+ expect(prompt).toContain("A clickable button");
581
+ expect(prompt).toContain("variant");
582
+ expect(prompt).toContain("primary | secondary");
583
+ expect(prompt).toContain("Known variants: Primary");
584
+ });
585
+ });
586
+
587
+ // ---------------------------------------------------------------------------
588
+ // Enrichment: buildEnrichedUsageBlock
589
+ // ---------------------------------------------------------------------------
590
+
591
+ describe("buildEnrichedUsageBlock", () => {
592
+ it("generates usage block with all enrichment fields", () => {
593
+ const block = buildEnrichedUsageBlock({
594
+ when: ["Grouped content", "Dashboard cards"],
595
+ whenNot: ["Simple text"],
596
+ guidelines: ["Always include header"],
597
+ a11yRules: [],
598
+ scenarioTags: [],
599
+ tags: [],
600
+ });
601
+
602
+ expect(block).toContain("usage: {");
603
+ expect(block).toContain("when: [");
604
+ expect(block).toContain("'Grouped content'");
605
+ expect(block).toContain("'Dashboard cards'");
606
+ expect(block).toContain("whenNot: [");
607
+ expect(block).toContain("'Simple text'");
608
+ expect(block).toContain("guidelines: [");
609
+ expect(block).toContain("'Always include header'");
610
+ });
611
+
612
+ it("omits guidelines block when empty", () => {
613
+ const block = buildEnrichedUsageBlock({
614
+ when: ["Use for actions"],
615
+ whenNot: [],
616
+ guidelines: [],
617
+ a11yRules: [],
618
+ scenarioTags: [],
619
+ tags: [],
620
+ });
621
+
622
+ expect(block).toContain("when: [");
623
+ expect(block).not.toContain("guidelines:");
624
+ });
625
+ });
626
+
627
+ // ---------------------------------------------------------------------------
628
+ // Integration: scanGenerate with --enrich (mocked AI)
629
+ // ---------------------------------------------------------------------------
630
+
631
+ describe("scanGenerate with enrichment", () => {
632
+ let tmpDir: string;
633
+
634
+ beforeAll(async () => {
635
+ tmpDir = await mkdtemp(join(tmpdir(), "scan-enrich-test-"));
636
+
637
+ const compDir = join(tmpDir, "components", "Badge");
638
+ await mkdir(compDir, { recursive: true });
639
+
640
+ await writeFile(
641
+ join(compDir, "Badge.tsx"),
642
+ `import React from 'react';
643
+
644
+ /** A badge for showing counts or labels. */
645
+ export interface BadgeProps {
646
+ /** Badge content */
647
+ children: React.ReactNode;
648
+ /** Visual variant */
649
+ variant?: 'default' | 'success' | 'warning' | 'error';
650
+ }
651
+
652
+ export function Badge({ children, variant = 'default' }: BadgeProps) {
653
+ return <span>{children}</span>;
654
+ }
655
+ `,
656
+ "utf-8"
657
+ );
658
+ });
659
+
660
+ afterAll(async () => {
661
+ await rm(tmpDir, { recursive: true, force: true });
662
+ });
663
+
664
+ it("generates enriched fragment with mocked AI", async () => {
665
+ // Mock the ai-client module
666
+ vi.doMock("../../ai-client.js", () => ({
667
+ detectProvider: () => "anthropic",
668
+ getApiKey: () => "sk-ant-test-key",
669
+ createAIClient: async () => ({}),
670
+ generateCompletion: async () => ({
671
+ text: JSON.stringify({
672
+ when: ["Showing status counts", "Labeling items"],
673
+ whenNot: ["Long text content — use a Tag instead"],
674
+ guidelines: ["Keep text short (1-3 words)"],
675
+ a11yRules: ["Use aria-label for icon-only badges"],
676
+ scenarioTags: ["feedback.indicator.badge"],
677
+ tags: ["badge", "count", "label"],
678
+ }),
679
+ inputTokens: 200,
680
+ outputTokens: 100,
681
+ }),
682
+ calculateCost: () => 0.001,
683
+ ENRICHMENT_MODELS: { anthropic: "claude-haiku-4-5-20251001", openai: "gpt-4o-mini", none: "" },
684
+ }));
685
+
686
+ // Re-import to pick up the mock
687
+ const { scanGenerate: scanGenerateMocked } = await import("../scan-generate.js");
688
+
689
+ const result = await scanGenerateMocked({
690
+ scanPath: join(tmpDir, "components"),
691
+ force: true,
692
+ enrich: true,
693
+ });
694
+
695
+ expect(result.success).toBe(true);
696
+ expect(result.generated.length).toBe(1);
697
+ expect(result.generated[0].enriched).toBe(true);
698
+
699
+ const content = await rf(
700
+ join(tmpDir, "components", "Badge", "Badge.fragment.tsx"),
701
+ "utf-8"
702
+ );
703
+
704
+ // Enriched usage fields should be present (not TODO markers)
705
+ expect(content).toContain("'Showing status counts'");
706
+ expect(content).toContain("'Labeling items'");
707
+ expect(content).toContain("'Long text content — use a Tag instead'");
708
+ expect(content).toContain("guidelines:");
709
+ expect(content).toContain("'Keep text short (1-3 words)'");
710
+
711
+ // Meta tags should be present
712
+ expect(content).toContain("tags:");
713
+ expect(content).toContain("'badge'");
714
+
715
+ // Contract should have enriched a11yRules and scenarioTags
716
+ expect(content).toContain("a11yRules:");
717
+ expect(content).toContain("'Use aria-label for icon-only badges'");
718
+ expect(content).toContain("scenarioTags:");
719
+ expect(content).toContain("'feedback.indicator.badge'");
720
+
721
+ // Should NOT have TODO markers for usage.when or usage.whenNot
722
+ expect(content).not.toContain("// TODO: Describe when to use Badge");
723
+ expect(content).not.toContain("// TODO: Describe when NOT to use Badge");
724
+
725
+ vi.doUnmock("../../ai-client.js");
726
+ });
727
+
728
+ it("skips enrichment in dry-run mode without API calls", async () => {
729
+ const result = await scanGenerate({
730
+ scanPath: join(tmpDir, "components"),
731
+ force: true,
732
+ enrich: true,
733
+ dryRun: true,
734
+ });
735
+
736
+ expect(result.success).toBe(true);
737
+ // Dry run should not enrich (no API key in env for real calls)
738
+ expect(result.generated[0].enriched).toBeFalsy();
739
+ });
740
+
741
+ it("generates identical output without --enrich (backward compat)", async () => {
742
+ const result = await scanGenerate({
743
+ scanPath: join(tmpDir, "components"),
744
+ force: true,
745
+ });
746
+
747
+ expect(result.success).toBe(true);
748
+
749
+ const content = await rf(
750
+ join(tmpDir, "components", "Badge", "Badge.fragment.tsx"),
751
+ "utf-8"
752
+ );
753
+
754
+ // Should have TODO markers (no enrichment)
755
+ expect(content).toContain("// TODO: Describe when to use Badge");
756
+ expect(content).toContain("// TODO: Describe when NOT to use Badge");
757
+ expect(result.generated[0].enriched).toBeFalsy();
758
+ expect(result.enrichmentCost).toBeUndefined();
759
+ });
308
760
  });
@@ -34,11 +34,14 @@ import {
34
34
  type PropsExtractionResult,
35
35
  type RenderedVariant,
36
36
  } from '../service/index.js';
37
+ import {
38
+ type AIProvider,
39
+ detectProvider as detectProviderShared,
40
+ getApiKey as getApiKeyShared,
41
+ createAIClient,
42
+ } from '../ai-client.js';
37
43
 
38
- /**
39
- * Supported AI providers
40
- */
41
- export type AIProvider = 'anthropic' | 'openai' | 'none';
44
+ export type { AIProvider };
42
45
 
43
46
  /**
44
47
  * Options for enhance command
@@ -558,45 +561,18 @@ For each component, provide your response in JSON format:
558
561
  }
559
562
 
560
563
  /**
561
- * Detect which AI provider to use based on available API keys
564
+ * Detect which AI provider to use, with enhance-specific contextOnly mapping.
562
565
  */
563
566
  function detectProvider(options: EnhanceOptions): AIProvider {
564
567
  if (options.contextOnly) return 'none';
565
- if (options.provider) return options.provider;
566
- if (options.apiKey) {
567
- // Try to guess from key format
568
- if (options.apiKey.startsWith('sk-ant-')) return 'anthropic';
569
- if (options.apiKey.startsWith('sk-')) return 'openai';
570
- }
571
- // Check environment variables
572
- if (process.env.ANTHROPIC_API_KEY) return 'anthropic';
573
- if (process.env.OPENAI_API_KEY) return 'openai';
574
- return 'none';
568
+ return detectProviderShared({ provider: options.provider, apiKey: options.apiKey });
575
569
  }
576
570
 
577
571
  /**
578
- * Get API key for the provider
572
+ * Get API key for the provider.
579
573
  */
580
574
  function getApiKey(provider: AIProvider, explicitKey?: string): string | undefined {
581
- if (explicitKey) return explicitKey;
582
- if (provider === 'anthropic') return process.env.ANTHROPIC_API_KEY;
583
- if (provider === 'openai') return process.env.OPENAI_API_KEY;
584
- return undefined;
585
- }
586
-
587
- /**
588
- * Create AI client for the provider
589
- */
590
- async function createAIClient(provider: AIProvider, apiKey: string): Promise<unknown> {
591
- if (provider === 'anthropic') {
592
- const Anthropic = (await import('@anthropic-ai/sdk')).default;
593
- return new Anthropic({ apiKey });
594
- }
595
- if (provider === 'openai') {
596
- const OpenAI = (await import('openai')).default;
597
- return new OpenAI({ apiKey });
598
- }
599
- throw new Error(`Unknown provider: ${provider}`);
575
+ return getApiKeyShared(provider, explicitKey);
600
576
  }
601
577
 
602
578
  /**