@fragments-sdk/cli 0.15.10 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/bin.js +901 -789
  2. package/dist/bin.js.map +1 -1
  3. package/dist/{chunk-6SQPP47U.js → chunk-ANTWP3UG.js} +532 -31
  4. package/dist/chunk-ANTWP3UG.js.map +1 -0
  5. package/dist/{chunk-ONUP6Z4W.js → chunk-B4A4ZEGS.js} +9 -9
  6. package/dist/{chunk-32LIWN2P.js → chunk-FFCI6OVZ.js} +584 -261
  7. package/dist/chunk-FFCI6OVZ.js.map +1 -0
  8. package/dist/{chunk-HQ6A6DTV.js → chunk-HNHE64CR.js} +315 -1089
  9. package/dist/chunk-HNHE64CR.js.map +1 -0
  10. package/dist/{chunk-BJE3425I.js → chunk-MN3B2EE6.js} +2 -2
  11. package/dist/{chunk-QCN35LJU.js → chunk-SAQW37L5.js} +3 -2
  12. package/dist/chunk-SAQW37L5.js.map +1 -0
  13. package/dist/{chunk-2WXKALIG.js → chunk-SNZXGHL2.js} +2 -2
  14. package/dist/{chunk-5JF26E55.js → chunk-VT2J62ND.js} +11 -11
  15. package/dist/{codebase-scanner-MQHUZC2G.js → codebase-scanner-2T5QIDBA.js} +2 -2
  16. package/dist/core/index.js +53 -1
  17. package/dist/{create-EXURTBKK.js → create-D44QD7MV.js} +2 -2
  18. package/dist/{doctor-BDPMYYE6.js → doctor-7B5N4JYU.js} +2 -2
  19. package/dist/{generate-PVOLUAAC.js → generate-T47JZRVU.js} +4 -4
  20. package/dist/govern-scan-X6UEIOSV.js +632 -0
  21. package/dist/govern-scan-X6UEIOSV.js.map +1 -0
  22. package/dist/index.js +7 -8
  23. package/dist/index.js.map +1 -1
  24. package/dist/{init-SSGUSP7Z.js → init-2RGAY4W6.js} +5 -5
  25. package/dist/mcp-bin.js +2 -2
  26. package/dist/scan-A2WJM54L.js +14 -0
  27. package/dist/{scan-generate-VY27PIOX.js → scan-generate-LUSOHT36.js} +4 -4
  28. package/dist/{service-QJGWUIVL.js → service-ROCP7TKG.js} +13 -15
  29. package/dist/{snapshot-WIJMEIFT.js → snapshot-B3SAW74Y.js} +2 -2
  30. package/dist/{static-viewer-7QIBQZRC.js → static-viewer-7L6UEYTJ.js} +3 -3
  31. package/dist/{test-64Z5BKBA.js → test-PQDVDURE.js} +3 -3
  32. package/dist/{token-normalizer-TEPOVBPV.js → token-normalizer-7TFCVDZL.js} +2 -2
  33. package/dist/{tokens-NZWFQIAB.js → tokens-64FG5FDP.js} +8 -9
  34. package/dist/{tokens-NZWFQIAB.js.map → tokens-64FG5FDP.js.map} +1 -1
  35. package/dist/{tokens-generate-5JQSJ27E.js → tokens-generate-CL4LBBQA.js} +2 -2
  36. package/package.json +9 -8
  37. package/src/bin.ts +55 -88
  38. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +1 -1
  39. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +1 -1
  40. package/src/commands/__tests__/context-cloud.test.ts +291 -0
  41. package/src/commands/__tests__/govern-scan.test.ts +185 -0
  42. package/src/commands/__tests__/govern.test.ts +1 -0
  43. package/src/commands/context-cloud.ts +355 -0
  44. package/src/commands/govern-scan-report.ts +170 -0
  45. package/src/commands/govern-scan.ts +282 -135
  46. package/src/commands/govern.ts +0 -157
  47. package/src/mcp/__tests__/server.integration.test.ts +9 -20
  48. package/src/service/enhance/codebase-scanner.ts +3 -2
  49. package/src/service/enhance/types.ts +3 -0
  50. package/dist/chunk-32LIWN2P.js.map +0 -1
  51. package/dist/chunk-6SQPP47U.js.map +0 -1
  52. package/dist/chunk-HQ6A6DTV.js.map +0 -1
  53. package/dist/chunk-MHIBEEW4.js +0 -511
  54. package/dist/chunk-MHIBEEW4.js.map +0 -1
  55. package/dist/chunk-QCN35LJU.js.map +0 -1
  56. package/dist/govern-scan-DW4QUAYD.js +0 -414
  57. package/dist/govern-scan-DW4QUAYD.js.map +0 -1
  58. package/dist/init-cloud-3DNKPWFB.js +0 -304
  59. package/dist/init-cloud-3DNKPWFB.js.map +0 -1
  60. package/dist/node-37AUE74M.js +0 -65
  61. package/dist/push-contracts-WY32TFP6.js +0 -84
  62. package/dist/push-contracts-WY32TFP6.js.map +0 -1
  63. package/dist/scan-PKSYSTRR.js +0 -15
  64. package/dist/static-viewer-7QIBQZRC.js.map +0 -1
  65. package/dist/token-parser-32KOIOFN.js +0 -22
  66. package/dist/token-parser-32KOIOFN.js.map +0 -1
  67. package/dist/tokens-push-HY3KO36V.js +0 -148
  68. package/dist/tokens-push-HY3KO36V.js.map +0 -1
  69. package/src/commands/init-cloud.ts +0 -382
  70. package/src/commands/push-contracts.ts +0 -112
  71. package/src/commands/tokens-push.ts +0 -199
  72. /package/dist/{chunk-ONUP6Z4W.js.map → chunk-B4A4ZEGS.js.map} +0 -0
  73. /package/dist/{chunk-BJE3425I.js.map → chunk-MN3B2EE6.js.map} +0 -0
  74. /package/dist/{chunk-2WXKALIG.js.map → chunk-SNZXGHL2.js.map} +0 -0
  75. /package/dist/{chunk-5JF26E55.js.map → chunk-VT2J62ND.js.map} +0 -0
  76. /package/dist/{codebase-scanner-MQHUZC2G.js.map → codebase-scanner-2T5QIDBA.js.map} +0 -0
  77. /package/dist/{create-EXURTBKK.js.map → create-D44QD7MV.js.map} +0 -0
  78. /package/dist/{doctor-BDPMYYE6.js.map → doctor-7B5N4JYU.js.map} +0 -0
  79. /package/dist/{generate-PVOLUAAC.js.map → generate-T47JZRVU.js.map} +0 -0
  80. /package/dist/{init-SSGUSP7Z.js.map → init-2RGAY4W6.js.map} +0 -0
  81. /package/dist/{node-37AUE74M.js.map → scan-A2WJM54L.js.map} +0 -0
  82. /package/dist/{scan-generate-VY27PIOX.js.map → scan-generate-LUSOHT36.js.map} +0 -0
  83. /package/dist/{scan-PKSYSTRR.js.map → service-ROCP7TKG.js.map} +0 -0
  84. /package/dist/{snapshot-WIJMEIFT.js.map → snapshot-B3SAW74Y.js.map} +0 -0
  85. /package/dist/{service-QJGWUIVL.js.map → static-viewer-7L6UEYTJ.js.map} +0 -0
  86. /package/dist/{test-64Z5BKBA.js.map → test-PQDVDURE.js.map} +0 -0
  87. /package/dist/{token-normalizer-TEPOVBPV.js.map → token-normalizer-7TFCVDZL.js.map} +0 -0
  88. /package/dist/{tokens-generate-5JQSJ27E.js.map → tokens-generate-CL4LBBQA.js.map} +0 -0
