@fragments-sdk/cli 0.15.10 → 0.16.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 (78) hide show
  1. package/dist/bin.js +896 -787
  2. package/dist/bin.js.map +1 -1
  3. package/dist/{chunk-6SQPP47U.js → chunk-77AAP6R6.js} +532 -31
  4. package/dist/chunk-77AAP6R6.js.map +1 -0
  5. package/dist/{chunk-5JF26E55.js → chunk-ACFVKMVZ.js} +11 -11
  6. package/dist/{chunk-BJE3425I.js → chunk-ACX7YWZW.js} +2 -2
  7. package/dist/{chunk-ONUP6Z4W.js → chunk-G6UVWMFU.js} +8 -8
  8. package/dist/{chunk-2WXKALIG.js → chunk-OZZ4SVZX.js} +2 -2
  9. package/dist/{chunk-32LIWN2P.js → chunk-SJFSG7QF.js} +582 -261
  10. package/dist/chunk-SJFSG7QF.js.map +1 -0
  11. package/dist/{chunk-HQ6A6DTV.js → chunk-XRADMHMV.js} +315 -1089
  12. package/dist/chunk-XRADMHMV.js.map +1 -0
  13. package/dist/core/index.js +53 -1
  14. package/dist/{create-EXURTBKK.js → create-3ZFYQB3T.js} +2 -2
  15. package/dist/{doctor-BDPMYYE6.js → doctor-4IDUM7HI.js} +2 -2
  16. package/dist/{generate-PVOLUAAC.js → generate-VNUUWVWQ.js} +4 -4
  17. package/dist/{govern-scan-DW4QUAYD.js → govern-scan-HTACKYPF.js} +158 -120
  18. package/dist/govern-scan-HTACKYPF.js.map +1 -0
  19. package/dist/index.js +6 -7
  20. package/dist/index.js.map +1 -1
  21. package/dist/{init-SSGUSP7Z.js → init-PXFRAQ64.js} +5 -5
  22. package/dist/mcp-bin.js +2 -2
  23. package/dist/{scan-PKSYSTRR.js → scan-L4GWGEZX.js} +5 -6
  24. package/dist/{scan-generate-VY27PIOX.js → scan-generate-74EYSAGH.js} +4 -4
  25. package/dist/{service-QJGWUIVL.js → service-VELQHEWV.js} +12 -14
  26. package/dist/{snapshot-WIJMEIFT.js → snapshot-DT4B6DPR.js} +2 -2
  27. package/dist/{static-viewer-7QIBQZRC.js → static-viewer-E4OJWFDJ.js} +3 -3
  28. package/dist/{test-64Z5BKBA.js → test-QJY2QO4X.js} +3 -3
  29. package/dist/{token-normalizer-TEPOVBPV.js → token-normalizer-56H4242J.js} +2 -2
  30. package/dist/{tokens-NZWFQIAB.js → tokens-K6URXFPK.js} +7 -8
  31. package/dist/{tokens-NZWFQIAB.js.map → tokens-K6URXFPK.js.map} +1 -1
  32. package/dist/{tokens-generate-5JQSJ27E.js → tokens-generate-EL6IN536.js} +2 -2
  33. package/package.json +7 -6
  34. package/src/bin.ts +49 -88
  35. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +1 -1
  36. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +1 -1
  37. package/src/commands/__tests__/context-cloud.test.ts +291 -0
  38. package/src/commands/__tests__/govern-scan.test.ts +185 -0
  39. package/src/commands/__tests__/govern.test.ts +1 -0
  40. package/src/commands/context-cloud.ts +355 -0
  41. package/src/commands/govern-scan-report.ts +170 -0
  42. package/src/commands/govern-scan.ts +42 -147
  43. package/src/commands/govern.ts +0 -157
  44. package/dist/chunk-32LIWN2P.js.map +0 -1
  45. package/dist/chunk-6SQPP47U.js.map +0 -1
  46. package/dist/chunk-HQ6A6DTV.js.map +0 -1
  47. package/dist/chunk-MHIBEEW4.js +0 -511
  48. package/dist/chunk-MHIBEEW4.js.map +0 -1
  49. package/dist/govern-scan-DW4QUAYD.js.map +0 -1
  50. package/dist/init-cloud-3DNKPWFB.js +0 -304
  51. package/dist/init-cloud-3DNKPWFB.js.map +0 -1
  52. package/dist/node-37AUE74M.js +0 -65
  53. package/dist/push-contracts-WY32TFP6.js +0 -84
  54. package/dist/push-contracts-WY32TFP6.js.map +0 -1
  55. package/dist/static-viewer-7QIBQZRC.js.map +0 -1
  56. package/dist/token-parser-32KOIOFN.js +0 -22
  57. package/dist/token-parser-32KOIOFN.js.map +0 -1
  58. package/dist/tokens-push-HY3KO36V.js +0 -148
  59. package/dist/tokens-push-HY3KO36V.js.map +0 -1
  60. package/src/commands/init-cloud.ts +0 -382
  61. package/src/commands/push-contracts.ts +0 -112
  62. package/src/commands/tokens-push.ts +0 -199
  63. /package/dist/{chunk-5JF26E55.js.map → chunk-ACFVKMVZ.js.map} +0 -0
  64. /package/dist/{chunk-BJE3425I.js.map → chunk-ACX7YWZW.js.map} +0 -0
  65. /package/dist/{chunk-ONUP6Z4W.js.map → chunk-G6UVWMFU.js.map} +0 -0
  66. /package/dist/{chunk-2WXKALIG.js.map → chunk-OZZ4SVZX.js.map} +0 -0
  67. /package/dist/{create-EXURTBKK.js.map → create-3ZFYQB3T.js.map} +0 -0
  68. /package/dist/{doctor-BDPMYYE6.js.map → doctor-4IDUM7HI.js.map} +0 -0
  69. /package/dist/{generate-PVOLUAAC.js.map → generate-VNUUWVWQ.js.map} +0 -0
  70. /package/dist/{init-SSGUSP7Z.js.map → init-PXFRAQ64.js.map} +0 -0
  71. /package/dist/{node-37AUE74M.js.map → scan-L4GWGEZX.js.map} +0 -0
  72. /package/dist/{scan-generate-VY27PIOX.js.map → scan-generate-74EYSAGH.js.map} +0 -0
  73. /package/dist/{scan-PKSYSTRR.js.map → service-VELQHEWV.js.map} +0 -0
  74. /package/dist/{snapshot-WIJMEIFT.js.map → snapshot-DT4B6DPR.js.map} +0 -0
  75. /package/dist/{service-QJGWUIVL.js.map → static-viewer-E4OJWFDJ.js.map} +0 -0
  76. /package/dist/{test-64Z5BKBA.js.map → test-QJY2QO4X.js.map} +0 -0
  77. /package/dist/{token-normalizer-TEPOVBPV.js.map → token-normalizer-56H4242J.js.map} +0 -0
  78. /package/dist/{tokens-generate-5JQSJ27E.js.map → tokens-generate-EL6IN536.js.map} +0 -0
