@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.
- package/dist/ai-client-I6MDWNYA.js +21 -0
- package/dist/bin.js +419 -410
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-HRFUSSZI.js → chunk-3SOAPJDX.js} +2 -2
- package/dist/{chunk-D5PYOXEI.js → chunk-4K7EAQ5L.js} +148 -13
- package/dist/{chunk-D5PYOXEI.js.map → chunk-4K7EAQ5L.js.map} +1 -1
- package/dist/chunk-DXX6HADE.js +443 -0
- package/dist/chunk-DXX6HADE.js.map +1 -0
- package/dist/chunk-EYXVAMEX.js +626 -0
- package/dist/chunk-EYXVAMEX.js.map +1 -0
- package/dist/{chunk-ZM4ZQZWZ.js → chunk-FO6EBJWP.js} +39 -37
- package/dist/chunk-FO6EBJWP.js.map +1 -0
- package/dist/{chunk-OQO55NKV.js → chunk-QM7SVOGF.js} +120 -12
- package/dist/chunk-QM7SVOGF.js.map +1 -0
- package/dist/{chunk-5G3VZH43.js → chunk-RF3C6LGA.js} +281 -351
- package/dist/chunk-RF3C6LGA.js.map +1 -0
- package/dist/{chunk-WXSR2II7.js → chunk-SM674YAS.js} +58 -6
- package/dist/chunk-SM674YAS.js.map +1 -0
- package/dist/chunk-SXTKFDCR.js +104 -0
- package/dist/chunk-SXTKFDCR.js.map +1 -0
- package/dist/{chunk-PW7QTQA6.js → chunk-UV5JQV3R.js} +2 -2
- package/dist/core/index.js +13 -1
- package/dist/{discovery-NEOY4MPN.js → discovery-VSGC76JN.js} +3 -3
- package/dist/{generate-FBHSXR3D.js → generate-QZXOXYFW.js} +4 -4
- package/dist/index.js +7 -6
- package/dist/index.js.map +1 -1
- package/dist/init-XK6PRUE5.js +636 -0
- package/dist/init-XK6PRUE5.js.map +1 -0
- package/dist/mcp-bin.js +2 -2
- package/dist/{scan-CJF2DOQW.js → scan-CHQHXWVD.js} +6 -6
- package/dist/scan-generate-U3RFVDTX.js +1115 -0
- package/dist/scan-generate-U3RFVDTX.js.map +1 -0
- package/dist/{service-TQYWY65E.js → service-MMEKG4MZ.js} +3 -3
- package/dist/{snapshot-SV2JOFZH.js → snapshot-53TUR3HW.js} +2 -2
- package/dist/{static-viewer-NUBFPKWH.js → static-viewer-KKCR4KXR.js} +3 -3
- package/dist/static-viewer-KKCR4KXR.js.map +1 -0
- package/dist/{test-Z5LVO724.js → test-5UCKXYSC.js} +4 -4
- package/dist/{tokens-CE46OTMD.js → tokens-L46MK5AW.js} +5 -5
- package/dist/{viewer-DLLJIMCK.js → viewer-M2EQQSGE.js} +14 -14
- package/dist/viewer-M2EQQSGE.js.map +1 -0
- package/package.json +11 -9
- package/src/ai-client.ts +156 -0
- package/src/bin.ts +99 -2
- package/src/build.ts +95 -33
- package/src/commands/__tests__/drift-sync.test.ts +252 -0
- package/src/commands/__tests__/scan-generate.test.ts +497 -45
- package/src/commands/enhance.ts +11 -35
- package/src/commands/govern.ts +122 -0
- package/src/commands/init.ts +288 -260
- package/src/commands/scan-generate.ts +740 -139
- package/src/commands/scan.ts +37 -32
- package/src/commands/setup.ts +143 -52
- package/src/commands/sync.ts +357 -0
- package/src/commands/validate.ts +43 -1
- package/src/core/component-extractor.test.ts +282 -0
- package/src/core/component-extractor.ts +1030 -0
- package/src/core/discovery.ts +93 -7
- package/src/service/enhance/props-extractor.ts +235 -13
- package/src/validators.ts +236 -0
- package/src/viewer/vite-plugin.ts +1 -1
- package/dist/chunk-5G3VZH43.js.map +0 -1
- package/dist/chunk-OQO55NKV.js.map +0 -1
- package/dist/chunk-WXSR2II7.js.map +0 -1
- package/dist/chunk-ZM4ZQZWZ.js.map +0 -1
- package/dist/init-UFGK5TCN.js +0 -867
- package/dist/init-UFGK5TCN.js.map +0 -1
- package/dist/scan-generate-SJAN5MVI.js +0 -691
- package/dist/scan-generate-SJAN5MVI.js.map +0 -1
- package/dist/viewer-DLLJIMCK.js.map +0 -1
- package/src/ai.ts +0 -266
- package/src/commands/init-framework.ts +0 -414
- package/src/mcp/bin.ts +0 -36
- package/src/migrate/bin.ts +0 -114
- package/src/theme/index.ts +0 -77
- package/src/viewer/bin.ts +0 -86
- package/src/viewer/cli/health.ts +0 -256
- package/src/viewer/cli/index.ts +0 -33
- package/src/viewer/cli/scan.ts +0 -124
- package/src/viewer/cli/utils.ts +0 -174
- /package/dist/{discovery-NEOY4MPN.js.map → ai-client-I6MDWNYA.js.map} +0 -0
- /package/dist/{chunk-HRFUSSZI.js.map → chunk-3SOAPJDX.js.map} +0 -0
- /package/dist/{chunk-PW7QTQA6.js.map → chunk-UV5JQV3R.js.map} +0 -0
- /package/dist/{scan-CJF2DOQW.js.map → discovery-VSGC76JN.js.map} +0 -0
- /package/dist/{generate-FBHSXR3D.js.map → generate-QZXOXYFW.js.map} +0 -0
- /package/dist/{service-TQYWY65E.js.map → scan-CHQHXWVD.js.map} +0 -0
- /package/dist/{static-viewer-NUBFPKWH.js.map → service-MMEKG4MZ.js.map} +0 -0
- /package/dist/{snapshot-SV2JOFZH.js.map → snapshot-53TUR3HW.js.map} +0 -0
- /package/dist/{test-Z5LVO724.js.map → test-5UCKXYSC.js.map} +0 -0
- /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 {
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
157
|
-
jsDoc: "A component",
|
|
158
|
-
compoundChildren: [],
|
|
162
|
+
meta: makeMeta({ description: "A component" }),
|
|
159
163
|
storyVariants: [],
|
|
160
164
|
});
|
|
161
|
-
// +15 (
|
|
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
|
-
|
|
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 +
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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(
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
});
|
package/src/commands/enhance.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|