@@ -3,14 +3,22 @@
3
3
  *
4
4
  * Parses real JSX/TSX files via the existing codebase scanner, converts
5
5
  * component usages to UISpec, and runs governance checks per file.
6
- * Optionally submits results to Fragments Cloud.
7
6
  */
8
7
 
9
8
  import pc from 'picocolors';
10
9
  import { resolve, relative } from 'node:path';
11
- import { existsSync } from 'node:fs';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { execSync } from 'node:child_process';
12
12
  import { BRAND } from '../core/index.js';
13
13
  import type { ComponentUsage } from '../service/enhance/types.js';
14
+ import type { GovernanceVerdict } from '@fragments-sdk/govern';
15
+ import {
16
+ aggregateVerdicts,
17
+ flattenComponentUsage,
18
+ buildComplianceSummary,
19
+ writeGovernScanReport,
20
+ type GovernScanReport,
21
+ } from './govern-scan-report.js';
14
22
 
15
23
  // ---------------------------------------------------------------------------
16
24
  // Options
@@ -23,8 +31,16 @@ export interface GovernScanOptions {
23
31
  config?: string;
24
32
  /** Output format */
25
33
  format?: 'summary' | 'json' | 'sarif';
34
+ /** Write an aggregated machine-readable JSON report */
35
+ report?: string;
26
36
  /** Suppress non-error output */
27
37
  quiet?: boolean;
38
+ /** Fragments Cloud API key — reports findings to Cloud */
39
+ apiKey?: string;
40
+ /** Fragments Cloud base URL (default: https://app.usefragments.com) */
41
+ cloudUrl?: string;
42
+ /** Only scan files changed vs a base ref (default base: auto-detected merge base) */
43
+ diff?: boolean | string;
28
44
  }
