@fragments-sdk/cli 0.14.2 → 0.15.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/README.md +0 -3
- package/dist/bin.js +4290 -3754
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-TXFCEDOC.js → chunk-2WXKALIG.js} +2 -2
- package/dist/{chunk-I34BC3CU.js → chunk-32LIWN2P.js} +1006 -3
- package/dist/chunk-32LIWN2P.js.map +1 -0
- package/dist/{chunk-55KERLWL.js → chunk-65WSVDV5.js} +314 -89
- package/dist/chunk-65WSVDV5.js.map +1 -0
- package/dist/chunk-7DZC4YEV.js +294 -0
- package/dist/chunk-7DZC4YEV.js.map +1 -0
- package/dist/{chunk-LOYS64QS.js → chunk-7WHVW72L.js} +230 -19
- package/dist/chunk-7WHVW72L.js.map +1 -0
- package/dist/{chunk-PJT5IZ37.js → chunk-BJE3425I.js} +19 -52
- package/dist/{chunk-PJT5IZ37.js.map → chunk-BJE3425I.js.map} +1 -1
- package/dist/{chunk-5A6X2Y73.js → chunk-CZD3AD4Q.js} +12 -11
- package/dist/chunk-CZD3AD4Q.js.map +1 -0
- package/dist/{chunk-EYXVAMEX.js → chunk-MN3TJ3D5.js} +72 -3
- package/dist/chunk-MN3TJ3D5.js.map +1 -0
- package/dist/chunk-QCN35LJU.js +630 -0
- package/dist/chunk-QCN35LJU.js.map +1 -0
- package/dist/chunk-T47OLCSF.js +36 -0
- package/dist/chunk-T47OLCSF.js.map +1 -0
- package/dist/{chunk-APTQIBS5.js → chunk-XJQ5BIWI.js} +144 -1049
- package/dist/chunk-XJQ5BIWI.js.map +1 -0
- package/dist/codebase-scanner-VOTPXRYW.js +22 -0
- package/dist/converter-JLINP7CJ.js +34 -0
- package/dist/converter-JLINP7CJ.js.map +1 -0
- package/dist/core/index.js +43 -1
- package/dist/{generate-RYWIPDN2.js → generate-A4FP5426.js} +3 -4
- package/dist/{generate-RYWIPDN2.js.map → generate-A4FP5426.js.map} +1 -1
- package/dist/govern-scan-UCBZR6D6.js +280 -0
- package/dist/govern-scan-UCBZR6D6.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +11 -11
- package/dist/{init-WRUSW7R5.js → init-HGSM35XA.js} +131 -128
- package/dist/init-HGSM35XA.js.map +1 -0
- package/dist/{init-cloud-REQ3XLHO.js → init-cloud-MQ6GRJAZ.js} +2 -2
- package/dist/mcp-bin.js +5 -36
- package/dist/mcp-bin.js.map +1 -1
- package/dist/scan-VNNKACG2.js +15 -0
- package/dist/{scan-generate-TFZVL3BT.js → scan-generate-TWRHNU5M.js} +335 -46
- package/dist/scan-generate-TWRHNU5M.js.map +1 -0
- package/dist/scanner-7LAZYPWZ.js +13 -0
- package/dist/{service-HKJ6B7P7.js → service-FHQU7YS7.js} +27 -23
- package/dist/{snapshot-C5DYIGIV.js → snapshot-KQEQ6XHL.js} +2 -2
- package/dist/{static-viewer-DUVC4UIM.js → static-viewer-63PG6FWY.js} +3 -3
- package/dist/static-viewer-63PG6FWY.js.map +1 -0
- package/dist/{test-JW7JIDFG.js → test-UQYUCZIS.js} +4 -6
- package/dist/{test-JW7JIDFG.js.map → test-UQYUCZIS.js.map} +1 -1
- package/dist/{tokens-KE73G5JC.js → tokens-6GYKDV6U.js} +6 -5
- package/dist/{tokens-KE73G5JC.js.map → tokens-6GYKDV6U.js.map} +1 -1
- package/dist/tokens-generate-VTZV5EEW.js +86 -0
- package/dist/tokens-generate-VTZV5EEW.js.map +1 -0
- package/package.json +6 -6
- package/src/bin.ts +210 -48
- package/src/build.ts +130 -6
- package/src/commands/__fixtures__/shadcn-label-wrapper/package.json +7 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +42 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.tsx +11 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +20 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.tsx +14 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/tsconfig.app.json +23 -0
- package/src/commands/__tests__/init.test.ts +113 -0
- package/src/commands/__tests__/scan-generate.test.ts +188 -69
- package/src/commands/__tests__/verify.test.ts +91 -0
- package/src/commands/discover.ts +151 -0
- package/src/commands/enhance.ts +3 -1
- package/src/commands/govern-scan.ts +386 -0
- package/src/commands/govern.ts +2 -2
- package/src/commands/init.ts +152 -28
- package/src/commands/inspect.ts +290 -0
- package/src/commands/migrate-contract.ts +85 -0
- package/src/commands/scan-generate.ts +438 -50
- package/src/commands/scan.ts +1 -0
- package/src/commands/setup.ts +27 -50
- package/src/commands/tokens-generate.ts +113 -0
- package/src/commands/verify.ts +195 -1
- package/src/core/__fixtures__/shadcn-input/input.tsx +7 -0
- package/src/core/__fixtures__/shadcn-input/tsconfig.json +14 -0
- package/src/core/__fixtures__/shadcn-label/label.tsx +11 -0
- package/src/core/__fixtures__/shadcn-label/primitive.tsx +14 -0
- package/src/core/__fixtures__/shadcn-label/tsconfig.json +14 -0
- package/src/core/__fixtures__/shadcn-radix-label/label.tsx +11 -0
- package/src/core/__fixtures__/shadcn-radix-label/node_modules/radix-ui/index.d.ts +12 -0
- package/src/core/__fixtures__/shadcn-radix-label/tsconfig.json +14 -0
- package/src/core/__tests__/contract-parity.test.ts +316 -0
- package/src/core/component-extractor.test.ts +39 -0
- package/src/core/component-extractor.ts +92 -1
- package/src/core/config.ts +2 -1
- package/src/core/discovery.ts +13 -2
- package/src/core/drift-verifier.ts +123 -0
- package/src/core/extractor-adapter.ts +80 -0
- package/src/mcp/__tests__/projectFields.test.ts +1 -1
- package/src/mcp/utils.ts +1 -50
- package/src/migrate/converter.ts +3 -3
- package/src/migrate/fragment-to-contract.ts +253 -0
- package/src/migrate/report.ts +1 -1
- package/src/scripts/token-benchmark.ts +121 -0
- package/src/service/__tests__/props-extractor.test.ts +94 -0
- package/src/service/__tests__/token-normalizer.test.ts +690 -0
- package/src/service/ast-utils.ts +4 -23
- package/src/service/babel-config.ts +23 -0
- package/src/service/enhance/converter.ts +61 -0
- package/src/service/enhance/props-extractor.ts +25 -8
- package/src/service/enhance/scanner.ts +5 -24
- package/src/service/snippet-validation.ts +9 -3
- package/src/service/token-normalizer.ts +510 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/project-fields.ts +46 -0
- package/src/viewer/__tests__/viewer-integration.test.ts +8 -8
- package/src/viewer/preview-adapter.ts +116 -0
- package/src/viewer/style-utils.ts +27 -412
- package/src/viewer/vite-plugin.ts +2 -2
- package/dist/chunk-55KERLWL.js.map +0 -1
- package/dist/chunk-5A6X2Y73.js.map +0 -1
- package/dist/chunk-APTQIBS5.js.map +0 -1
- package/dist/chunk-EYXVAMEX.js.map +0 -1
- package/dist/chunk-I34BC3CU.js.map +0 -1
- package/dist/chunk-LOYS64QS.js.map +0 -1
- package/dist/chunk-ZKTFKHWN.js +0 -324
- package/dist/chunk-ZKTFKHWN.js.map +0 -1
- package/dist/discovery-VDANZAJ2.js +0 -28
- package/dist/init-WRUSW7R5.js.map +0 -1
- package/dist/scan-YJHQIRKG.js +0 -14
- package/dist/scan-generate-TFZVL3BT.js.map +0 -1
- package/dist/viewer-2TZS3NDL.js +0 -2730
- package/dist/viewer-2TZS3NDL.js.map +0 -1
- package/src/commands/dev.ts +0 -107
- /package/dist/{chunk-TXFCEDOC.js.map → chunk-2WXKALIG.js.map} +0 -0
- /package/dist/{discovery-VDANZAJ2.js.map → codebase-scanner-VOTPXRYW.js.map} +0 -0
- /package/dist/{init-cloud-REQ3XLHO.js.map → init-cloud-MQ6GRJAZ.js.map} +0 -0
- /package/dist/{scan-YJHQIRKG.js.map → scan-VNNKACG2.js.map} +0 -0
- /package/dist/{service-HKJ6B7P7.js.map → scanner-7LAZYPWZ.js.map} +0 -0
- /package/dist/{static-viewer-DUVC4UIM.js.map → service-FHQU7YS7.js.map} +0 -0
- /package/dist/{snapshot-C5DYIGIV.js.map → snapshot-KQEQ6XHL.js.map} +0 -0
package/src/commands/enhance.ts
CHANGED
|
@@ -266,6 +266,7 @@ export async function enhance(options: EnhanceOptions = {}): Promise<EnhanceResu
|
|
|
266
266
|
if (fs.existsSync(srcPath)) {
|
|
267
267
|
const extraction = await extractPropsFromFile(srcPath, {
|
|
268
268
|
propsTypeName: `${compName}Props`,
|
|
269
|
+
componentName: compName,
|
|
269
270
|
});
|
|
270
271
|
if (extraction.success) {
|
|
271
272
|
propsExtractions.set(compName, extraction);
|
|
@@ -763,7 +764,8 @@ function calculateCost(provider: AIProvider, tokens: number): number {
|
|
|
763
764
|
*/
|
|
764
765
|
async function findFragmentFiles(dir: string): Promise<string[]> {
|
|
765
766
|
const fg = await import('fast-glob');
|
|
766
|
-
|
|
767
|
+
// Search for both .contract.json (preferred) and legacy .fragment.tsx files
|
|
768
|
+
return fg.default(['**/*.contract.json', '**/*.fragment.tsx', '**/*.fragment.ts'], {
|
|
767
769
|
cwd: dir,
|
|
768
770
|
absolute: true,
|
|
769
771
|
ignore: ['**/node_modules/**', '**/dist/**'],
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* govern scan / govern watch — Zero-config governance scanning
|
|
3
|
+
*
|
|
4
|
+
* Parses real JSX/TSX files via the existing codebase scanner, converts
|
|
5
|
+
* component usages to UISpec, and runs governance checks per file.
|
|
6
|
+
* Optionally submits results to Fragments Cloud.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import pc from 'picocolors';
|
|
10
|
+
import { resolve, relative } from 'node:path';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { BRAND } from '../core/index.js';
|
|
13
|
+
import type { ComponentUsage } from '../service/enhance/types.js';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Options
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface GovernScanOptions {
|
|
20
|
+
/** Root directory to scan (default: auto-detect) */
|
|
21
|
+
dir?: string;
|
|
22
|
+
/** Path to govern.config.ts */
|
|
23
|
+
config?: string;
|
|
24
|
+
/** Output format */
|
|
25
|
+
format?: 'summary' | 'json' | 'sarif';
|
|
26
|
+
/** Suppress non-error output */
|
|
27
|
+
quiet?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GovernWatchOptions extends GovernScanOptions {
|
|
31
|
+
/** Debounce interval in ms (default: 300) */
|
|
32
|
+
debounce?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Scan defaults — applied when no config file exists
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const SCAN_DEFAULT_RULES: Record<string, boolean | object> = {
|
|
40
|
+
'safety/no-dangerous-html': true,
|
|
41
|
+
'safety/sanitize-hrefs': true,
|
|
42
|
+
'safety/no-inline-scripts': true,
|
|
43
|
+
'safety/no-exposed-secrets': true,
|
|
44
|
+
'tokens/require-design-tokens': true,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Helpers
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Auto-detect root directory by looking for common React project dirs
|
|
53
|
+
*/
|
|
54
|
+
function detectRootDir(cwd: string): string {
|
|
55
|
+
const candidates = ['src', 'app', 'pages', 'components'];
|
|
56
|
+
for (const dir of candidates) {
|
|
57
|
+
if (existsSync(resolve(cwd, dir))) {
|
|
58
|
+
return cwd;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return cwd;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Group component usages by their source file
|
|
66
|
+
*/
|
|
67
|
+
function groupByFile(usages: ComponentUsage[]): Map<string, ComponentUsage[]> {
|
|
68
|
+
const grouped = new Map<string, ComponentUsage[]>();
|
|
69
|
+
for (const usage of usages) {
|
|
70
|
+
const existing = grouped.get(usage.filePath);
|
|
71
|
+
if (existing) {
|
|
72
|
+
existing.push(usage);
|
|
73
|
+
} else {
|
|
74
|
+
grouped.set(usage.filePath, [usage]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return grouped;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// governScan
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
export async function governScan(
|
|
85
|
+
options: GovernScanOptions = {},
|
|
86
|
+
): Promise<{ exitCode: number }> {
|
|
87
|
+
const {
|
|
88
|
+
loadPolicy,
|
|
89
|
+
createEngine,
|
|
90
|
+
buildAdaptersFromConfig,
|
|
91
|
+
createCloudAdapter,
|
|
92
|
+
formatVerdict,
|
|
93
|
+
} = await import('@fragments-sdk/govern');
|
|
94
|
+
|
|
95
|
+
const { scanCodebase } = await import(
|
|
96
|
+
'../service/enhance/codebase-scanner.js'
|
|
97
|
+
);
|
|
98
|
+
const { usagesToSpec } = await import(
|
|
99
|
+
'../service/enhance/converter.js'
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const format = options.format ?? 'summary';
|
|
103
|
+
const quiet = options.quiet ?? false;
|
|
104
|
+
|
|
105
|
+
if (!quiet) {
|
|
106
|
+
console.log(pc.cyan(`\n${BRAND.name} Governance Scan\n`));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 1. Resolve root directory
|
|
110
|
+
const rootDir = resolve(options.dir ?? detectRootDir(process.cwd()));
|
|
111
|
+
if (!quiet) {
|
|
112
|
+
console.log(pc.dim(` Root: ${rootDir}\n`));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 2. Load policy — use scan defaults if no config exists
|
|
116
|
+
let policy = await loadPolicy(options.config);
|
|
117
|
+
const hasRules = Object.keys(policy.rules).length > 0;
|
|
118
|
+
|
|
119
|
+
if (!hasRules) {
|
|
120
|
+
policy = { ...policy, rules: SCAN_DEFAULT_RULES };
|
|
121
|
+
if (!quiet) {
|
|
122
|
+
console.log(pc.dim(' No config found — using scan defaults (safety + tokens)\n'));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 3. Build adapters. Auto-add cloud if FRAGMENTS_API_KEY is set
|
|
127
|
+
const adapters = buildAdaptersFromConfig(policy.audit);
|
|
128
|
+
const hasCloudAdapter = adapters.length > 0 && policy.audit?.cloud;
|
|
129
|
+
if (!hasCloudAdapter && process.env.FRAGMENTS_API_KEY) {
|
|
130
|
+
adapters.push(createCloudAdapter());
|
|
131
|
+
if (!quiet) {
|
|
132
|
+
console.log(pc.dim(' Cloud audit enabled (FRAGMENTS_API_KEY detected)\n'));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 4. Create engine
|
|
137
|
+
const engine = createEngine(policy, adapters);
|
|
138
|
+
|
|
139
|
+
// 5. Scan codebase
|
|
140
|
+
if (!quiet) {
|
|
141
|
+
console.log(pc.dim(' Scanning files...\n'));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const analysis = await scanCodebase({
|
|
145
|
+
rootDir,
|
|
146
|
+
useCache: true,
|
|
147
|
+
onProgress: quiet
|
|
148
|
+
? undefined
|
|
149
|
+
: (progress) => {
|
|
150
|
+
if (progress.phase === 'scanning') {
|
|
151
|
+
process.stdout.write(
|
|
152
|
+
`\r ${pc.dim(`[${progress.current}/${progress.total}]`)} ${pc.dim(relative(rootDir, progress.currentFile))}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!quiet) {
|
|
159
|
+
// Clear progress line
|
|
160
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
161
|
+
console.log(
|
|
162
|
+
pc.dim(` Scanned ${analysis.totalFiles} files, found ${analysis.totalComponents} component types\n`),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 6. Collect all usages across components
|
|
167
|
+
const allUsages: ComponentUsage[] = [];
|
|
168
|
+
for (const comp of Object.values(analysis.components)) {
|
|
169
|
+
allUsages.push(...comp.usages);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (allUsages.length === 0) {
|
|
173
|
+
if (!quiet) {
|
|
174
|
+
console.log(pc.yellow(' No component usages found.\n'));
|
|
175
|
+
}
|
|
176
|
+
return { exitCode: 0 };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 7. Group by file and run checks
|
|
180
|
+
const grouped = groupByFile(allUsages);
|
|
181
|
+
let totalFiles = 0;
|
|
182
|
+
let passedFiles = 0;
|
|
183
|
+
let totalViolations = 0;
|
|
184
|
+
const violationCounts = new Map<string, number>();
|
|
185
|
+
|
|
186
|
+
for (const [filePath, usages] of grouped) {
|
|
187
|
+
const spec = usagesToSpec(usages, filePath, rootDir);
|
|
188
|
+
const relPath = relative(rootDir, filePath);
|
|
189
|
+
|
|
190
|
+
const verdict = await engine.check(spec, {
|
|
191
|
+
runner: 'cli',
|
|
192
|
+
input: relPath,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
totalFiles++;
|
|
196
|
+
|
|
197
|
+
if (verdict.passed) {
|
|
198
|
+
passedFiles++;
|
|
199
|
+
} else {
|
|
200
|
+
if (!quiet) {
|
|
201
|
+
console.log(pc.red(` ✗ ${relPath}`));
|
|
202
|
+
if (format === 'summary') {
|
|
203
|
+
for (const result of verdict.results) {
|
|
204
|
+
for (const v of result.violations) {
|
|
205
|
+
const count = violationCounts.get(v.rule) ?? 0;
|
|
206
|
+
violationCounts.set(v.rule, count + 1);
|
|
207
|
+
totalViolations++;
|
|
208
|
+
console.log(
|
|
209
|
+
pc.dim(` ${v.severity} `) +
|
|
210
|
+
pc.yellow(v.rule) +
|
|
211
|
+
pc.dim(` — ${v.message}`),
|
|
212
|
+
);
|
|
213
|
+
if (v.nodeId) {
|
|
214
|
+
console.log(pc.dim(` at ${v.nodeId}`));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (verdict.passed && !quiet && format === 'summary') {
|
|
223
|
+
console.log(pc.green(` ✓ ${relPath}`) + pc.dim(` (${usages.length} components, score: ${verdict.score}/100)`));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// JSON/SARIF: print per-file
|
|
227
|
+
if (format === 'json' || format === 'sarif') {
|
|
228
|
+
const output = formatVerdict(verdict, format);
|
|
229
|
+
console.log(output);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 8. Summary
|
|
234
|
+
if (!quiet && format === 'summary') {
|
|
235
|
+
console.log(pc.dim('\n ─────────────────────────────────────\n'));
|
|
236
|
+
console.log(` Files checked: ${totalFiles}`);
|
|
237
|
+
console.log(` Passed: ${passedFiles}/${totalFiles}`);
|
|
238
|
+
console.log(` Violations: ${totalViolations}`);
|
|
239
|
+
|
|
240
|
+
if (violationCounts.size > 0) {
|
|
241
|
+
console.log(pc.dim('\n Top violations:'));
|
|
242
|
+
const sorted = [...violationCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
243
|
+
for (const [rule, count] of sorted.slice(0, 5)) {
|
|
244
|
+
console.log(pc.dim(` ${count}× `) + pc.yellow(rule));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.log();
|
|
249
|
+
|
|
250
|
+
if (passedFiles === totalFiles) {
|
|
251
|
+
console.log(pc.green(` ✓ All files passed governance checks\n`));
|
|
252
|
+
} else {
|
|
253
|
+
console.log(
|
|
254
|
+
pc.red(` ✗ ${totalFiles - passedFiles} file(s) failed governance checks\n`),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { exitCode: passedFiles === totalFiles ? 0 : 1 };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// governWatch
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
export async function governWatch(
|
|
267
|
+
options: GovernWatchOptions = {},
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
const {
|
|
270
|
+
loadPolicy,
|
|
271
|
+
createEngine,
|
|
272
|
+
buildAdaptersFromConfig,
|
|
273
|
+
createCloudAdapter,
|
|
274
|
+
formatVerdict,
|
|
275
|
+
} = await import('@fragments-sdk/govern');
|
|
276
|
+
|
|
277
|
+
const { scanFile } = await import('../service/enhance/scanner.js');
|
|
278
|
+
const { usagesToSpec } = await import(
|
|
279
|
+
'../service/enhance/converter.js'
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const quiet = options.quiet ?? false;
|
|
283
|
+
const debounceMs = options.debounce ?? 300;
|
|
284
|
+
const format = options.format ?? 'summary';
|
|
285
|
+
|
|
286
|
+
// 1. Run initial scan
|
|
287
|
+
console.log(pc.cyan(`\n${BRAND.name} Governance Watch\n`));
|
|
288
|
+
|
|
289
|
+
const { exitCode } = await governScan(options);
|
|
290
|
+
if (!quiet) {
|
|
291
|
+
console.log(
|
|
292
|
+
pc.dim(` Initial scan ${exitCode === 0 ? 'passed' : 'completed with violations'}\n`),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 2. Set up engine for incremental checks
|
|
297
|
+
const rootDir = resolve(options.dir ?? detectRootDir(process.cwd()));
|
|
298
|
+
let policy = await loadPolicy(options.config);
|
|
299
|
+
if (Object.keys(policy.rules).length === 0) {
|
|
300
|
+
policy = { ...policy, rules: SCAN_DEFAULT_RULES };
|
|
301
|
+
}
|
|
302
|
+
const adapters = buildAdaptersFromConfig(policy.audit);
|
|
303
|
+
if (!adapters.some(() => policy.audit?.cloud) && process.env.FRAGMENTS_API_KEY) {
|
|
304
|
+
adapters.push(createCloudAdapter());
|
|
305
|
+
}
|
|
306
|
+
const engine = createEngine(policy, adapters);
|
|
307
|
+
|
|
308
|
+
// 3. Watch for changes
|
|
309
|
+
console.log(pc.dim(' Watching for changes... (Ctrl+C to stop)\n'));
|
|
310
|
+
|
|
311
|
+
const chokidar = await import('chokidar');
|
|
312
|
+
|
|
313
|
+
const watcher = chokidar.watch(
|
|
314
|
+
['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
|
|
315
|
+
{
|
|
316
|
+
cwd: rootDir,
|
|
317
|
+
ignoreInitial: true,
|
|
318
|
+
ignored: [
|
|
319
|
+
'**/node_modules/**',
|
|
320
|
+
'**/dist/**',
|
|
321
|
+
'**/build/**',
|
|
322
|
+
'**/.next/**',
|
|
323
|
+
'**/*.test.*',
|
|
324
|
+
'**/*.spec.*',
|
|
325
|
+
'**/*.stories.*',
|
|
326
|
+
],
|
|
327
|
+
awaitWriteFinish: { stabilityThreshold: debounceMs },
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const handleChange = async (changedRelPath: string) => {
|
|
332
|
+
const absolutePath = resolve(rootDir, changedRelPath);
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const { usages } = await scanFile(absolutePath);
|
|
336
|
+
|
|
337
|
+
if (usages.length === 0) {
|
|
338
|
+
if (!quiet) {
|
|
339
|
+
console.log(pc.dim(` ○ ${changedRelPath} — no component usages`));
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const spec = usagesToSpec(usages, absolutePath, rootDir);
|
|
345
|
+
const verdict = await engine.check(spec, {
|
|
346
|
+
runner: 'cli',
|
|
347
|
+
input: changedRelPath,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
if (verdict.passed) {
|
|
351
|
+
console.log(
|
|
352
|
+
pc.green(` ✓ ${changedRelPath}`) +
|
|
353
|
+
pc.dim(` (${usages.length} components, score: ${verdict.score}/100)`),
|
|
354
|
+
);
|
|
355
|
+
} else {
|
|
356
|
+
console.log(pc.red(` ✗ ${changedRelPath}`));
|
|
357
|
+
if (format === 'summary') {
|
|
358
|
+
for (const result of verdict.results) {
|
|
359
|
+
for (const v of result.violations) {
|
|
360
|
+
console.log(
|
|
361
|
+
pc.dim(` ${v.severity} `) +
|
|
362
|
+
pc.yellow(v.rule) +
|
|
363
|
+
pc.dim(` — ${v.message}`),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
console.log(formatVerdict(verdict, format));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (!quiet) {
|
|
373
|
+
console.log(
|
|
374
|
+
pc.dim(` ⚠ ${changedRelPath} — `) +
|
|
375
|
+
pc.yellow(error instanceof Error ? error.message : 'parse error'),
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
watcher.on('change', handleChange);
|
|
382
|
+
watcher.on('add', handleChange);
|
|
383
|
+
|
|
384
|
+
// Keep process alive
|
|
385
|
+
await new Promise(() => {});
|
|
386
|
+
}
|
package/src/commands/govern.ts
CHANGED
|
@@ -95,7 +95,7 @@ export async function governConnect(): Promise<void> {
|
|
|
95
95
|
// ── Step 1: Get API key ──────────────────────────────────────────────────
|
|
96
96
|
console.log(pc.bold(' Step 1 of 3: Get your API key\n'));
|
|
97
97
|
|
|
98
|
-
const dashboardUrl = `${cloudUrl}/
|
|
98
|
+
const dashboardUrl = `${cloudUrl}/api-keys`;
|
|
99
99
|
console.log(pc.dim(` → Opening the dashboard in your browser...`));
|
|
100
100
|
console.log(pc.dim(` Copy your API key from Settings → API Keys\n`));
|
|
101
101
|
|
|
@@ -226,7 +226,7 @@ export async function governConnect(): Promise<void> {
|
|
|
226
226
|
// ── Done ────────────────────────────────────────────────────────────────
|
|
227
227
|
console.log(pc.dim('\n ─────────────────────────────────────\n'));
|
|
228
228
|
console.log(pc.green(' ✓ All set!') + ' Run `fragments govern check` to send your first audit.\n');
|
|
229
|
-
console.log(pc.dim(` Dashboard: ${cloudUrl}/
|
|
229
|
+
console.log(pc.dim(` Dashboard: ${cloudUrl}/overview\n`));
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
// ---------------------------------------------------------------------------
|
package/src/commands/init.ts
CHANGED
|
@@ -20,6 +20,49 @@ import {
|
|
|
20
20
|
addTranspilePackages,
|
|
21
21
|
} from "./setup.js";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Detect existing UI component libraries by reading package.json.
|
|
25
|
+
* Returns the library display name or null if none found.
|
|
26
|
+
*/
|
|
27
|
+
async function detectExistingUILibrary(projectRoot: string): Promise<string | null> {
|
|
28
|
+
let pkg: Record<string, unknown>;
|
|
29
|
+
try {
|
|
30
|
+
const raw = await readFile(join(projectRoot, "package.json"), "utf-8");
|
|
31
|
+
pkg = JSON.parse(raw);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const allDeps: Record<string, string> = {
|
|
37
|
+
...(pkg.dependencies as Record<string, string> || {}),
|
|
38
|
+
...(pkg.devDependencies as Record<string, string> || {}),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// shadcn/ui: tailwindcss present + components/ui/ directory
|
|
42
|
+
if (allDeps["tailwindcss"]) {
|
|
43
|
+
const shadcnFiles = await fg(["**/components/ui/*.tsx", "**/components/ui/*.ts"], {
|
|
44
|
+
cwd: projectRoot,
|
|
45
|
+
ignore: ["**/node_modules/**"],
|
|
46
|
+
});
|
|
47
|
+
if (shadcnFiles.length > 0) {
|
|
48
|
+
return "shadcn/ui";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (allDeps["@mui/material"]) return "Material UI";
|
|
53
|
+
if (allDeps["@chakra-ui/react"]) return "Chakra UI";
|
|
54
|
+
if (allDeps["@mantine/core"]) return "Mantine";
|
|
55
|
+
if (allDeps["antd"]) return "Ant Design";
|
|
56
|
+
|
|
57
|
+
// Radix UI: has @radix-ui/react-* packages but NOT @fragments-sdk/ui
|
|
58
|
+
const hasRadix = Object.keys(allDeps).some((dep) => dep.startsWith("@radix-ui/react-"));
|
|
59
|
+
if (hasRadix && !allDeps["@fragments-sdk/ui"]) {
|
|
60
|
+
return "Radix UI";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
23
66
|
export interface InitOptions {
|
|
24
67
|
/** Project root directory */
|
|
25
68
|
projectRoot?: string;
|
|
@@ -43,6 +86,10 @@ export interface InitOptions {
|
|
|
43
86
|
apiKey?: string;
|
|
44
87
|
/** Override AI model for enrichment */
|
|
45
88
|
model?: string;
|
|
89
|
+
/** Generate metadata/governance files without injecting runtime UI */
|
|
90
|
+
metadataOnly?: boolean;
|
|
91
|
+
/** Alias for metadataOnly */
|
|
92
|
+
govern?: boolean;
|
|
46
93
|
}
|
|
47
94
|
|
|
48
95
|
export interface InitResult {
|
|
@@ -342,6 +389,49 @@ export default defineFragment({
|
|
|
342
389
|
`;
|
|
343
390
|
}
|
|
344
391
|
|
|
392
|
+
function generateExampleContract(): string {
|
|
393
|
+
return JSON.stringify({
|
|
394
|
+
$schema: 'https://usefragments.com/schemas/contract.v1.json',
|
|
395
|
+
name: 'Button',
|
|
396
|
+
description: 'Interactive button for triggering actions',
|
|
397
|
+
category: 'Actions',
|
|
398
|
+
status: 'stable',
|
|
399
|
+
sourcePath: 'src/components/Button.tsx',
|
|
400
|
+
exportName: 'Button',
|
|
401
|
+
propsSummary: [
|
|
402
|
+
'variant: primary|secondary|ghost (default: primary)',
|
|
403
|
+
'size: sm|md|lg (default: md)',
|
|
404
|
+
'children: node (required)',
|
|
405
|
+
],
|
|
406
|
+
props: {
|
|
407
|
+
children: { type: 'node', required: true, description: 'Button label content' },
|
|
408
|
+
variant: {
|
|
409
|
+
type: 'enum',
|
|
410
|
+
values: ['primary', 'secondary', 'ghost'],
|
|
411
|
+
default: 'primary',
|
|
412
|
+
description: 'Visual style variant',
|
|
413
|
+
},
|
|
414
|
+
size: {
|
|
415
|
+
type: 'enum',
|
|
416
|
+
values: ['sm', 'md', 'lg'],
|
|
417
|
+
default: 'md',
|
|
418
|
+
description: 'Button size',
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
usage: {
|
|
422
|
+
when: ['Triggering an action (save, submit, delete)', 'Form submission', 'Opening dialogs or menus'],
|
|
423
|
+
whenNot: ['Simple navigation (use Link)', 'Toggling state (use Switch)'],
|
|
424
|
+
guidelines: ['Use Primary for the main action in a context', 'Only one Primary button per section'],
|
|
425
|
+
},
|
|
426
|
+
examples: [
|
|
427
|
+
{ name: 'Primary', description: 'Default action button', code: '<Button variant="primary">Save Changes</Button>' },
|
|
428
|
+
{ name: 'Secondary', description: 'Less prominent action', code: '<Button variant="secondary">Cancel</Button>' },
|
|
429
|
+
{ name: 'Ghost', description: 'Minimal visual weight', code: '<Button variant="ghost">Learn More</Button>' },
|
|
430
|
+
],
|
|
431
|
+
provenance: { source: 'manual', verified: false },
|
|
432
|
+
}, null, 2) + '\n';
|
|
433
|
+
}
|
|
434
|
+
|
|
345
435
|
/**
|
|
346
436
|
* Start the dev server
|
|
347
437
|
*/
|
|
@@ -539,7 +629,7 @@ export async function init(options: InitOptions = {}): Promise<InitResult> {
|
|
|
539
629
|
const relScanPath = relative(projectRoot, scanPath);
|
|
540
630
|
const configPath = join(projectRoot, BRAND.configFile);
|
|
541
631
|
const configContent = generateConfig({
|
|
542
|
-
includePaths: [`${relScanPath}/**/*.
|
|
632
|
+
includePaths: [`${relScanPath}/**/*.contract.json`],
|
|
543
633
|
componentPaths: [`${relScanPath}/**/*.tsx`],
|
|
544
634
|
framework: "react",
|
|
545
635
|
});
|
|
@@ -642,7 +732,7 @@ export async function init(options: InitOptions = {}): Promise<InitResult> {
|
|
|
642
732
|
|
|
643
733
|
// Step 5: Create configuration file
|
|
644
734
|
const includePaths: string[] = [
|
|
645
|
-
`${componentPath}/**/*.
|
|
735
|
+
`${componentPath}/**/*.contract.json`,
|
|
646
736
|
];
|
|
647
737
|
|
|
648
738
|
if (scenario === 'stories') {
|
|
@@ -667,33 +757,62 @@ export async function init(options: InitOptions = {}): Promise<InitResult> {
|
|
|
667
757
|
}
|
|
668
758
|
|
|
669
759
|
// Step 6: Auto-inject styles + framework config
|
|
670
|
-
|
|
760
|
+
// Detect existing UI libraries — skip @fragments-sdk/ui runtime if one is found
|
|
761
|
+
const detectedUILib = await detectExistingUILibrary(projectRoot);
|
|
671
762
|
|
|
672
|
-
if
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
763
|
+
// Check if @fragments-sdk/ui is already a dependency
|
|
764
|
+
let hasFragmentsUI = false;
|
|
765
|
+
try {
|
|
766
|
+
const pkgRaw = await readFile(join(projectRoot, "package.json"), "utf-8");
|
|
767
|
+
const pkgJson = JSON.parse(pkgRaw);
|
|
768
|
+
const allDeps = {
|
|
769
|
+
...(pkgJson.dependencies || {}),
|
|
770
|
+
...(pkgJson.devDependencies || {}),
|
|
771
|
+
};
|
|
772
|
+
hasFragmentsUI = !!allDeps["@fragments-sdk/ui"];
|
|
773
|
+
} catch {
|
|
774
|
+
// no package.json — can't determine, default to injecting
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const explicitMetadataOnly = !!options.metadataOnly || !!options.govern;
|
|
778
|
+
const skipUIRuntime = explicitMetadataOnly || (detectedUILib !== null && !hasFragmentsUI);
|
|
779
|
+
|
|
780
|
+
if (skipUIRuntime) {
|
|
781
|
+
if (explicitMetadataOnly) {
|
|
782
|
+
console.log(pc.dim(` · Metadata-only mode — skipping @fragments-sdk/ui runtime setup`));
|
|
783
|
+
} else {
|
|
784
|
+
console.log(pc.dim(` · Detected ${detectedUILib} — skipping @fragments-sdk/ui runtime setup`));
|
|
682
785
|
}
|
|
786
|
+
console.log(pc.dim(` · Run '${BRAND.cliCommand} setup' if you want to add @fragments-sdk/ui later`));
|
|
787
|
+
} else {
|
|
788
|
+
const entryFile = await findEntryFile(projectRoot, framework);
|
|
789
|
+
|
|
790
|
+
if (entryFile) {
|
|
791
|
+
try {
|
|
792
|
+
const stylesResult = await addStylesImport(projectRoot, entryFile);
|
|
793
|
+
if (stylesResult.modified) {
|
|
794
|
+
console.log(pc.green(` ✓ Added styles import to ${entryFile}`));
|
|
795
|
+
} else {
|
|
796
|
+
console.log(pc.dim(` · ${stylesResult.message}`));
|
|
797
|
+
}
|
|
798
|
+
} catch (e) {
|
|
799
|
+
errors.push(`Failed to add styles import: ${e instanceof Error ? e.message : e}`);
|
|
800
|
+
}
|
|
683
801
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
802
|
+
try {
|
|
803
|
+
const providerResult = await addThemeProvider(projectRoot, entryFile, framework);
|
|
804
|
+
if (providerResult.modified) {
|
|
805
|
+
console.log(pc.green(` ✓ Added ThemeProvider to ${entryFile}`));
|
|
806
|
+
} else {
|
|
807
|
+
console.log(pc.dim(` · ${providerResult.message}`));
|
|
808
|
+
}
|
|
809
|
+
} catch (e) {
|
|
810
|
+
errors.push(`Failed to add ThemeProvider: ${e instanceof Error ? e.message : e}`);
|
|
690
811
|
}
|
|
691
|
-
}
|
|
692
|
-
|
|
812
|
+
} else {
|
|
813
|
+
console.log(pc.yellow(` ! Could not detect entry file — add styles import manually`));
|
|
814
|
+
console.log(pc.dim(` import '@fragments-sdk/ui/styles'`));
|
|
693
815
|
}
|
|
694
|
-
} else {
|
|
695
|
-
console.log(pc.yellow(` ! Could not detect entry file — add styles import manually`));
|
|
696
|
-
console.log(pc.dim(` import '@fragments-sdk/ui/styles'`));
|
|
697
816
|
}
|
|
698
817
|
|
|
699
818
|
// Next.js: add transpilePackages
|
|
@@ -727,13 +846,13 @@ export async function init(options: InitOptions = {}): Promise<InitResult> {
|
|
|
727
846
|
);
|
|
728
847
|
|
|
729
848
|
await writeFile(
|
|
730
|
-
join(exampleDir, "Button.
|
|
731
|
-
|
|
849
|
+
join(exampleDir, "Button.contract.json"),
|
|
850
|
+
generateExampleContract(),
|
|
732
851
|
"utf-8"
|
|
733
852
|
);
|
|
734
853
|
console.log(
|
|
735
854
|
pc.green(
|
|
736
|
-
` ✓ Created ${relative(projectRoot, join(exampleDir, "Button.
|
|
855
|
+
` ✓ Created ${relative(projectRoot, join(exampleDir, "Button.contract.json"))}`
|
|
737
856
|
)
|
|
738
857
|
);
|
|
739
858
|
} catch (e) {
|
|
@@ -775,9 +894,14 @@ export async function init(options: InitOptions = {}): Promise<InitResult> {
|
|
|
775
894
|
|
|
776
895
|
if (startServer) {
|
|
777
896
|
startDevServer(projectRoot);
|
|
897
|
+
} else if (skipUIRuntime) {
|
|
898
|
+
console.log(` ${pc.bold("Get started:")}`);
|
|
899
|
+
console.log(` ${pc.dim("$")} ${BRAND.cliCommand} build`);
|
|
900
|
+
console.log(` ${pc.dim("$")} ${BRAND.cliCommand} verify --ci`);
|
|
901
|
+
console.log();
|
|
778
902
|
} else {
|
|
779
903
|
console.log(` ${pc.bold("Get started:")}`);
|
|
780
|
-
console.log(` ${pc.dim("$")} ${BRAND.cliCommand}
|
|
904
|
+
console.log(` ${pc.dim("$")} ${BRAND.cliCommand} build`);
|
|
781
905
|
console.log();
|
|
782
906
|
|
|
783
907
|
if (!options.configure) {
|