@fragments-sdk/cli 0.14.3 → 0.15.1
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/{ai-client-I6MDWNYA.js → ai-client-LSLQGOMM.js} +1 -2
- package/dist/bin.js +4745 -3817
- 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-5JF26E55.js +1255 -0
- package/dist/chunk-5JF26E55.js.map +1 -0
- package/dist/{chunk-APTQIBS5.js → chunk-6SQPP47U.js} +153 -1342
- package/dist/chunk-6SQPP47U.js.map +1 -0
- package/dist/chunk-7DZC4YEV.js +294 -0
- package/dist/chunk-7DZC4YEV.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-55KERLWL.js → chunk-HQ6A6DTV.js} +1587 -1073
- package/dist/chunk-HQ6A6DTV.js.map +1 -0
- package/dist/chunk-MHIBEEW4.js +511 -0
- package/dist/chunk-MHIBEEW4.js.map +1 -0
- package/dist/{chunk-5A6X2Y73.js → chunk-ONUP6Z4W.js} +25 -13
- package/dist/chunk-ONUP6Z4W.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/codebase-scanner-MQHUZC2G.js +21 -0
- package/dist/converter-7XM3Y6NJ.js +33 -0
- package/dist/converter-7XM3Y6NJ.js.map +1 -0
- package/dist/core/index.js +43 -2
- package/dist/create-IH4R45GE.js +806 -0
- package/dist/create-IH4R45GE.js.map +1 -0
- package/dist/{generate-RYWIPDN2.js → generate-PVOLUAAC.js} +4 -6
- package/dist/{generate-RYWIPDN2.js.map → generate-PVOLUAAC.js.map} +1 -1
- package/dist/govern-scan-OYFZYOQW.js +413 -0
- package/dist/govern-scan-OYFZYOQW.js.map +1 -0
- package/dist/index.d.ts +4 -23
- package/dist/index.js +15 -14
- package/dist/index.js.map +1 -1
- package/dist/{init-WRUSW7R5.js → init-SSGUSP7Z.js} +131 -129
- package/dist/init-SSGUSP7Z.js.map +1 -0
- package/dist/{init-cloud-REQ3XLHO.js → init-cloud-3DNKPWFB.js} +30 -5
- package/dist/{init-cloud-REQ3XLHO.js.map → init-cloud-3DNKPWFB.js.map} +1 -1
- package/dist/mcp-bin.js +5 -37
- package/dist/mcp-bin.js.map +1 -1
- package/dist/node-37AUE74M.js +65 -0
- package/dist/push-contracts-WY32TFP6.js +84 -0
- package/dist/push-contracts-WY32TFP6.js.map +1 -0
- package/dist/scan-PKSYSTRR.js +15 -0
- package/dist/{scan-generate-TFZVL3BT.js → scan-generate-VY27PIOX.js} +340 -52
- package/dist/scan-generate-VY27PIOX.js.map +1 -0
- package/dist/scanner-4KZNOXAK.js +12 -0
- package/dist/{service-HKJ6B7P7.js → service-QJGWUIVL.js} +41 -30
- package/dist/{snapshot-C5DYIGIV.js → snapshot-WIJMEIFT.js} +2 -3
- package/dist/{snapshot-C5DYIGIV.js.map → snapshot-WIJMEIFT.js.map} +1 -1
- package/dist/{static-viewer-DUVC4UIM.js → static-viewer-7QIBQZRC.js} +3 -4
- package/dist/static-viewer-7QIBQZRC.js.map +1 -0
- package/dist/{test-JW7JIDFG.js → test-64Z5BKBA.js} +4 -7
- package/dist/{test-JW7JIDFG.js.map → test-64Z5BKBA.js.map} +1 -1
- package/dist/token-normalizer-TEPOVBPV.js +312 -0
- package/dist/token-normalizer-TEPOVBPV.js.map +1 -0
- package/dist/token-parser-32KOIOFN.js +22 -0
- package/dist/token-parser-32KOIOFN.js.map +1 -0
- package/dist/{tokens-KE73G5JC.js → tokens-NZWFQIAB.js} +10 -9
- package/dist/{tokens-KE73G5JC.js.map → tokens-NZWFQIAB.js.map} +1 -1
- package/dist/tokens-generate-5JQSJ27E.js +85 -0
- package/dist/tokens-generate-5JQSJ27E.js.map +1 -0
- package/dist/tokens-push-HY3KO36V.js +148 -0
- package/dist/tokens-push-HY3KO36V.js.map +1 -0
- package/package.json +8 -6
- package/src/bin.ts +300 -48
- 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__/build-freshness.test.ts +231 -0
- package/src/commands/__tests__/create.test.ts +71 -0
- package/src/commands/__tests__/drift-sync.test.ts +1 -1
- package/src/commands/__tests__/govern.test.ts +258 -0
- package/src/commands/__tests__/init.test.ts +113 -0
- package/src/commands/__tests__/scan-generate.test.ts +189 -70
- package/src/commands/__tests__/verify.test.ts +91 -0
- package/src/commands/build.ts +54 -1
- package/src/commands/context.ts +1 -1
- package/src/commands/create.ts +536 -0
- package/src/commands/discover.ts +151 -0
- package/src/commands/doctor.ts +3 -2
- package/src/commands/enhance.ts +3 -1
- package/src/commands/govern-scan.ts +565 -0
- package/src/commands/govern.ts +67 -4
- package/src/commands/init-cloud.ts +32 -4
- 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/push-contracts.ts +112 -0
- package/src/commands/scan-generate.ts +439 -51
- package/src/commands/scan.ts +14 -0
- package/src/commands/setup.ts +27 -50
- package/src/commands/sync.ts +2 -2
- package/src/commands/tokens-generate.ts +113 -0
- package/src/commands/tokens-push.ts +199 -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/__tests__/token-resolver.test.ts +1 -1
- package/src/core/component-extractor.test.ts +40 -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/index.ts +3 -3
- 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/index.ts +8 -0
- package/src/service/snippet-validation.ts +9 -3
- package/src/service/tailwind-v4-parser.ts +314 -0
- package/src/service/token-normalizer.ts +510 -0
- package/src/service/token-parser.ts +56 -0
- package/src/setup.ts +10 -39
- package/src/shared/index.ts +1 -0
- package/src/shared/project-fields.ts +46 -0
- package/src/theme/__tests__/component-contrast.test.ts +2 -2
- package/src/theme/__tests__/serializer.test.ts +1 -1
- package/src/theme/generator.ts +16 -1
- package/src/theme/schema.ts +8 -0
- package/src/theme/serializer.ts +13 -9
- package/src/theme/types.ts +8 -0
- package/src/validators.ts +1 -2
- package/src/viewer/__tests__/viewer-integration.test.ts +8 -8
- 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 +0 -626
- package/dist/chunk-EYXVAMEX.js.map +0 -1
- package/dist/chunk-I34BC3CU.js.map +0 -1
- package/dist/chunk-LOYS64QS.js +0 -2453
- package/dist/chunk-LOYS64QS.js.map +0 -1
- package/dist/chunk-Z7EY4VHE.js +0 -50
- 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/sass.node-4XJK6YBF.js +0 -130708
- package/dist/sass.node-4XJK6YBF.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/build.ts +0 -612
- package/src/commands/dev.ts +0 -107
- package/src/core/auto-props.ts +0 -464
- package/src/core/component-extractor.ts +0 -1030
- package/src/core/token-resolver.ts +0 -155
- /package/dist/{ai-client-I6MDWNYA.js.map → ai-client-LSLQGOMM.js.map} +0 -0
- /package/dist/{chunk-TXFCEDOC.js.map → chunk-2WXKALIG.js.map} +0 -0
- /package/dist/{chunk-Z7EY4VHE.js.map → codebase-scanner-MQHUZC2G.js.map} +0 -0
- /package/dist/{discovery-VDANZAJ2.js.map → node-37AUE74M.js.map} +0 -0
- /package/dist/{scan-YJHQIRKG.js.map → scan-PKSYSTRR.js.map} +0 -0
- /package/dist/{service-HKJ6B7P7.js.map → scanner-4KZNOXAK.js.map} +0 -0
- /package/dist/{static-viewer-DUVC4UIM.js.map → service-QJGWUIVL.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,565 @@
|
|
|
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
|
+
computeComponentHealth,
|
|
94
|
+
} = await import('@fragments-sdk/govern');
|
|
95
|
+
|
|
96
|
+
const { scanCodebase } = await import(
|
|
97
|
+
'../service/enhance/codebase-scanner.js'
|
|
98
|
+
);
|
|
99
|
+
const { usagesToSpec } = await import(
|
|
100
|
+
'../service/enhance/converter.js'
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const format = options.format ?? 'summary';
|
|
104
|
+
const quiet = options.quiet ?? false;
|
|
105
|
+
|
|
106
|
+
if (!quiet) {
|
|
107
|
+
console.log(pc.cyan(`\n${BRAND.name} Governance Scan\n`));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 1. Resolve root directory
|
|
111
|
+
const rootDir = resolve(options.dir ?? detectRootDir(process.cwd()));
|
|
112
|
+
if (!quiet) {
|
|
113
|
+
console.log(pc.dim(` Root: ${rootDir}\n`));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2. Load policy — use scan defaults if no config exists
|
|
117
|
+
let policy = await loadPolicy(options.config);
|
|
118
|
+
const hasRules = Object.keys(policy.rules).length > 0;
|
|
119
|
+
|
|
120
|
+
if (!hasRules) {
|
|
121
|
+
policy = { ...policy, rules: SCAN_DEFAULT_RULES };
|
|
122
|
+
if (!quiet) {
|
|
123
|
+
console.log(pc.dim(' No config found — using scan defaults (safety + tokens)\n'));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 3. Extract code tokens for cloud ingest
|
|
128
|
+
let codeTokens: string | undefined;
|
|
129
|
+
if (process.env.FRAGMENTS_API_KEY) {
|
|
130
|
+
codeTokens = await extractCodeTokens(rootDir, options.config, quiet);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 3b. Load contract registry for governance + cloud ingest (if fragments.json exists)
|
|
134
|
+
let contractRegistry: string | undefined;
|
|
135
|
+
let registryMap: Record<string, unknown> | undefined;
|
|
136
|
+
{
|
|
137
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
138
|
+
const fragmentsJsonPath = resolve(rootDir, 'fragments.json');
|
|
139
|
+
if (existsSync(fragmentsJsonPath)) {
|
|
140
|
+
try {
|
|
141
|
+
const raw = readFileSync(fragmentsJsonPath, 'utf-8');
|
|
142
|
+
const parsed = JSON.parse(raw);
|
|
143
|
+
if (parsed.fragments && Array.isArray(parsed.fragments)) {
|
|
144
|
+
// Build name-keyed map for engine registry injection
|
|
145
|
+
const map: Record<string, unknown> = {};
|
|
146
|
+
for (const f of parsed.fragments) {
|
|
147
|
+
if (f.meta?.name) {
|
|
148
|
+
map[f.meta.name] = f;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
registryMap = map;
|
|
152
|
+
|
|
153
|
+
if (process.env.FRAGMENTS_API_KEY) {
|
|
154
|
+
contractRegistry = JSON.stringify({ fragments: parsed.fragments });
|
|
155
|
+
}
|
|
156
|
+
if (!quiet) {
|
|
157
|
+
console.log(pc.dim(` Contract registry loaded (${parsed.fragments.length} components)\n`));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// Invalid fragments.json — skip
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 4. Build adapters. Auto-add cloud if FRAGMENTS_API_KEY is set
|
|
167
|
+
const adapters = buildAdaptersFromConfig(policy.audit);
|
|
168
|
+
const hasCloudAdapter = adapters.length > 0 && policy.audit?.cloud;
|
|
169
|
+
if (!hasCloudAdapter && process.env.FRAGMENTS_API_KEY) {
|
|
170
|
+
adapters.push(createCloudAdapter({ codeTokens, contractRegistry }));
|
|
171
|
+
if (!quiet) {
|
|
172
|
+
console.log(pc.dim(' Cloud audit enabled (FRAGMENTS_API_KEY detected)\n'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 5. Create engine (with registry for contract-aware validators)
|
|
177
|
+
const engine = createEngine(
|
|
178
|
+
policy,
|
|
179
|
+
adapters,
|
|
180
|
+
registryMap
|
|
181
|
+
? { registry: { fragments: registryMap as Record<string, Record<string, unknown>> } }
|
|
182
|
+
: undefined,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// 6. Scan codebase
|
|
186
|
+
if (!quiet) {
|
|
187
|
+
console.log(pc.dim(' Scanning files...\n'));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const analysis = await scanCodebase({
|
|
191
|
+
rootDir,
|
|
192
|
+
useCache: true,
|
|
193
|
+
onProgress: quiet
|
|
194
|
+
? undefined
|
|
195
|
+
: (progress) => {
|
|
196
|
+
if (progress.phase === 'scanning') {
|
|
197
|
+
process.stdout.write(
|
|
198
|
+
`\r ${pc.dim(`[${progress.current}/${progress.total}]`)} ${pc.dim(relative(rootDir, progress.currentFile))}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (!quiet) {
|
|
205
|
+
// Clear progress line
|
|
206
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
207
|
+
console.log(
|
|
208
|
+
pc.dim(` Scanned ${analysis.totalFiles} files, found ${analysis.totalComponents} component types\n`),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 7. Collect all usages across components
|
|
213
|
+
const allUsages: ComponentUsage[] = [];
|
|
214
|
+
for (const comp of Object.values(analysis.components)) {
|
|
215
|
+
allUsages.push(...comp.usages);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (allUsages.length === 0) {
|
|
219
|
+
if (!quiet) {
|
|
220
|
+
console.log(pc.yellow(' No component usages found.\n'));
|
|
221
|
+
}
|
|
222
|
+
return { exitCode: 0 };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 8. Group by file and run checks
|
|
226
|
+
const grouped = groupByFile(allUsages);
|
|
227
|
+
let totalFiles = 0;
|
|
228
|
+
let passedFiles = 0;
|
|
229
|
+
let totalViolations = 0;
|
|
230
|
+
const violationCounts = new Map<string, number>();
|
|
231
|
+
const allVerdicts: Awaited<ReturnType<typeof engine.check>>[] = [];
|
|
232
|
+
|
|
233
|
+
// Build per-file usage snapshot for cloud
|
|
234
|
+
const usageSnapshot: Array<{
|
|
235
|
+
file: string;
|
|
236
|
+
components: Array<{
|
|
237
|
+
name: string;
|
|
238
|
+
line: number;
|
|
239
|
+
props: { static: Record<string, unknown>; dynamic: string[] };
|
|
240
|
+
}>;
|
|
241
|
+
}> = [];
|
|
242
|
+
|
|
243
|
+
for (const [filePath, usages] of grouped) {
|
|
244
|
+
const spec = usagesToSpec(usages, filePath, rootDir);
|
|
245
|
+
const relPath = relative(rootDir, filePath);
|
|
246
|
+
|
|
247
|
+
const verdict = await engine.check(spec, {
|
|
248
|
+
runner: 'cli',
|
|
249
|
+
input: relPath,
|
|
250
|
+
});
|
|
251
|
+
allVerdicts.push(verdict);
|
|
252
|
+
|
|
253
|
+
// Collect per-file usage snapshot
|
|
254
|
+
usageSnapshot.push({
|
|
255
|
+
file: relPath,
|
|
256
|
+
components: usages.map((u) => ({
|
|
257
|
+
name: u.componentName,
|
|
258
|
+
line: u.line,
|
|
259
|
+
props: {
|
|
260
|
+
static: u.props.static,
|
|
261
|
+
dynamic: u.props.dynamic,
|
|
262
|
+
},
|
|
263
|
+
})),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
totalFiles++;
|
|
267
|
+
|
|
268
|
+
if (verdict.passed) {
|
|
269
|
+
passedFiles++;
|
|
270
|
+
} else {
|
|
271
|
+
if (!quiet) {
|
|
272
|
+
console.log(pc.red(` ✗ ${relPath}`));
|
|
273
|
+
if (format === 'summary') {
|
|
274
|
+
for (const result of verdict.results) {
|
|
275
|
+
for (const v of result.violations) {
|
|
276
|
+
const count = violationCounts.get(v.rule) ?? 0;
|
|
277
|
+
violationCounts.set(v.rule, count + 1);
|
|
278
|
+
totalViolations++;
|
|
279
|
+
console.log(
|
|
280
|
+
pc.dim(` ${v.severity} `) +
|
|
281
|
+
pc.yellow(v.rule) +
|
|
282
|
+
pc.dim(` — ${v.message}`),
|
|
283
|
+
);
|
|
284
|
+
if (v.nodeId) {
|
|
285
|
+
console.log(pc.dim(` at ${v.nodeId}`));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (verdict.passed && !quiet && format === 'summary') {
|
|
294
|
+
console.log(pc.green(` ✓ ${relPath}`) + pc.dim(` (${usages.length} components, score: ${verdict.score}/100)`));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// JSON/SARIF: print per-file
|
|
298
|
+
if (format === 'json' || format === 'sarif') {
|
|
299
|
+
const output = formatVerdict(verdict, format);
|
|
300
|
+
console.log(output);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 8b. Compute component health
|
|
305
|
+
const health = computeComponentHealth(allVerdicts, registryMap ?? {});
|
|
306
|
+
|
|
307
|
+
// 9. Summary
|
|
308
|
+
if (!quiet && format === 'summary') {
|
|
309
|
+
console.log(pc.dim('\n ─────────────────────────────────────\n'));
|
|
310
|
+
console.log(` Files checked: ${totalFiles}`);
|
|
311
|
+
console.log(` Passed: ${passedFiles}/${totalFiles}`);
|
|
312
|
+
console.log(` Violations: ${totalViolations}`);
|
|
313
|
+
|
|
314
|
+
if (violationCounts.size > 0) {
|
|
315
|
+
console.log(pc.dim('\n Top violations:'));
|
|
316
|
+
const sorted = [...violationCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
317
|
+
for (const [rule, count] of sorted.slice(0, 5)) {
|
|
318
|
+
console.log(pc.dim(` ${count}× `) + pc.yellow(rule));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Component health
|
|
323
|
+
console.log(pc.dim('\n Component Health:'));
|
|
324
|
+
console.log(` Contract coverage: ${health.contractCoverage}% (${health.contractedComponents}/${health.totalComponents})`);
|
|
325
|
+
console.log(` Compliance rate: ${health.overallCompliance}%`);
|
|
326
|
+
|
|
327
|
+
if (health.uncontracted.length > 0) {
|
|
328
|
+
console.log(pc.dim(` Uncontracted: ${health.uncontracted.slice(0, 5).join(', ')}${health.uncontracted.length > 5 ? ` (+${health.uncontracted.length - 5} more)` : ''}`));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
console.log();
|
|
332
|
+
|
|
333
|
+
if (passedFiles === totalFiles) {
|
|
334
|
+
console.log(pc.green(` ✓ All files passed governance checks\n`));
|
|
335
|
+
} else {
|
|
336
|
+
console.log(
|
|
337
|
+
pc.red(` ✗ ${totalFiles - passedFiles} file(s) failed governance checks\n`),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 10. Push component usage snapshot + health to Cloud (if API key set)
|
|
343
|
+
if (process.env.FRAGMENTS_API_KEY && usageSnapshot.length > 0) {
|
|
344
|
+
try {
|
|
345
|
+
const apiKey = process.env.FRAGMENTS_API_KEY;
|
|
346
|
+
const url = process.env.FRAGMENTS_URL ?? 'https://app.usefragments.com';
|
|
347
|
+
await fetch(`${url}/api/ingest`, {
|
|
348
|
+
method: 'POST',
|
|
349
|
+
headers: {
|
|
350
|
+
'Content-Type': 'application/json',
|
|
351
|
+
Authorization: `Bearer ${apiKey}`,
|
|
352
|
+
},
|
|
353
|
+
body: JSON.stringify({
|
|
354
|
+
componentUsage: JSON.stringify(usageSnapshot),
|
|
355
|
+
componentHealth: JSON.stringify(health),
|
|
356
|
+
}),
|
|
357
|
+
});
|
|
358
|
+
} catch {
|
|
359
|
+
// Non-critical — don't fail the scan
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { exitCode: passedFiles === totalFiles ? 0 : 1 };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// Token extraction for cloud ingest
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Auto-detect and extract code tokens to send alongside governance verdicts.
|
|
372
|
+
* Tries: 1) tokens config from fragments.config.ts, 2) Tailwind config.
|
|
373
|
+
* Returns a flat JSON string of token name → value, or undefined if nothing found.
|
|
374
|
+
*/
|
|
375
|
+
async function extractCodeTokens(
|
|
376
|
+
rootDir: string,
|
|
377
|
+
configPath?: string,
|
|
378
|
+
quiet?: boolean,
|
|
379
|
+
): Promise<string | undefined> {
|
|
380
|
+
try {
|
|
381
|
+
// 1. Try fragments.config.ts tokens config
|
|
382
|
+
try {
|
|
383
|
+
const { loadConfig } = await import('../core/node.js');
|
|
384
|
+
const { parseTokenFiles } = await import('../service/index.js');
|
|
385
|
+
|
|
386
|
+
const { config, configDir } = await loadConfig(configPath);
|
|
387
|
+
if (config.tokens?.include?.length) {
|
|
388
|
+
const result = await parseTokenFiles(config.tokens, configDir);
|
|
389
|
+
if (result.tokens.length > 0) {
|
|
390
|
+
const flat: Record<string, string> = {};
|
|
391
|
+
for (const token of result.tokens) {
|
|
392
|
+
flat[token.name] = token.resolvedValue;
|
|
393
|
+
}
|
|
394
|
+
if (!quiet) {
|
|
395
|
+
console.log(
|
|
396
|
+
pc.dim(` Extracted ${result.tokens.length} code tokens from config\n`),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return JSON.stringify(flat);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
// No config or no tokens section — fall through
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 2. Try Tailwind config
|
|
407
|
+
const {
|
|
408
|
+
findTailwindConfig,
|
|
409
|
+
loadTailwindConfig,
|
|
410
|
+
} = await import('../service/token-normalizer.js');
|
|
411
|
+
|
|
412
|
+
const tailwindPath = findTailwindConfig(rootDir);
|
|
413
|
+
if (tailwindPath) {
|
|
414
|
+
const tokens = await loadTailwindConfig(tailwindPath);
|
|
415
|
+
if (tokens.length > 0) {
|
|
416
|
+
const flat: Record<string, string> = {};
|
|
417
|
+
for (const token of tokens) {
|
|
418
|
+
flat[token.name] = token.value;
|
|
419
|
+
}
|
|
420
|
+
if (!quiet) {
|
|
421
|
+
console.log(
|
|
422
|
+
pc.dim(` Extracted ${tokens.length} tokens from Tailwind config\n`),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return JSON.stringify(flat);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (!quiet) {
|
|
430
|
+
console.log(
|
|
431
|
+
pc.dim(
|
|
432
|
+
` Token extraction skipped: ${error instanceof Error ? error.message : 'unknown error'}\n`,
|
|
433
|
+
),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// governWatch
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
export async function governWatch(
|
|
446
|
+
options: GovernWatchOptions = {},
|
|
447
|
+
): Promise<void> {
|
|
448
|
+
const {
|
|
449
|
+
loadPolicy,
|
|
450
|
+
createEngine,
|
|
451
|
+
buildAdaptersFromConfig,
|
|
452
|
+
createCloudAdapter,
|
|
453
|
+
formatVerdict,
|
|
454
|
+
} = await import('@fragments-sdk/govern');
|
|
455
|
+
|
|
456
|
+
const { scanFile } = await import('../service/enhance/scanner.js');
|
|
457
|
+
const { usagesToSpec } = await import(
|
|
458
|
+
'../service/enhance/converter.js'
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
const quiet = options.quiet ?? false;
|
|
462
|
+
const debounceMs = options.debounce ?? 300;
|
|
463
|
+
const format = options.format ?? 'summary';
|
|
464
|
+
|
|
465
|
+
// 1. Run initial scan
|
|
466
|
+
console.log(pc.cyan(`\n${BRAND.name} Governance Watch\n`));
|
|
467
|
+
|
|
468
|
+
const { exitCode } = await governScan(options);
|
|
469
|
+
if (!quiet) {
|
|
470
|
+
console.log(
|
|
471
|
+
pc.dim(` Initial scan ${exitCode === 0 ? 'passed' : 'completed with violations'}\n`),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// 2. Set up engine for incremental checks
|
|
476
|
+
const rootDir = resolve(options.dir ?? detectRootDir(process.cwd()));
|
|
477
|
+
let policy = await loadPolicy(options.config);
|
|
478
|
+
if (Object.keys(policy.rules).length === 0) {
|
|
479
|
+
policy = { ...policy, rules: SCAN_DEFAULT_RULES };
|
|
480
|
+
}
|
|
481
|
+
const adapters = buildAdaptersFromConfig(policy.audit);
|
|
482
|
+
if (!adapters.some(() => policy.audit?.cloud) && process.env.FRAGMENTS_API_KEY) {
|
|
483
|
+
adapters.push(createCloudAdapter());
|
|
484
|
+
}
|
|
485
|
+
const engine = createEngine(policy, adapters);
|
|
486
|
+
|
|
487
|
+
// 3. Watch for changes
|
|
488
|
+
console.log(pc.dim(' Watching for changes... (Ctrl+C to stop)\n'));
|
|
489
|
+
|
|
490
|
+
const chokidar = await import('chokidar');
|
|
491
|
+
|
|
492
|
+
const watcher = chokidar.watch(
|
|
493
|
+
['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
|
|
494
|
+
{
|
|
495
|
+
cwd: rootDir,
|
|
496
|
+
ignoreInitial: true,
|
|
497
|
+
ignored: [
|
|
498
|
+
'**/node_modules/**',
|
|
499
|
+
'**/dist/**',
|
|
500
|
+
'**/build/**',
|
|
501
|
+
'**/.next/**',
|
|
502
|
+
'**/*.test.*',
|
|
503
|
+
'**/*.spec.*',
|
|
504
|
+
'**/*.stories.*',
|
|
505
|
+
],
|
|
506
|
+
awaitWriteFinish: { stabilityThreshold: debounceMs },
|
|
507
|
+
},
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
const handleChange = async (changedRelPath: string) => {
|
|
511
|
+
const absolutePath = resolve(rootDir, changedRelPath);
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
const { usages } = await scanFile(absolutePath);
|
|
515
|
+
|
|
516
|
+
if (usages.length === 0) {
|
|
517
|
+
if (!quiet) {
|
|
518
|
+
console.log(pc.dim(` ○ ${changedRelPath} — no component usages`));
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const spec = usagesToSpec(usages, absolutePath, rootDir);
|
|
524
|
+
const verdict = await engine.check(spec, {
|
|
525
|
+
runner: 'cli',
|
|
526
|
+
input: changedRelPath,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
if (verdict.passed) {
|
|
530
|
+
console.log(
|
|
531
|
+
pc.green(` ✓ ${changedRelPath}`) +
|
|
532
|
+
pc.dim(` (${usages.length} components, score: ${verdict.score}/100)`),
|
|
533
|
+
);
|
|
534
|
+
} else {
|
|
535
|
+
console.log(pc.red(` ✗ ${changedRelPath}`));
|
|
536
|
+
if (format === 'summary') {
|
|
537
|
+
for (const result of verdict.results) {
|
|
538
|
+
for (const v of result.violations) {
|
|
539
|
+
console.log(
|
|
540
|
+
pc.dim(` ${v.severity} `) +
|
|
541
|
+
pc.yellow(v.rule) +
|
|
542
|
+
pc.dim(` — ${v.message}`),
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
} else {
|
|
547
|
+
console.log(formatVerdict(verdict, format));
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if (!quiet) {
|
|
552
|
+
console.log(
|
|
553
|
+
pc.dim(` ⚠ ${changedRelPath} — `) +
|
|
554
|
+
pc.yellow(error instanceof Error ? error.message : 'parse error'),
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
watcher.on('change', handleChange);
|
|
561
|
+
watcher.on('add', handleChange);
|
|
562
|
+
|
|
563
|
+
// Keep process alive
|
|
564
|
+
await new Promise(() => {});
|
|
565
|
+
}
|
package/src/commands/govern.ts
CHANGED
|
@@ -62,14 +62,77 @@ export async function governCheck(options: GovernCheckOptions = {}): Promise<{ e
|
|
|
62
62
|
|
|
63
63
|
export async function governInit(options: GovernInitOptions = {}): Promise<void> {
|
|
64
64
|
const { writeFile } = await import('node:fs/promises');
|
|
65
|
+
const { existsSync } = await import('node:fs');
|
|
65
66
|
const { resolve } = await import('node:path');
|
|
66
67
|
const { generateConfigTemplate } = await import('@fragments-sdk/govern');
|
|
68
|
+
const { findTailwindConfig } = await import(
|
|
69
|
+
'../service/token-normalizer.js'
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const cwd = process.cwd();
|
|
73
|
+
|
|
74
|
+
// Auto-detect token sources
|
|
75
|
+
const detected: { tokenIncludes: string[]; projectType: string } = {
|
|
76
|
+
tokenIncludes: [],
|
|
77
|
+
projectType: 'unknown',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Check for Tailwind
|
|
81
|
+
const tailwindPath = findTailwindConfig(cwd);
|
|
82
|
+
if (tailwindPath) {
|
|
83
|
+
const { relative } = await import('node:path');
|
|
84
|
+
detected.tokenIncludes.push(relative(cwd, tailwindPath));
|
|
85
|
+
detected.projectType = 'tailwind';
|
|
86
|
+
console.log(pc.dim(` Detected Tailwind config: ${detected.tokenIncludes[0]}`));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Check for common SCSS/CSS token files
|
|
90
|
+
if (detected.tokenIncludes.length === 0) {
|
|
91
|
+
const candidates = [
|
|
92
|
+
'src/styles/tokens.scss',
|
|
93
|
+
'src/styles/variables.scss',
|
|
94
|
+
'src/styles/theme.scss',
|
|
95
|
+
'src/tokens.css',
|
|
96
|
+
'src/styles/tokens.css',
|
|
97
|
+
'src/styles/variables.css',
|
|
98
|
+
'styles/tokens.scss',
|
|
99
|
+
'styles/variables.scss',
|
|
100
|
+
'tokens.json',
|
|
101
|
+
'src/tokens.json',
|
|
102
|
+
];
|
|
103
|
+
for (const candidate of candidates) {
|
|
104
|
+
if (existsSync(resolve(cwd, candidate))) {
|
|
105
|
+
detected.tokenIncludes.push(candidate);
|
|
106
|
+
detected.projectType = candidate.endsWith('.scss')
|
|
107
|
+
? 'scss'
|
|
108
|
+
: candidate.endsWith('.json')
|
|
109
|
+
? 'dtcg'
|
|
110
|
+
: 'css';
|
|
111
|
+
console.log(pc.dim(` Detected token source: ${candidate}`));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (detected.tokenIncludes.length === 0) {
|
|
117
|
+
console.log(pc.dim(' No token sources auto-detected. You can add them manually.'));
|
|
118
|
+
}
|
|
67
119
|
|
|
68
120
|
const outputPath = resolve(options.output ?? 'fragments.config.ts');
|
|
69
|
-
const template = generateConfigTemplate();
|
|
121
|
+
const template = generateConfigTemplate({ tokenIncludes: detected.tokenIncludes });
|
|
70
122
|
|
|
71
123
|
await writeFile(outputPath, template, 'utf-8');
|
|
72
|
-
console.log(pc.green(
|
|
124
|
+
console.log(pc.green(`\n✓ Created ${outputPath}\n`));
|
|
125
|
+
|
|
126
|
+
if (detected.tokenIncludes.length > 0) {
|
|
127
|
+
console.log(
|
|
128
|
+
pc.dim(` Token sources configured: ${detected.tokenIncludes.join(', ')}`),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
console.log(
|
|
132
|
+
pc.dim(' Run ') +
|
|
133
|
+
pc.cyan('fragments govern scan') +
|
|
134
|
+
pc.dim(' to check your project.\n'),
|
|
135
|
+
);
|
|
73
136
|
}
|
|
74
137
|
|
|
75
138
|
// ---------------------------------------------------------------------------
|
|
@@ -95,7 +158,7 @@ export async function governConnect(): Promise<void> {
|
|
|
95
158
|
// ── Step 1: Get API key ──────────────────────────────────────────────────
|
|
96
159
|
console.log(pc.bold(' Step 1 of 3: Get your API key\n'));
|
|
97
160
|
|
|
98
|
-
const dashboardUrl = `${cloudUrl}/
|
|
161
|
+
const dashboardUrl = `${cloudUrl}/api-keys`;
|
|
99
162
|
console.log(pc.dim(` → Opening the dashboard in your browser...`));
|
|
100
163
|
console.log(pc.dim(` Copy your API key from Settings → API Keys\n`));
|
|
101
164
|
|
|
@@ -226,7 +289,7 @@ export async function governConnect(): Promise<void> {
|
|
|
226
289
|
// ── Done ────────────────────────────────────────────────────────────────
|
|
227
290
|
console.log(pc.dim('\n ─────────────────────────────────────\n'));
|
|
228
291
|
console.log(pc.green(' ✓ All set!') + ' Run `fragments govern check` to send your first audit.\n');
|
|
229
|
-
console.log(pc.dim(` Dashboard: ${cloudUrl}/
|
|
292
|
+
console.log(pc.dim(` Dashboard: ${cloudUrl}/overview\n`));
|
|
230
293
|
}
|
|
231
294
|
|
|
232
295
|
// ---------------------------------------------------------------------------
|