29
45
 
30
46
  export interface GovernWatchOptions extends GovernScanOptions {
@@ -32,6 +48,53 @@ export interface GovernWatchOptions extends GovernScanOptions {
32
48
  debounce?: number;
33
49
  }
34
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // Git diff helpers
53
+ // ---------------------------------------------------------------------------
54
+
55
+ const SCANNABLE_EXTENSIONS = new Set([
56
+ '.tsx', '.ts', '.jsx', '.js',
57
+ ]);
58
+
59
+ function getChangedFiles(rootDir: string, base?: string): string[] | null {
60
+ try {
61
+ const baseRef = base || detectMergeBase(rootDir);
62
+ if (!baseRef) return null;
63
+
64
+ const output = execSync(
65
+ `git diff --name-only --diff-filter=ACMR ${baseRef}...HEAD`,
66
+ { cwd: rootDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
67
+ );
68
+
69
+ return output
70
+ .split('\n')
71
+ .map((f) => f.trim())
72
+ .filter((f) => f && SCANNABLE_EXTENSIONS.has(f.slice(f.lastIndexOf('.'))))
73
+ .map((f) => resolve(rootDir, f));
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function detectMergeBase(rootDir: string): string | null {
80
+ try {
81
+ const remote = execSync('git rev-parse --abbrev-ref origin/HEAD', {
82
+ cwd: rootDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
83
+ }).trim();
84
+ if (remote) return remote;
85
+ } catch { /* fallback */ }
86
+
87
+ for (const candidate of ['origin/main', 'origin/master']) {
88
+ try {
89
+ execSync(`git rev-parse --verify ${candidate}`, {
90
+ cwd: rootDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
91
+ });
92
+ return candidate;
93
+ } catch { /* try next */ }
94
+ }
95
+ return null;
96
+ }
97
+
35
98
  // ---------------------------------------------------------------------------
36
99
  // Scan defaults — applied when no config file exists
37
100
  // ---------------------------------------------------------------------------
@@ -78,6 +141,7 @@ function groupByFile(usages: ComponentUsage[]): Map<string, ComponentUsage[]> {
78
141
  return grouped;
79
142
  }
80
143
 
144
+
81
145
  // ---------------------------------------------------------------------------
82
146
  // governScan
83
147
  // ---------------------------------------------------------------------------
@@ -89,9 +153,9 @@ export async function governScan(
89
153
  loadPolicy,
90
154
  createEngine,
91
155
  buildAdaptersFromConfig,
92
- createCloudAdapter,
93
156
  formatVerdict,
94
157
  computeComponentHealth,
158
+ computeScore,
95
159
  } = await import('@fragments-sdk/govern');
96
160
 
97
161
  const { scanCodebase } = await import(
@@ -125,54 +189,38 @@ export async function governScan(
125
189
  }
126
190
  }
127
191
 
128
- // 3. Extract code tokens for cloud ingest
129
- let codeTokens: string | undefined;
130
- if (process.env.FRAGMENTS_API_KEY) {
131
- codeTokens = await extractCodeTokens(rootDir, options.config, quiet);
132
- }
133
-
134
- // 3b. Load contract registry for governance + cloud ingest (if fragments.json exists)
135
- let contractRegistry: string | undefined;
192
+ // 3. Load contract registry for contract-aware scoring (if fragments.json exists)
136
193
  let registryMap: Record<string, unknown> | undefined;
194
+ let hasRegistry = false;
137
195
  {
138
- const { readFileSync, existsSync } = await import('node:fs');
139
196
  const fragmentsJsonPath = resolve(rootDir, 'fragments.json');
140
197
  if (existsSync(fragmentsJsonPath)) {
141
198
  try {
142
199
  const raw = readFileSync(fragmentsJsonPath, 'utf-8');
143
200
  const parsed = JSON.parse(raw);
144
201
  if (parsed.fragments && Array.isArray(parsed.fragments)) {
145
- // Build name-keyed map for engine registry injection
146
202
  const map: Record<string, unknown> = {};
147
- for (const f of parsed.fragments) {
148
- if (f.meta?.name) {
149
- map[f.meta.name] = f;
203
+ for (const fragment of parsed.fragments) {
204
+ if (fragment.meta?.name) {
205
+ map[fragment.meta.name] = fragment;
150
206
  }
151
207
  }
152
208
  registryMap = map;
153
-
154
- if (process.env.FRAGMENTS_API_KEY) {
155
- contractRegistry = JSON.stringify({ fragments: parsed.fragments });
156
- }
209
+ hasRegistry = true;
157
210
  if (!quiet) {
158
- console.log(pc.dim(` Contract registry loaded (${parsed.fragments.length} components)\n`));
211
+ console.log(
212
+ pc.dim(` Contract registry loaded (${parsed.fragments.length} components)\n`),
213
+ );
159
214
  }
160
215
  }
161
216
  } catch {
162
- // Invalid fragments.json — skip
217
+ // Invalid fragments.json — skip registry-aware summary
163
218
  }
164
219
  }
165
220
  }
166
221
 
167
- // 4. Build adapters. Auto-add cloud if FRAGMENTS_API_KEY is set
222
+ // 4. Build adapters from policy config
168
223
  const adapters = buildAdaptersFromConfig(policy.audit);
169
- const hasCloudAdapter = adapters.length > 0 && policy.audit?.cloud;
170
- if (!hasCloudAdapter && process.env.FRAGMENTS_API_KEY) {
171
- adapters.push(createCloudAdapter({ codeTokens, contractRegistry }));
172
- if (!quiet) {
173
- console.log(pc.dim(' Cloud audit enabled (FRAGMENTS_API_KEY detected)\n'));
174
- }
175
- }
176
224
 
177
225
  // 5. Create engine (with registry for contract-aware validators)
178
226
  const engine = createEngine(
@@ -183,14 +231,36 @@ export async function governScan(
183
231
  : undefined,
184
232
  );
185
233
 
186
- // 6. Scan codebase
187
- if (!quiet) {
234
+ // 6. Scan codebase (optionally scoped to changed files via --diff)
235
+ let diffFiles: string[] | undefined;
236
+ if (options.diff) {
237
+ const base = typeof options.diff === 'string' ? options.diff : undefined;
238
+ const changed = getChangedFiles(rootDir, base);
239
+ if (changed && changed.length > 0) {
240
+ diffFiles = changed;
241
+ if (!quiet) {
242
+ console.log(pc.dim(` Diff mode: scanning ${changed.length} changed file(s)...\n`));
243
+ }
244
+ } else if (changed && changed.length === 0) {
245
+ if (!quiet) {
246
+ console.log(pc.green(' No scannable files changed — all clear.\n'));
247
+ }
248
+ return { exitCode: 0 };
249
+ } else {
250
+ if (!quiet) {
251
+ console.log(pc.yellow(' Could not detect git diff — falling back to full scan.\n'));
252
+ }
253
+ }
254
+ }
255
+
256
+ if (!quiet && !diffFiles) {
188
257
  console.log(pc.dim(' Scanning files...\n'));
189
258
  }
190
259
 
191
260
  const analysis = await scanCodebase({
192
261
  rootDir,
193
262
  useCache: true,
263
+ files: diffFiles,
194
264
  onProgress: quiet
195
265
  ? undefined
196
266
  : (progress) => {
@@ -220,6 +290,16 @@ export async function governScan(
220
290
  if (!quiet) {
221
291
  console.log(pc.yellow(' No component usages found.\n'));
222
292
  }
293
+ if (options.report) {
294
+ const report: GovernScanReport = {
295
+ verdict: aggregateVerdicts([], computeScore, 'ci'),
296
+ componentUsage: [],
297
+ };
298
+ await writeGovernScanReport(options.report, report);
299
+ if (!quiet) {
300
+ console.log(pc.dim(` Wrote governance report: ${resolve(options.report)}\n`));
301
+ }
302
+ }
223
303
  return { exitCode: 0 };
224
304
  }
225
305
 
@@ -229,17 +309,7 @@ export async function governScan(
229
309
  let passedFiles = 0;
230
310
  let totalViolations = 0;
231
311
  const violationCounts = new Map<string, number>();
232
- const allVerdicts: Awaited<ReturnType<typeof engine.check>>[] = [];
233
-
234
- // Build per-file usage snapshot for cloud
235
- const usageSnapshot: Array<{
236
- file: string;
237
- components: Array<{
238
- name: string;
239
- line: number;
240
- props: { static: Record<string, unknown>; dynamic: string[] };
241
- }>;
242
- }> = [];
312
+ const allVerdicts: GovernanceVerdict[] = [];
243
313
 
244
314
  for (const [filePath, usages] of grouped) {
245
315
  const spec = usagesToSpec(usages, filePath, rootDir);
@@ -251,19 +321,6 @@ export async function governScan(
251
321
  });
252
322
  allVerdicts.push(verdict);
253
323
 
254
- // Collect per-file usage snapshot
255
- usageSnapshot.push({
256
- file: relPath,
257
- components: usages.map((u) => ({
258
- name: u.componentName,
259
- line: u.line,
260
- props: {
261
- static: u.props.static,
262
- dynamic: u.props.dynamic,
263
- },
264
- })),
265
- });
266
-
267
324
  totalFiles++;
268
325
 
269
326
  if (verdict.passed) {
@@ -340,103 +397,197 @@ export async function governScan(
340
397
  }
341
398
  }
342
399
 
343
- // 10. Push component usage snapshot + health to Cloud (if API key set)
344
- if (process.env.FRAGMENTS_API_KEY && usageSnapshot.length > 0) {
345
- try {
346
- const apiKey = process.env.FRAGMENTS_API_KEY;
347
- const url = process.env.FRAGMENTS_URL ?? 'https://app.usefragments.com';
348
- await fetch(`${url}/api/ingest`, {
349
- method: 'POST',
350
- headers: {
351
- 'Content-Type': 'application/json',
352
- Authorization: `Bearer ${apiKey}`,
353
- },
354
- body: JSON.stringify({
355
- componentUsage: JSON.stringify(usageSnapshot),
356
- componentHealth: JSON.stringify(health),
357
- }),
358
- });
359
- } catch {
360
- // Non-critical — don't fail the scan
400
+ if (options.report) {
401
+ const report: GovernScanReport = {
402
+ verdict: aggregateVerdicts(allVerdicts, computeScore, 'ci'),
403
+ componentUsage: flattenComponentUsage(allUsages, rootDir),
404
+ };
405
+ if (hasRegistry) {
406
+ report.complianceSummary = buildComplianceSummary(health);
407
+ }
408
+ await writeGovernScanReport(options.report, report);
409
+ if (!quiet) {
410
+ console.log(pc.dim(` Wrote governance report: ${resolve(options.report)}\n`));
361
411
  }
362
412
  }
363
413
 
414
+ // Report to Fragments Cloud
415
+ if (options.apiKey) {
416
+ await reportToCloud({
417
+ apiKey: options.apiKey,
418
+ cloudUrl: options.cloudUrl,
419
+ verdicts: allVerdicts,
420
+ rootDir,
421
+ quiet,
422
+ diffOnly: !!diffFiles,
423
+ });
424
+ }
425
+
364
426
  return { exitCode: passedFiles === totalFiles ? 0 : 1 };
365
427
  }
366
428
 
367
429
  // ---------------------------------------------------------------------------
368
- // Token extraction for cloud ingest
430
+ // Cloud reporting
369
431
  // ---------------------------------------------------------------------------
370
432
 
371
- /**
372
- * Auto-detect and extract code tokens to send alongside governance verdicts.
373
- * Tries: 1) tokens config from fragments.config.ts, 2) Tailwind config.
374
- * Returns a flat JSON string of token name → value, or undefined if nothing found.
375
- */
376
- async function extractCodeTokens(
377
- rootDir: string,
378
- configPath?: string,
379
- quiet?: boolean,
380
- ): Promise<string | undefined> {
381
- try {
382
- // 1. Try fragments.config.ts tokens config
433
+ const DEFAULT_CLOUD_URL = 'https://app.usefragments.com';
434
+
435
+ interface CloudReportOptions {
436
+ apiKey: string;
437
+ cloudUrl?: string;
438
+ verdicts: GovernanceVerdict[];
439
+ rootDir: string;
440
+ quiet: boolean;
441
+ diffOnly?: boolean;
442
+ }
443
+
444
+ function detectGitMetadata(): {
445
+ commitSha?: string;
446
+ branch?: string;
447
+ pr?: number;
448
+ repoFullName?: string;
449
+ } {
450
+ const env = process.env;
451
+ const meta: ReturnType<typeof detectGitMetadata> = {};
452
+
453
+ if (env.GITHUB_SHA) meta.commitSha = env.GITHUB_SHA;
454
+ if (env.GITHUB_REPOSITORY) meta.repoFullName = env.GITHUB_REPOSITORY;
455
+ if (env.GITHUB_REF_NAME) meta.branch = env.GITHUB_REF_NAME;
456
+
457
+ if (env.GITHUB_EVENT_NAME === 'pull_request' && env.GITHUB_EVENT_PATH) {
383
458
  try {
384
- const { loadConfig } = await import('../core/node.js');
385
- const { parseTokenFiles } = await import('../service/index.js');
386
-
387
- const { config, configDir } = await loadConfig(configPath);
388
- if (config.tokens?.include?.length) {
389
- const result = await parseTokenFiles(config.tokens, configDir);
390
- if (result.tokens.length > 0) {
391
- const flat: Record<string, string> = {};
392
- for (const token of result.tokens) {
393
- flat[token.name] = token.resolvedValue;
394
- }
395
- if (!quiet) {
396
- console.log(
397
- pc.dim(` Extracted ${result.tokens.length} code tokens from config\n`),
398
- );
399
- }
400
- return JSON.stringify(flat);
401
- }
459
+ const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf-8'));
460
+ if (event?.pull_request?.number) {
461
+ meta.pr = event.pull_request.number;
462
+ }
463
+ if (event?.pull_request?.head?.sha) {
464
+ meta.commitSha = event.pull_request.head?.sha;
402
465
  }
403
466
  } catch {
404
- // No config or no tokens section — fall through
467
+ // Event file unreadable skip PR number
405
468
  }
469
+ }
406
470
 
407
- // 2. Try Tailwind config
408
- const {
409
- findTailwindConfig,
410
- loadTailwindConfig,
411
- } = await import('../service/token-normalizer.js');
412
-
413
- const tailwindPath = findTailwindConfig(rootDir);
414
- if (tailwindPath) {
415
- const tokens = await loadTailwindConfig(tailwindPath);
416
- if (tokens.length > 0) {
417
- const flat: Record<string, string> = {};
418
- for (const token of tokens) {
419
- flat[token.name] = token.value;
420
- }
421
- if (!quiet) {
422
- console.log(
423
- pc.dim(` Extracted ${tokens.length} tokens from Tailwind config\n`),
424
- );
471
+ return meta;
472
+ }
473
+
474
+ async function reportToCloud(options: CloudReportOptions): Promise<void> {
475
+ const { apiKey, verdicts, rootDir, quiet, diffOnly } = options;
476
+ const baseUrl = (options.cloudUrl ?? DEFAULT_CLOUD_URL).replace(/\/+$/, '');
477
+
478
+ const findings: Array<{
479
+ ruleId: string;
480
+ severity: 'error' | 'warning' | 'info';
481
+ filePath?: string;
482
+ line?: number;
483
+ column?: number;
484
+ rawValue?: string;
485
+ suggestedToken?: string;
486
+ message: string;
487
+ category?: string;
488
+ fingerprint: string;
489
+ }> = [];
490
+
491
+ const { createHash } = await import('node:crypto');
492
+
493
+ for (const verdict of verdicts) {
494
+ for (const result of verdict.results) {
495
+ for (const v of result.violations) {
496
+ const severity = v.severity === 'critical' || v.severity === 'serious'
497
+ ? 'error'
498
+ : v.severity === 'moderate'
499
+ ? 'warning'
500
+ : 'info';
501
+
502
+ const fingerprint = createHash('sha256')
503
+ .update(`${v.rule}:${v.nodeType}:${v.nodeId}:${v.message}`)
504
+ .digest('hex')
505
+ .slice(0, 16);
506
+
507
+ let filePath: string | undefined;
508
+ let line: number | undefined;
509
+ let column: number | undefined;
510
+
511
+ if (v.filePath) {
512
+ filePath = v.filePath;
513
+ line = v.line;
514
+ column = v.column;
515
+ } else if (v.nodeId) {
516
+ const parts = v.nodeId.split(':');
517
+ if (parts.length >= 3) {
518
+ const col = parseInt(parts.pop()!, 10);
519
+ const ln = parseInt(parts.pop()!, 10);
520
+ const path = parts.join(':');
521
+ if (!isNaN(ln) && !isNaN(col) && path) {
522
+ filePath = path;
523
+ line = ln;
524
+ column = col;
525
+ }
526
+ }
527
+ if (!filePath) {
528
+ filePath = relative(rootDir, v.nodeId);
529
+ }
425
530
  }
426
- return JSON.stringify(flat);
531
+
532
+ findings.push({
533
+ ruleId: v.rule,
534
+ severity,
535
+ filePath,
536
+ line,
537
+ column,
538
+ rawValue: v.rawValue,
539
+ message: `[${result.validator}] ${v.message}`,
540
+ category: result.validator,
541
+ fingerprint,
542
+ suggestedToken: v.suggestion,
543
+ });
427
544
  }
428
545
  }
429
- } catch (error) {
546
+ }
547
+
548
+ if (!quiet) {
549
+ console.log(pc.dim(` Reporting ${findings.length} finding(s) to Fragments Cloud...`));
550
+ }
551
+
552
+ const gitMeta = detectGitMetadata();
553
+
554
+ try {
555
+ const response = await fetch(`${baseUrl}/api/govern/ingest`, {
556
+ method: 'POST',
557
+ headers: {
558
+ 'Content-Type': 'application/json',
559
+ 'Authorization': `Bearer ${apiKey}`,
560
+ },
561
+ body: JSON.stringify({
562
+ findings,
563
+ source: 'ci',
564
+ diffOnly: diffOnly ?? false,
565
+ ...gitMeta,
566
+ }),
567
+ });
568
+
569
+ if (!response.ok) {
570
+ const body = await response.json().catch(() => ({}));
571
+ const msg = (body as { error?: string }).error ?? `HTTP ${response.status}`;
572
+ console.error(pc.red(` ✗ Cloud report failed: ${msg}\n`));
573
+ return;
574
+ }
575
+
576
+ const body = await response.json() as { ingested?: number; orgSlug?: string };
430
577
  if (!quiet) {
431
578
  console.log(
432
- pc.dim(
433
- ` Token extraction skipped: ${error instanceof Error ? error.message : 'unknown error'}\n`,
434
- ),
579
+ pc.green(` ✓ Reported ${body.ingested ?? findings.length} finding(s) to Cloud`) +
580
+ (body.orgSlug ? pc.dim(` (${body.orgSlug})`) : '') +
581
+ '\n',
435
582
  );
436
583
  }
584
+ } catch (err) {
585
+ console.error(
586
+ pc.red(` ✗ Cloud report failed: `) +
587
+ pc.dim(err instanceof Error ? err.message : 'Network error') +
588
+ '\n',
589
+ );
437
590
  }
438
-
439
- return undefined;
440
591
  }
441
592
 
442
593
  // ---------------------------------------------------------------------------
@@ -450,7 +601,6 @@ export async function governWatch(
450
601
  loadPolicy,
451
602
  createEngine,
452
603
  buildAdaptersFromConfig,
453
- createCloudAdapter,
454
604
  formatVerdict,
455
605
  } = await import('@fragments-sdk/govern');
456
606
 
@@ -480,9 +630,6 @@ export async function governWatch(
480
630
  policy = { ...policy, rules: SCAN_DEFAULT_RULES };
481
631
  }
482
632
  const adapters = buildAdaptersFromConfig(policy.audit);
483
- if (!adapters.some(() => policy.audit?.cloud) && process.env.FRAGMENTS_API_KEY) {
484
- adapters.push(createCloudAdapter());
485
- }
486
633
  const engine = createEngine(policy, adapters);
487
634
 
488
635
  // 3. Watch for changes