@@ -0,0 +1,355 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import {
3
+ access,
4
+ mkdir,
5
+ readFile,
6
+ writeFile,
7
+ } from 'node:fs/promises';
8
+ import { dirname, resolve } from 'node:path';
9
+ import pc from 'picocolors';
10
+ import { strFromU8, unzipSync } from 'fflate';
11
+ import {
12
+ bundleManifestSchema,
13
+ bundleTargetSchema,
14
+ type BundleManifest,
15
+ type BundleTarget,
16
+ } from '@fragments-sdk/core';
17
+
18
+ const DEFAULT_CLOUD_URL =
19
+ process.env.FRAGMENTS_CLOUD_URL ?? 'https://app.usefragments.com';
20
+ const MANAGED_START = '<!-- BEGIN FRAGMENTS DESIGN SYSTEM -->';
21
+ const MANAGED_END = '<!-- END FRAGMENTS DESIGN SYSTEM -->';
22
+
23
+ const ROOT_FILE_TARGETS: Partial<
24
+ Record<BundleTarget, { helperPath: string; rootPath: string }>
25
+ > = {
26
+ agents: {
27
+ helperPath: '.fragments/instructions/agents.md',
28
+ rootPath: 'AGENTS.md',
29
+ },
30
+ claude: {
31
+ helperPath: '.fragments/instructions/claude-code.md',
32
+ rootPath: 'CLAUDE.md',
33
+ },
34
+ copilot: {
35
+ helperPath: '.fragments/instructions/copilot.md',
36
+ rootPath: '.github/copilot-instructions.md',
37
+ },
38
+ };
39
+
40
+ export interface ContextCloudInstallOptions {
41
+ apiKey?: string;
42
+ targets?: string;
43
+ cwd?: string;
44
+ dryRun?: boolean;
45
+ yes?: boolean;
46
+ rootFiles?: 'prompt' | 'never' | 'patch';
47
+ gitignoreFragments?: boolean;
48
+ }
49
+
50
+ export interface ContextCloudStatusOptions {
51
+ apiKey?: string;
52
+ cwd?: string;
53
+ }
54
+
55
+ function parseManifestOrThrow(
56
+ payload: string,
57
+ sourceLabel: 'local' | 'remote' | 'downloaded',
58
+ ) {
59
+ let parsed: unknown;
60
+ try {
61
+ parsed = JSON.parse(payload);
62
+ } catch {
63
+ throw new Error(`Invalid ${sourceLabel} Fragments manifest JSON.`);
64
+ }
65
+
66
+ const result = bundleManifestSchema.safeParse(parsed);
67
+ if (result.success) {
68
+ return result.data;
69
+ }
70
+
71
+ const schemaVersion =
72
+ parsed &&
73
+ typeof parsed === 'object' &&
74
+ 'schemaVersion' in parsed &&
75
+ typeof (parsed as { schemaVersion?: unknown }).schemaVersion === 'number'
76
+ ? (parsed as { schemaVersion: number }).schemaVersion
77
+ : null;
78
+
79
+ if (schemaVersion !== null) {
80
+ throw new Error(
81
+ `Unsupported Fragments bundle schemaVersion ${schemaVersion}. Upgrade your CLI.`,
82
+ );
83
+ }
84
+
85
+ throw new Error(`Invalid ${sourceLabel} Fragments manifest.`);
86
+ }
87
+
88
+ function resolveApiKey(explicit?: string) {
89
+ const apiKey = explicit ?? process.env.FRAGMENTS_API_KEY;
90
+ if (!apiKey) {
91
+ throw new Error(
92
+ 'Missing Fragments Cloud API key. Set FRAGMENTS_API_KEY or pass --api-key.',
93
+ );
94
+ }
95
+ return apiKey;
96
+ }
97
+
98
+ function resolveTargets(csv?: string) {
99
+ if (!csv) return null;
100
+ const targets = csv
101
+ .split(',')
102
+ .map((value) => value.trim())
103
+ .filter(Boolean);
104
+ for (const target of targets) {
105
+ bundleTargetSchema.parse(target);
106
+ }
107
+ return Array.from(new Set(targets)) as BundleTarget[];
108
+ }
109
+
110
+ function managedBlock(content: string) {
111
+ return `${MANAGED_START}\n${content.trim()}\n${MANAGED_END}\n`;
112
+ }
113
+
114
+ function upsertManagedBlock(existing: string, content: string) {
115
+ const block = managedBlock(content);
116
+ if (existing.includes(MANAGED_START) && existing.includes(MANAGED_END)) {
117
+ return existing.replace(
118
+ new RegExp(
119
+ `${MANAGED_START}[\\s\\S]*?${MANAGED_END}\\n?`,
120
+ 'm',
121
+ ),
122
+ block,
123
+ );
124
+ }
125
+ return existing.trim()
126
+ ? `${existing.trimEnd()}\n\n${block}`
127
+ : block;
128
+ }
129
+
130
+ async function fileExists(path: string) {
131
+ try {
132
+ await access(path);
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ async function writeManagedFile(path: string, content: string, dryRun?: boolean) {
140
+ if (dryRun) return;
141
+ await mkdir(dirname(path), { recursive: true });
142
+ await writeFile(path, content, 'utf-8');
143
+ }
144
+
145
+ async function fetchBundle(args: {
146
+ apiKey: string;
147
+ targets: BundleTarget[] | null;
148
+ }) {
149
+ const url = new URL('/api/bundle', DEFAULT_CLOUD_URL);
150
+ if (args.targets?.length) {
151
+ url.searchParams.set('targets', args.targets.join(','));
152
+ }
153
+
154
+ const response = await fetch(url, {
155
+ headers: {
156
+ Authorization: `Bearer ${args.apiKey}`,
157
+ },
158
+ });
159
+ if (!response.ok) {
160
+ const payload = (await response.json().catch(() => null)) as
161
+ | { message?: string; error?: string }
162
+ | null;
163
+ throw new Error(payload?.message ?? payload?.error ?? 'Bundle download failed.');
164
+ }
165
+
166
+ try {
167
+ const archive = unzipSync(new Uint8Array(await response.arrayBuffer()));
168
+ return Object.fromEntries(
169
+ Object.entries(archive).map(([path, bytes]) => [path, strFromU8(bytes)]),
170
+ ) as Record<string, string>;
171
+ } catch {
172
+ throw new Error('Bundle download was corrupted, try again.');
173
+ }
174
+ }
175
+
176
+ async function fetchRemoteManifest(apiKey: string) {
177
+ const url = new URL('/api/bundle-artifact', DEFAULT_CLOUD_URL);
178
+ url.searchParams.set('artifact', 'manifest');
179
+ const response = await fetch(url, {
180
+ headers: {
181
+ Authorization: `Bearer ${apiKey}`,
182
+ },
183
+ });
184
+ if (!response.ok) {
185
+ const payload = (await response.json().catch(() => null)) as
186
+ | { message?: string; error?: string }
187
+ | null;
188
+ throw new Error(payload?.message ?? payload?.error ?? 'Unable to fetch remote manifest.');
189
+ }
190
+ const payload = (await response.json()) as { content: string };
191
+ return parseManifestOrThrow(payload.content, 'remote');
192
+ }
193
+
194
+ function resolveRootFileMode(options: ContextCloudInstallOptions) {
195
+ if (options.rootFiles) {
196
+ return options.rootFiles;
197
+ }
198
+ if (options.yes || !process.stdout.isTTY) {
199
+ return 'never' as const;
200
+ }
201
+ return 'prompt' as const;
202
+ }
203
+
204
+ async function maybePatchRootFile(args: {
205
+ projectRoot: string;
206
+ target: keyof typeof ROOT_FILE_TARGETS;
207
+ files: Record<string, string>;
208
+ mode: 'prompt' | 'never' | 'patch';
209
+ dryRun?: boolean;
210
+ }) {
211
+ const config = ROOT_FILE_TARGETS[args.target];
212
+ if (!config) return;
213
+
214
+ const snippet = args.files[config.helperPath];
215
+ if (!snippet) return;
216
+
217
+ const rootPath = resolve(args.projectRoot, config.rootPath);
218
+ const exists = await fileExists(rootPath);
219
+ if (args.mode === 'never') {
220
+ console.log(
221
+ pc.dim(
222
+ ` • Next step: paste ${config.helperPath} into ${config.rootPath} if you want repo-level instructions.`,
223
+ ),
224
+ );
225
+ return;
226
+ }
227
+
228
+ if (args.mode === 'prompt' && exists) {
229
+ const approved = await confirm({
230
+ message: `Update ${config.rootPath} with a Fragments-managed block?`,
231
+ default: true,
232
+ });
233
+ if (!approved) {
234
+ return;
235
+ }
236
+ }
237
+
238
+ if (args.mode === 'prompt' && !exists) {
239
+ console.log(
240
+ pc.dim(
241
+ ` • ${config.rootPath} does not exist. Keeping snippet-only output in ${config.helperPath}.`,
242
+ ),
243
+ );
244
+ return;
245
+ }
246
+
247
+ const nextContent = exists
248
+ ? upsertManagedBlock(await readFile(rootPath, 'utf-8'), snippet)
249
+ : managedBlock(snippet);
250
+ await writeManagedFile(rootPath, nextContent, args.dryRun);
251
+ console.log(pc.green(` • Updated ${config.rootPath}`));
252
+ }
253
+
254
+ async function maybeUpdateGitignore(projectRoot: string, dryRun?: boolean) {
255
+ const gitignorePath = resolve(projectRoot, '.gitignore');
256
+ const existing = (await fileExists(gitignorePath))
257
+ ? await readFile(gitignorePath, 'utf-8')
258
+ : '';
259
+ if (existing.includes('.fragments/')) {
260
+ return;
261
+ }
262
+ const next = existing.trimEnd()
263
+ ? `${existing.trimEnd()}\n.fragments/\n`
264
+ : '.fragments/\n';
265
+ await writeManagedFile(gitignorePath, next, dryRun);
266
+ }
267
+
268
+ export async function contextInstallCloud(
269
+ options: ContextCloudInstallOptions,
270
+ ) {
271
+ const projectRoot = resolve(process.cwd(), options.cwd ?? '.');
272
+ const apiKey = resolveApiKey(options.apiKey);
273
+ const targets = resolveTargets(options.targets);
274
+ const files = await fetchBundle({ apiKey, targets });
275
+ const manifest = parseManifestOrThrow(
276
+ files['.fragments/manifest.json'] ?? '{}',
277
+ 'downloaded',
278
+ );
279
+ const mode = resolveRootFileMode(options);
280
+
281
+ console.log(
282
+ pc.cyan(
283
+ `Installing Fragments bundle (${manifest.totalComponents} components, revision ${manifest.catalogRevision})`,
284
+ ),
285
+ );
286
+
287
+ for (const [relativePath, content] of Object.entries(files).sort(([a], [b]) =>
288
+ a.localeCompare(b),
289
+ )) {
290
+ const absolutePath = resolve(projectRoot, relativePath);
291
+ if (!options.dryRun) {
292
+ await mkdir(dirname(absolutePath), { recursive: true });
293
+ await writeFile(absolutePath, content, 'utf-8');
294
+ }
295
+ console.log(pc.green(` • Wrote ${relativePath}`));
296
+ }
297
+
298
+ if (options.gitignoreFragments) {
299
+ await maybeUpdateGitignore(projectRoot, options.dryRun);
300
+ console.log(
301
+ pc.yellow(
302
+ ' • Added .fragments/ to .gitignore. This disables committed offline context by default.',
303
+ ),
304
+ );
305
+ }
306
+
307
+ for (const target of ['agents', 'claude', 'copilot'] as const) {
308
+ await maybePatchRootFile({
309
+ projectRoot,
310
+ target,
311
+ files,
312
+ mode,
313
+ dryRun: options.dryRun,
314
+ });
315
+ }
316
+
317
+ console.log(
318
+ pc.dim(
319
+ `Status: ${options.dryRun ? 'dry run only — no files written' : 'bundle installed successfully'}`,
320
+ ),
321
+ );
322
+ }
323
+
324
+ export async function contextStatusCloud(
325
+ options: ContextCloudStatusOptions,
326
+ ) {
327
+ const projectRoot = resolve(process.cwd(), options.cwd ?? '.');
328
+ const manifestPath = resolve(projectRoot, '.fragments/manifest.json');
329
+ if (!(await fileExists(manifestPath))) {
330
+ console.log(pc.yellow('Status: missing'));
331
+ console.log(pc.dim('No local .fragments/manifest.json was found.'));
332
+ return;
333
+ }
334
+
335
+ const parsedLocalManifest = parseManifestOrThrow(
336
+ await readFile(manifestPath, 'utf-8'),
337
+ 'local',
338
+ );
339
+ const remoteManifest = await fetchRemoteManifest(resolveApiKey(options.apiKey));
340
+
341
+ const status =
342
+ parsedLocalManifest.catalogRevision === remoteManifest.catalogRevision
343
+ ? 'up-to-date'
344
+ : 'outdated';
345
+
346
+ console.log(pc.cyan(`Status: ${status}`));
347
+ console.log(
348
+ pc.dim(` Local revision: ${parsedLocalManifest.catalogRevision}`),
349
+ );
350
+ console.log(pc.dim(` Remote revision: ${remoteManifest.catalogRevision}`));
351
+ console.log(
352
+ pc.dim(` Local updated: ${parsedLocalManifest.catalogUpdatedAt}`),
353
+ );
354
+ console.log(pc.dim(` Remote updated: ${remoteManifest.catalogUpdatedAt}`));
355
+ }
@@ -0,0 +1,170 @@
1
+ import { resolve, dirname, relative } from 'node:path';
2
+ import type {
3
+ GovernanceVerdict,
4
+ Severity,
5
+ ComponentHealthSummary,
6
+ Violation,
7
+ } from '@fragments-sdk/govern';
8
+ import type { ComponentUsage } from '../service/enhance/types.js';
9
+
10
+ export interface FlatUsageEntry {
11
+ component: string;
12
+ file: string;
13
+ occurrences: number;
14
+ }
15
+
16
+ export interface ComplianceSummary {
17
+ complianceRate: number;
18
+ passingUsages: number;
19
+ totalUsages: number;
20
+ contractedCount: number;
21
+ detectedCount: number;
22
+ }
23
+
24
+ export interface GovernScanReport {
25
+ verdict: GovernanceVerdict;
26
+ componentUsage: FlatUsageEntry[];
27
+ complianceSummary?: ComplianceSummary;
28
+ }
29
+
30
+ const SEVERITY_RANK: Record<Severity, number> = {
31
+ critical: 4,
32
+ serious: 3,
33
+ moderate: 2,
34
+ minor: 1,
35
+ };
36
+
37
+ function mergeSeverity(a: Severity, b: Severity): Severity {
38
+ return SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b;
39
+ }
40
+
41
+ export function aggregateVerdicts(
42
+ verdicts: GovernanceVerdict[],
43
+ computeScore: (violations: Violation[]) => number,
44
+ runner: string = 'cli',
45
+ ): GovernanceVerdict {
46
+ if (verdicts.length === 0) {
47
+ return {
48
+ passed: true,
49
+ score: 100,
50
+ results: [],
51
+ metadata: {
52
+ runner,
53
+ duration: 0,
54
+ nodeCount: 0,
55
+ componentTypes: [],
56
+ },
57
+ };
58
+ }
59
+
60
+ const byValidator = new Map<string, GovernanceVerdict['results'][number]>();
61
+ let duration = 0;
62
+ let nodeCount = 0;
63
+ const componentTypes = new Set<string>();
64
+
65
+ for (const verdict of verdicts) {
66
+ duration += verdict.metadata.duration;
67
+ nodeCount += verdict.metadata.nodeCount;
68
+ for (const type of verdict.metadata.componentTypes) {
69
+ componentTypes.add(type);
70
+ }
71
+
72
+ for (const result of verdict.results) {
73
+ const existing = byValidator.get(result.validator);
74
+ if (!existing) {
75
+ byValidator.set(result.validator, {
76
+ validator: result.validator,
77
+ severity: result.severity,
78
+ passed: result.passed,
79
+ violations: [...result.violations],
80
+ suggestions: result.suggestions ? [...result.suggestions] : undefined,
81
+ });
82
+ continue;
83
+ }
84
+
85
+ existing.passed = existing.passed && result.passed;
86
+ existing.severity = mergeSeverity(existing.severity, result.severity);
87
+ existing.violations.push(...result.violations);
88
+
89
+ if (result.suggestions?.length) {
90
+ const merged = existing.suggestions ?? [];
91
+ merged.push(...result.suggestions);
92
+ existing.suggestions = merged;
93
+ }
94
+ }
95
+ }
96
+
97
+ const results = [...byValidator.values()].sort((a, b) =>
98
+ a.validator.localeCompare(b.validator),
99
+ );
100
+ const allViolations = results.flatMap((result) => result.violations);
101
+
102
+ return {
103
+ passed: verdicts.every((verdict) => verdict.passed),
104
+ score: computeScore(allViolations),
105
+ results,
106
+ metadata: {
107
+ runner,
108
+ duration,
109
+ nodeCount,
110
+ componentTypes: [...componentTypes].sort(),
111
+ },
112
+ };
113
+ }
114
+
115
+ export function flattenComponentUsage(
116
+ usages: ComponentUsage[],
117
+ rootDir: string,
118
+ ): FlatUsageEntry[] {
119
+ const counts = new Map<string, number>();
120
+
121
+ for (const usage of usages) {
122
+ const relPath = relative(rootDir, usage.filePath);
123
+ const key = `${relPath}\u0000${usage.componentName}`;
124
+ counts.set(key, (counts.get(key) ?? 0) + 1);
125
+ }
126
+
127
+ return [...counts.entries()]
128
+ .map(([key, occurrences]) => {
129
+ const separatorIndex = key.indexOf('\u0000');
130
+ const file = key.slice(0, separatorIndex);
131
+ const component = key.slice(separatorIndex + 1);
132
+ return { component, file, occurrences };
133
+ })
134
+ .sort((a, b) =>
135
+ a.file === b.file
136
+ ? a.component.localeCompare(b.component)
137
+ : a.file.localeCompare(b.file),
138
+ );
139
+ }
140
+
141
+ export function buildComplianceSummary(
142
+ health: ComponentHealthSummary,
143
+ ): ComplianceSummary {
144
+ const usageTotals = Object.values(health.components).reduce(
145
+ (acc, component) => {
146
+ acc.passingUsages += component.passed;
147
+ acc.totalUsages += component.total;
148
+ return acc;
149
+ },
150
+ { passingUsages: 0, totalUsages: 0 },
151
+ );
152
+
153
+ return {
154
+ complianceRate: health.overallCompliance,
155
+ passingUsages: usageTotals.passingUsages,
156
+ totalUsages: usageTotals.totalUsages,
157
+ contractedCount: health.contractedComponents,
158
+ detectedCount: health.totalComponents,
159
+ };
160
+ }
161
+
162
+ export async function writeGovernScanReport(
163
+ path: string,
164
+ report: GovernScanReport,
165
+ ): Promise<void> {
166
+ const { mkdir, writeFile } = await import('node:fs/promises');
167
+ const absPath = resolve(path);
168
+ await mkdir(dirname(absPath), { recursive: true });
169
+ await writeFile(absPath, JSON.stringify(report, null, 2), 'utf-8');
170
+ }