@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.
- package/dist/bin.js +901 -789
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-6SQPP47U.js → chunk-ANTWP3UG.js} +532 -31
- package/dist/chunk-ANTWP3UG.js.map +1 -0
- package/dist/{chunk-ONUP6Z4W.js → chunk-B4A4ZEGS.js} +9 -9
- package/dist/{chunk-32LIWN2P.js → chunk-FFCI6OVZ.js} +584 -261
- package/dist/chunk-FFCI6OVZ.js.map +1 -0
- package/dist/{chunk-HQ6A6DTV.js → chunk-HNHE64CR.js} +315 -1089
- package/dist/chunk-HNHE64CR.js.map +1 -0
- package/dist/{chunk-BJE3425I.js → chunk-MN3B2EE6.js} +2 -2
- package/dist/{chunk-QCN35LJU.js → chunk-SAQW37L5.js} +3 -2
- package/dist/chunk-SAQW37L5.js.map +1 -0
- package/dist/{chunk-2WXKALIG.js → chunk-SNZXGHL2.js} +2 -2
- package/dist/{chunk-5JF26E55.js → chunk-VT2J62ND.js} +11 -11
- package/dist/{codebase-scanner-MQHUZC2G.js → codebase-scanner-2T5QIDBA.js} +2 -2
- package/dist/core/index.js +53 -1
- package/dist/{create-EXURTBKK.js → create-D44QD7MV.js} +2 -2
- package/dist/{doctor-BDPMYYE6.js → doctor-7B5N4JYU.js} +2 -2
- package/dist/{generate-PVOLUAAC.js → generate-T47JZRVU.js} +4 -4
- package/dist/govern-scan-X6UEIOSV.js +632 -0
- package/dist/govern-scan-X6UEIOSV.js.map +1 -0
- package/dist/index.js +7 -8
- package/dist/index.js.map +1 -1
- package/dist/{init-SSGUSP7Z.js → init-2RGAY4W6.js} +5 -5
- package/dist/mcp-bin.js +2 -2
- package/dist/scan-A2WJM54L.js +14 -0
- package/dist/{scan-generate-VY27PIOX.js → scan-generate-LUSOHT36.js} +4 -4
- package/dist/{service-QJGWUIVL.js → service-ROCP7TKG.js} +13 -15
- package/dist/{snapshot-WIJMEIFT.js → snapshot-B3SAW74Y.js} +2 -2
- package/dist/{static-viewer-7QIBQZRC.js → static-viewer-7L6UEYTJ.js} +3 -3
- package/dist/{test-64Z5BKBA.js → test-PQDVDURE.js} +3 -3
- package/dist/{token-normalizer-TEPOVBPV.js → token-normalizer-7TFCVDZL.js} +2 -2
- package/dist/{tokens-NZWFQIAB.js → tokens-64FG5FDP.js} +8 -9
- package/dist/{tokens-NZWFQIAB.js.map → tokens-64FG5FDP.js.map} +1 -1
- package/dist/{tokens-generate-5JQSJ27E.js → tokens-generate-CL4LBBQA.js} +2 -2
- package/package.json +9 -8
- package/src/bin.ts +55 -88
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +1 -1
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +1 -1
- package/src/commands/__tests__/context-cloud.test.ts +291 -0
- package/src/commands/__tests__/govern-scan.test.ts +185 -0
- package/src/commands/__tests__/govern.test.ts +1 -0
- package/src/commands/context-cloud.ts +355 -0
- package/src/commands/govern-scan-report.ts +170 -0
- package/src/commands/govern-scan.ts +282 -135
- package/src/commands/govern.ts +0 -157
- package/src/mcp/__tests__/server.integration.test.ts +9 -20
- package/src/service/enhance/codebase-scanner.ts +3 -2
- package/src/service/enhance/types.ts +3 -0
- package/dist/chunk-32LIWN2P.js.map +0 -1
- package/dist/chunk-6SQPP47U.js.map +0 -1
- package/dist/chunk-HQ6A6DTV.js.map +0 -1
- package/dist/chunk-MHIBEEW4.js +0 -511
- package/dist/chunk-MHIBEEW4.js.map +0 -1
- package/dist/chunk-QCN35LJU.js.map +0 -1
- package/dist/govern-scan-DW4QUAYD.js +0 -414
- package/dist/govern-scan-DW4QUAYD.js.map +0 -1
- package/dist/init-cloud-3DNKPWFB.js +0 -304
- package/dist/init-cloud-3DNKPWFB.js.map +0 -1
- package/dist/node-37AUE74M.js +0 -65
- package/dist/push-contracts-WY32TFP6.js +0 -84
- package/dist/push-contracts-WY32TFP6.js.map +0 -1
- package/dist/scan-PKSYSTRR.js +0 -15
- package/dist/static-viewer-7QIBQZRC.js.map +0 -1
- package/dist/token-parser-32KOIOFN.js +0 -22
- package/dist/token-parser-32KOIOFN.js.map +0 -1
- package/dist/tokens-push-HY3KO36V.js +0 -148
- package/dist/tokens-push-HY3KO36V.js.map +0 -1
- package/src/commands/init-cloud.ts +0 -382
- package/src/commands/push-contracts.ts +0 -112
- package/src/commands/tokens-push.ts +0 -199
- /package/dist/{chunk-ONUP6Z4W.js.map → chunk-B4A4ZEGS.js.map} +0 -0
- /package/dist/{chunk-BJE3425I.js.map → chunk-MN3B2EE6.js.map} +0 -0
- /package/dist/{chunk-2WXKALIG.js.map → chunk-SNZXGHL2.js.map} +0 -0
- /package/dist/{chunk-5JF26E55.js.map → chunk-VT2J62ND.js.map} +0 -0
- /package/dist/{codebase-scanner-MQHUZC2G.js.map → codebase-scanner-2T5QIDBA.js.map} +0 -0
- /package/dist/{create-EXURTBKK.js.map → create-D44QD7MV.js.map} +0 -0
- /package/dist/{doctor-BDPMYYE6.js.map → doctor-7B5N4JYU.js.map} +0 -0
- /package/dist/{generate-PVOLUAAC.js.map → generate-T47JZRVU.js.map} +0 -0
- /package/dist/{init-SSGUSP7Z.js.map → init-2RGAY4W6.js.map} +0 -0
- /package/dist/{node-37AUE74M.js.map → scan-A2WJM54L.js.map} +0 -0
- /package/dist/{scan-generate-VY27PIOX.js.map → scan-generate-LUSOHT36.js.map} +0 -0
- /package/dist/{scan-PKSYSTRR.js.map → service-ROCP7TKG.js.map} +0 -0
- /package/dist/{snapshot-WIJMEIFT.js.map → snapshot-B3SAW74Y.js.map} +0 -0
- /package/dist/{service-QJGWUIVL.js.map → static-viewer-7L6UEYTJ.js.map} +0 -0
- /package/dist/{test-64Z5BKBA.js.map → test-PQDVDURE.js.map} +0 -0
- /package/dist/{token-normalizer-TEPOVBPV.js.map → token-normalizer-7TFCVDZL.js.map} +0 -0
- /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.
|
|
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
|
|
148
|
-
if (
|
|
149
|
-
map[
|
|
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(
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
//
|
|
430
|
+
// Cloud reporting
|
|
369
431
|
// ---------------------------------------------------------------------------
|
|
370
432
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
rootDir: string
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (
|
|
389
|
-
|
|
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
|
-
//
|
|
467
|
+
// Event file unreadable — skip PR number
|
|
405
468
|
}
|
|
469
|
+
}
|
|
406
470
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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.
|
|
433
|
-
|
|
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
|