flecto 2.0.0 → 2.1.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/CHANGELOG.md +103 -0
- package/README.md +258 -120
- package/index.js +269 -38
- package/package.json +7 -6
- package/schemas/flecto-policy-pack-2.0.json +124 -0
- package/src/alerter.js +4 -3
- package/src/config.js +23 -2
- package/src/differ.js +95 -45
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +1 -1
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/strict-prod.json +1 -1
- package/src/policy-test.js +124 -0
- package/src/policy.js +325 -27
- package/src/renderer.js +7 -4
- package/src/watcher.js +18 -8
package/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { program } from 'commander';
|
|
4
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'fs';
|
|
5
5
|
import { resolve, relative, dirname, join } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { createHash } from 'crypto';
|
|
@@ -22,7 +22,8 @@ import {
|
|
|
22
22
|
} from './src/renderer.js';
|
|
23
23
|
import { fireAlerts } from './src/alerter.js';
|
|
24
24
|
import { createEnvelope } from './src/envelope.js';
|
|
25
|
-
import { evaluatePolicies, highestSeverity } from './src/policy.js';
|
|
25
|
+
import { evaluatePolicies, highestSeverity, listPolicyPacks } from './src/policy.js';
|
|
26
|
+
import { testPolicyFixture } from './src/policy-test.js';
|
|
26
27
|
import {
|
|
27
28
|
loadRcConfig,
|
|
28
29
|
resolveEffectiveOptions,
|
|
@@ -48,6 +49,90 @@ function snapshotPathForFile(absPath) {
|
|
|
48
49
|
return resolve(`${SNAPSHOT_DIR}/${id}.json`);
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
function snapshotHistoryPathForFile(absPath) {
|
|
53
|
+
const id = snapshotIdForPath(absPath);
|
|
54
|
+
let timestamp = Date.now();
|
|
55
|
+
let path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
|
|
56
|
+
while (existsSync(path)) {
|
|
57
|
+
timestamp += 1;
|
|
58
|
+
path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
|
|
59
|
+
}
|
|
60
|
+
return path;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasSnapshotHistoryForFile(absPath) {
|
|
64
|
+
if (!existsSync(SNAPSHOT_DIR)) return false;
|
|
65
|
+
const id = snapshotIdForPath(absPath);
|
|
66
|
+
return readdirSync(SNAPSHOT_DIR).some((name) => new RegExp(`^${id}\\.\\d+\\.json$`).test(name));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function preserveLegacySnapshotForHistory(absPath, snapshotPath) {
|
|
70
|
+
if (!existsSync(snapshotPath) || hasSnapshotHistoryForFile(absPath)) return;
|
|
71
|
+
|
|
72
|
+
const legacy = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
73
|
+
writeFileSync(
|
|
74
|
+
snapshotHistoryPathForFile(absPath),
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
file: legacy.file ?? absPath,
|
|
77
|
+
state: legacy.state ?? legacy,
|
|
78
|
+
createdAt: legacy.createdAt ?? statSync(snapshotPath).mtime.toISOString(),
|
|
79
|
+
}, null, 2),
|
|
80
|
+
'utf8',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readLocalSnapshotHistory() {
|
|
85
|
+
if (!existsSync(SNAPSHOT_DIR)) return [];
|
|
86
|
+
|
|
87
|
+
const entries = readdirSync(SNAPSHOT_DIR, { withFileTypes: true })
|
|
88
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
|
|
89
|
+
const historyEntries = entries.filter((entry) => /^[a-f0-9]{16}\.\d+\.json$/.test(entry.name));
|
|
90
|
+
const historyIds = new Set(historyEntries.map((entry) => entry.name.slice(0, 16)));
|
|
91
|
+
const legacyEntries = entries.filter((entry) =>
|
|
92
|
+
/^[a-f0-9]{16}\.json$/.test(entry.name) && !historyIds.has(entry.name.slice(0, 16)));
|
|
93
|
+
const snapshotEntries = [...historyEntries, ...legacyEntries];
|
|
94
|
+
|
|
95
|
+
return snapshotEntries.map((entry) => {
|
|
96
|
+
const path = resolve(SNAPSHOT_DIR, entry.name);
|
|
97
|
+
const snapshot = JSON.parse(readFileSync(path, 'utf8'));
|
|
98
|
+
const state = snapshot?.state ?? snapshot;
|
|
99
|
+
if (typeof snapshot?.file !== 'string') {
|
|
100
|
+
throw new Error(`Invalid snapshot file: ${path}`);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
file: snapshot.file,
|
|
104
|
+
state,
|
|
105
|
+
createdAt: snapshot.createdAt ?? statSync(path).mtime.toISOString(),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function summarizeSnapshotHistory(snapshots, limit, diffOpts = {}) {
|
|
111
|
+
const byFile = new Map();
|
|
112
|
+
for (const snapshot of snapshots) {
|
|
113
|
+
const records = byFile.get(snapshot.file) ?? [];
|
|
114
|
+
records.push(snapshot);
|
|
115
|
+
byFile.set(snapshot.file, records);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const summaries = [];
|
|
119
|
+
for (const records of byFile.values()) {
|
|
120
|
+
records.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
|
121
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
122
|
+
summaries.push({
|
|
123
|
+
...records[index],
|
|
124
|
+
changeCount: index === 0
|
|
125
|
+
? 0
|
|
126
|
+
: diffTrees(records[index - 1].state, records[index].state, diffOpts).length,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return summaries
|
|
132
|
+
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
|
133
|
+
.slice(0, limit);
|
|
134
|
+
}
|
|
135
|
+
|
|
51
136
|
function parseCsv(value) {
|
|
52
137
|
if (!value) return [];
|
|
53
138
|
if (Array.isArray(value)) return value;
|
|
@@ -80,30 +165,20 @@ function validateInterval(interval) {
|
|
|
80
165
|
}
|
|
81
166
|
}
|
|
82
167
|
|
|
83
|
-
function stripUnsetCliOverrides(opts) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
'policies',
|
|
88
|
-
'plugins',
|
|
89
|
-
'arrayIdKey',
|
|
90
|
-
'maskSecrets',
|
|
91
|
-
'maskSecretsWebhooks',
|
|
92
|
-
'arrayIgnoreOrder',
|
|
93
|
-
'snapshotRef',
|
|
94
|
-
'ignore',
|
|
95
|
-
]) {
|
|
96
|
-
if (out[key] === undefined || out[key] === false || out[key] === null || out[key] === '') {
|
|
97
|
-
delete out[key];
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return out;
|
|
168
|
+
function stripUnsetCliOverrides(opts, command) {
|
|
169
|
+
return Object.fromEntries(
|
|
170
|
+
Object.entries(opts).filter(([key]) => command.getOptionValueSource(key) === 'cli'),
|
|
171
|
+
);
|
|
101
172
|
}
|
|
102
173
|
|
|
103
174
|
function diffOptionsFromEffective(effective, ignorePaths) {
|
|
175
|
+
const arrayIdKey = effective.arrayIdKey || null;
|
|
104
176
|
return {
|
|
105
177
|
ignorePaths,
|
|
106
|
-
arrayIdKey
|
|
178
|
+
arrayIdKey,
|
|
179
|
+
// Explicit --array-id-key / arrayIdKey enables identity matching even when
|
|
180
|
+
// .flectorc sets arrayId:false (index escape hatch for auto-detect only).
|
|
181
|
+
arrayIdentity: arrayIdKey ? true : effective.arrayId !== false,
|
|
107
182
|
arrayIgnoreOrder: Boolean(effective.arrayIgnoreOrder),
|
|
108
183
|
};
|
|
109
184
|
}
|
|
@@ -174,6 +249,19 @@ function shouldFailFromChanges(events, failOn) {
|
|
|
174
249
|
return false;
|
|
175
250
|
}
|
|
176
251
|
|
|
252
|
+
function escapeWorkflowCommandData(value) {
|
|
253
|
+
return String(value)
|
|
254
|
+
.replaceAll('%', '%25')
|
|
255
|
+
.replaceAll('\r', '%0D')
|
|
256
|
+
.replaceAll('\n', '%0A');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function escapeWorkflowCommandProperty(value) {
|
|
260
|
+
return escapeWorkflowCommandData(value)
|
|
261
|
+
.replaceAll(':', '%3A')
|
|
262
|
+
.replaceAll(',', '%2C');
|
|
263
|
+
}
|
|
264
|
+
|
|
177
265
|
function printCiOutput(results, format) {
|
|
178
266
|
if (format === 'json') {
|
|
179
267
|
console.log(JSON.stringify(results, null, 2));
|
|
@@ -190,13 +278,14 @@ function printCiOutput(results, format) {
|
|
|
190
278
|
for (const event of result.envelope.changes) {
|
|
191
279
|
const title = `flecto ${event.type}`;
|
|
192
280
|
const detail = event.note ? `${event.path} (${event.note})` : event.path;
|
|
193
|
-
console.log(`::warning file=${result.file},title=${title}::${detail}`);
|
|
281
|
+
console.log(`::warning file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
|
|
194
282
|
}
|
|
195
283
|
for (const finding of result.policies) {
|
|
196
284
|
const level = finding.severity === 'error' ? 'error' : 'warning';
|
|
197
285
|
const pack = finding.pack ? ` [${finding.pack}]` : '';
|
|
198
286
|
const title = `flecto policy ${finding.id}${pack}`;
|
|
199
|
-
|
|
287
|
+
const detail = `${finding.path}: ${finding.message}`;
|
|
288
|
+
console.log(`::${level} file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
|
|
200
289
|
}
|
|
201
290
|
}
|
|
202
291
|
}
|
|
@@ -227,18 +316,20 @@ program
|
|
|
227
316
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
|
|
228
317
|
.option('--policies <ids>', 'Comma-separated policy pack ids (default: default)')
|
|
229
318
|
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
230
|
-
.option('--array-id-key <key>', 'Diff arrays by this object identity key
|
|
319
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key')
|
|
320
|
+
.option('--no-array-id', 'Diff arrays by index instead of object identity')
|
|
231
321
|
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
232
322
|
.option('--mask-secrets', 'Mask secret-like values in human output', false)
|
|
233
323
|
.option('--mask-secrets-webhooks', 'Also mask secrets in webhook payloads', false)
|
|
234
324
|
.option('--snapshot', 'Save current state as baseline instead of watching')
|
|
235
325
|
.option('--diff', 'Diff current file against saved baseline and exit')
|
|
236
|
-
.
|
|
326
|
+
.option('--allow-empty', 'Allow --snapshot to succeed when nothing was written', false)
|
|
327
|
+
.action(async (files, opts, command) => {
|
|
237
328
|
try {
|
|
238
329
|
const { config } = loadRcConfig(process.cwd());
|
|
239
330
|
const profile = resolveProfileName(opts.profile);
|
|
240
|
-
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts));
|
|
241
|
-
const { policies, plugins } = resolvePolicyOptions(effective);
|
|
331
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
|
|
332
|
+
const { policies, plugins, severityRemap } = resolvePolicyOptions(effective);
|
|
242
333
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
243
334
|
if (targets.length === 0) {
|
|
244
335
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
@@ -256,12 +347,30 @@ program
|
|
|
256
347
|
|
|
257
348
|
if (effective.snapshot) {
|
|
258
349
|
mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
350
|
+
let written = 0;
|
|
259
351
|
for (const filepath of targets) {
|
|
260
|
-
if (!existsSync(filepath)
|
|
352
|
+
if (!existsSync(filepath)) {
|
|
353
|
+
renderWarn(`Skipping missing file: ${filepath}`);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (!isSupported(filepath)) {
|
|
357
|
+
renderWarn(`Skipping unsupported file: ${filepath}`);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
261
360
|
const state = parseFile(filepath);
|
|
262
361
|
const snapshotPath = snapshotPathForFile(filepath);
|
|
263
|
-
|
|
362
|
+
preserveLegacySnapshotForHistory(filepath, snapshotPath);
|
|
363
|
+
const snapshot = { file: filepath, state, createdAt: new Date().toISOString() };
|
|
364
|
+
writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf8');
|
|
365
|
+
writeFileSync(snapshotHistoryPathForFile(filepath), JSON.stringify(snapshot, null, 2), 'utf8');
|
|
264
366
|
console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
|
|
367
|
+
written += 1;
|
|
368
|
+
}
|
|
369
|
+
if (written === 0 && !effective.allowEmpty) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
'No snapshots written — all targets were missing or unsupported.' +
|
|
372
|
+
' Pass --allow-empty to allow an empty snapshot run.',
|
|
373
|
+
);
|
|
265
374
|
}
|
|
266
375
|
return;
|
|
267
376
|
}
|
|
@@ -310,10 +419,11 @@ program
|
|
|
310
419
|
source: 'watch',
|
|
311
420
|
policies,
|
|
312
421
|
plugins,
|
|
422
|
+
severityRemap,
|
|
313
423
|
});
|
|
314
424
|
} catch (err) {
|
|
315
425
|
renderError(`policy evaluation failed: ${err.message}`);
|
|
316
|
-
|
|
426
|
+
process.exit(1);
|
|
317
427
|
}
|
|
318
428
|
renderPolicyFindings(policyFindings);
|
|
319
429
|
if (effective.command || effective.webhook) {
|
|
@@ -378,6 +488,56 @@ program
|
|
|
378
488
|
}
|
|
379
489
|
});
|
|
380
490
|
|
|
491
|
+
program
|
|
492
|
+
.command('history [files...]')
|
|
493
|
+
.description('Summarize drift across local snapshots')
|
|
494
|
+
.option('-l, --limit <n>', 'Number of recent snapshots to show', '10')
|
|
495
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
496
|
+
.option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
|
|
497
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key (opt-in)')
|
|
498
|
+
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
499
|
+
.action(async (files, opts, command) => {
|
|
500
|
+
try {
|
|
501
|
+
const limit = Number.parseInt(String(opts.limit), 10);
|
|
502
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
503
|
+
throw new Error('--limit must be a positive integer');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const { config } = loadRcConfig(process.cwd());
|
|
507
|
+
const profile = resolveProfileName(opts.profile);
|
|
508
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
|
|
509
|
+
const ignorePaths = parseCsv(effective.ignore);
|
|
510
|
+
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
511
|
+
|
|
512
|
+
const allSnapshots = readLocalSnapshotHistory();
|
|
513
|
+
let snapshots = allSnapshots;
|
|
514
|
+
if (files.length > 0) {
|
|
515
|
+
const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
|
|
516
|
+
snapshots = snapshots.filter((snapshot) => targets.has(resolve(snapshot.file)));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const summaries = summarizeSnapshotHistory(snapshots, limit, dOpts);
|
|
520
|
+
if (summaries.length === 0) {
|
|
521
|
+
if (files.length > 0 && allSnapshots.length > 0) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
'No local snapshots matched the given files. Omit files to view all saved snapshot history.',
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
console.log(`Local snapshot history (${summaries.length} snapshots)`);
|
|
530
|
+
for (const snapshot of summaries) {
|
|
531
|
+
const file = relative(process.cwd(), snapshot.file) || snapshot.file;
|
|
532
|
+
const changes = `${snapshot.changeCount} change${snapshot.changeCount === 1 ? '' : 's'}`;
|
|
533
|
+
console.log(`${snapshot.createdAt} ${file} — ${changes}`);
|
|
534
|
+
}
|
|
535
|
+
} catch (err) {
|
|
536
|
+
renderError(err.message);
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
381
541
|
program
|
|
382
542
|
.command('ci [files...]')
|
|
383
543
|
.description('Run semantic diff in CI mode')
|
|
@@ -388,22 +548,24 @@ program
|
|
|
388
548
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore')
|
|
389
549
|
.option('--policies <ids>', 'Comma-separated policy pack ids')
|
|
390
550
|
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
391
|
-
.option('--array-id-key <key>', 'Diff arrays by this object identity key
|
|
551
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key')
|
|
552
|
+
.option('--no-array-id', 'Diff arrays by index instead of object identity')
|
|
392
553
|
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
393
554
|
.option('--mask-secrets', 'Mask secret-like values in CI output', false)
|
|
394
|
-
.
|
|
555
|
+
.option('--allow-empty', 'Allow CI to succeed when no files were diffed', false)
|
|
556
|
+
.action(async (files, opts, command) => {
|
|
395
557
|
try {
|
|
396
558
|
const { config } = loadRcConfig(process.cwd());
|
|
397
559
|
const profile = resolveProfileName(opts.profile);
|
|
398
|
-
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts));
|
|
399
|
-
const { policies: packIds, plugins } = resolvePolicyOptions(effective);
|
|
560
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
|
|
561
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective);
|
|
400
562
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
401
563
|
if (targets.length === 0) {
|
|
402
564
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
403
565
|
}
|
|
404
566
|
|
|
405
567
|
const ignorePaths = parseCsv(effective.ignore);
|
|
406
|
-
const failOn = new Set(parseCsv(effective.failOn));
|
|
568
|
+
const failOn = new Set(parseCsv(effective.failOn ?? 'changed,policy,error'));
|
|
407
569
|
const format = String(effective.format ?? 'json');
|
|
408
570
|
if (!['json', 'ndjson', 'github-annotations'].includes(format)) {
|
|
409
571
|
throw new Error('--format must be json, ndjson, or github-annotations');
|
|
@@ -414,9 +576,17 @@ program
|
|
|
414
576
|
/** @type {any[]} */
|
|
415
577
|
const results = [];
|
|
416
578
|
let shouldFail = false;
|
|
579
|
+
let diffed = 0;
|
|
417
580
|
|
|
418
581
|
for (const filepath of targets) {
|
|
419
|
-
if (!existsSync(filepath)
|
|
582
|
+
if (!existsSync(filepath)) {
|
|
583
|
+
renderWarn(`Skipping missing file: ${filepath}`);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (!isSupported(filepath)) {
|
|
587
|
+
renderWarn(`Skipping unsupported file: ${filepath}`);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
420
590
|
const after = parseFile(filepath);
|
|
421
591
|
let before;
|
|
422
592
|
try {
|
|
@@ -435,6 +605,7 @@ program
|
|
|
435
605
|
source: 'ci',
|
|
436
606
|
policies: packIds,
|
|
437
607
|
plugins,
|
|
608
|
+
severityRemap,
|
|
438
609
|
});
|
|
439
610
|
const outboundChanges = maybeMaskChanges(events, maskSecrets);
|
|
440
611
|
const envelope = createEnvelope({
|
|
@@ -444,12 +615,20 @@ program
|
|
|
444
615
|
policies: policyFindings,
|
|
445
616
|
});
|
|
446
617
|
results.push({ file: filepath, envelope, policies: policyFindings });
|
|
618
|
+
diffed += 1;
|
|
447
619
|
|
|
448
620
|
if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
|
|
449
621
|
shouldFail = true;
|
|
450
622
|
}
|
|
451
623
|
}
|
|
452
624
|
|
|
625
|
+
if (diffed === 0 && !effective.allowEmpty) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
'No files were diffed — all targets were missing or unsupported.' +
|
|
628
|
+
' Pass --allow-empty to allow an empty CI run.',
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
453
632
|
printCiOutput(results, format);
|
|
454
633
|
process.exit(shouldFail ? 1 : 0);
|
|
455
634
|
} catch (err) {
|
|
@@ -458,6 +637,53 @@ program
|
|
|
458
637
|
}
|
|
459
638
|
});
|
|
460
639
|
|
|
640
|
+
{
|
|
641
|
+
const policies = program
|
|
642
|
+
.command('policies')
|
|
643
|
+
.description('Work with policy packs and plugins');
|
|
644
|
+
|
|
645
|
+
policies
|
|
646
|
+
.command('test <fixtureDir>')
|
|
647
|
+
.description('Assert policy findings from a fixture directory')
|
|
648
|
+
.option('--config <name>', 'Fixture config file name', 'flecto-policy-test.json')
|
|
649
|
+
.action(async (fixtureDir, opts) => {
|
|
650
|
+
try {
|
|
651
|
+
const result = await testPolicyFixture(fixtureDir, { configName: opts.config });
|
|
652
|
+
console.log(chalk.green(
|
|
653
|
+
`✓ Policy fixture passed: ${result.fixtureDir} (${result.findings.length} findings)`,
|
|
654
|
+
));
|
|
655
|
+
} catch (err) {
|
|
656
|
+
renderError(err.message);
|
|
657
|
+
process.exitCode = 1;
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
policies
|
|
662
|
+
.command('list')
|
|
663
|
+
.description('List built-in and local policy packs')
|
|
664
|
+
.option('--json', 'Output machine-readable JSON')
|
|
665
|
+
.action((opts) => {
|
|
666
|
+
try {
|
|
667
|
+
const packs = listPolicyPacks(process.cwd());
|
|
668
|
+
if (opts.json) {
|
|
669
|
+
console.log(JSON.stringify(packs, null, 2));
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
console.log('Resolution order: policies/<id>.json, .yaml, .yml, then built-in packs.');
|
|
674
|
+
console.log('id\tsource path\trules\toverrides builtin');
|
|
675
|
+
for (const pack of packs) {
|
|
676
|
+
console.log(
|
|
677
|
+
`${pack.id}\t${pack.sourcePath}\t${pack.ruleCount}\t${pack.overridesBuiltin ? 'yes' : 'no'}`,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
} catch (err) {
|
|
681
|
+
renderError(err.message);
|
|
682
|
+
process.exit(1);
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
461
687
|
program
|
|
462
688
|
.command('init')
|
|
463
689
|
.description('Create starter .flectorc configuration')
|
|
@@ -485,8 +711,13 @@ program
|
|
|
485
711
|
exclude: config?.exclude ?? [],
|
|
486
712
|
});
|
|
487
713
|
renderInfo(`resolved files: ${files.length}`);
|
|
714
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
715
|
+
if (major < 20 || (major === 20 && minor < 19)) {
|
|
716
|
+
throw new Error(`Node.js ${process.versions.node} is unsupported. Use Node.js >= 20.19.0.`);
|
|
717
|
+
}
|
|
718
|
+
renderInfo(`node: ${process.versions.node}`);
|
|
488
719
|
if (typeof fetch !== 'function') {
|
|
489
|
-
throw new Error('Global fetch unavailable. Use Node.js >=
|
|
720
|
+
throw new Error('Global fetch unavailable. Use Node.js >= 20.19.0.');
|
|
490
721
|
}
|
|
491
722
|
renderInfo('fetch: available');
|
|
492
723
|
renderInfo(`version: ${PKG.version}`);
|
|
@@ -497,7 +728,7 @@ program
|
|
|
497
728
|
}
|
|
498
729
|
});
|
|
499
730
|
|
|
500
|
-
program.
|
|
731
|
+
await program.parseAsync(process.argv);
|
|
501
732
|
|
|
502
733
|
if (!process.argv.slice(2).length) {
|
|
503
734
|
program.help();
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"access": "public",
|
|
5
5
|
"provenance": true
|
|
6
6
|
},
|
|
7
|
-
"version": "2.
|
|
7
|
+
"version": "2.1.0",
|
|
8
8
|
"description": "Flecto — semantic config watcher that reports meaningful changes in plain English",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"keywords": [
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"url": "git+https://github.com/myselfsiddharth/Flecto.git"
|
|
26
26
|
},
|
|
27
27
|
"engines": {
|
|
28
|
-
"node": ">=
|
|
28
|
+
"node": ">=20.19.0"
|
|
29
29
|
},
|
|
30
30
|
"type": "module",
|
|
31
31
|
"main": "index.js",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"src/**/*",
|
|
38
38
|
"schemas/**/*",
|
|
39
39
|
"README.md",
|
|
40
|
-
"LICENSE"
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"CHANGELOG.md"
|
|
41
42
|
],
|
|
42
43
|
"scripts": {
|
|
43
44
|
"test": "node --test test/*.test.js",
|
|
@@ -47,10 +48,10 @@
|
|
|
47
48
|
"dependencies": {
|
|
48
49
|
"@iarna/toml": "^2.2.5",
|
|
49
50
|
"chalk": "^5.3.0",
|
|
50
|
-
"chokidar": "^
|
|
51
|
+
"chokidar": "^5.0.0",
|
|
51
52
|
"commander": "^12.1.0",
|
|
52
|
-
"dotenv": "^
|
|
53
|
+
"dotenv": "^17.4.2",
|
|
53
54
|
"fast-glob": "^3.3.3",
|
|
54
|
-
"js-yaml": "^4.
|
|
55
|
+
"js-yaml": "^4.3.0"
|
|
55
56
|
}
|
|
56
57
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/myselfsiddharth/Flecto/schemas/flecto-policy-pack-2.0.json",
|
|
4
|
+
"title": "FlectoPolicyPack",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["rules"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"id": { "type": "string", "minLength": 1 },
|
|
10
|
+
"rules": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"items": { "$ref": "#/$defs/rule" }
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"$defs": {
|
|
16
|
+
"rule": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"required": ["id", "severity"],
|
|
20
|
+
"properties": {
|
|
21
|
+
"id": { "type": "string", "minLength": 1 },
|
|
22
|
+
"severity": { "enum": ["info", "warn", "error"] },
|
|
23
|
+
"when": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"minItems": 1,
|
|
26
|
+
"items": { "enum": ["added", "removed", "changed"] }
|
|
27
|
+
},
|
|
28
|
+
"match": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"additionalProperties": false,
|
|
31
|
+
"properties": {
|
|
32
|
+
"path": { "type": "string" },
|
|
33
|
+
"pathFlags": { "type": "string" },
|
|
34
|
+
"pathEquals": { "type": "string" },
|
|
35
|
+
"pathPrefix": { "type": "string" }
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"beforeEquals": true,
|
|
39
|
+
"afterEquals": true,
|
|
40
|
+
"beforeIn": { "type": "array" },
|
|
41
|
+
"afterIn": { "type": "array" },
|
|
42
|
+
"beforeTruthy": { "const": true },
|
|
43
|
+
"afterTruthy": { "const": true },
|
|
44
|
+
"afterMatches": { "type": "string" },
|
|
45
|
+
"numericJump": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"additionalProperties": false,
|
|
48
|
+
"required": ["minMultiple"],
|
|
49
|
+
"properties": {
|
|
50
|
+
"minMultiple": {
|
|
51
|
+
"type": "number",
|
|
52
|
+
"exclusiveMinimum": 0
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"numericDelta": {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"additionalProperties": false,
|
|
59
|
+
"required": ["min"],
|
|
60
|
+
"properties": {
|
|
61
|
+
"min": {
|
|
62
|
+
"type": "number",
|
|
63
|
+
"minimum": 0
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"allOf": {
|
|
68
|
+
"type": "array",
|
|
69
|
+
"minItems": 1,
|
|
70
|
+
"items": { "$ref": "#/$defs/clause" }
|
|
71
|
+
},
|
|
72
|
+
"anyOf": {
|
|
73
|
+
"type": "array",
|
|
74
|
+
"minItems": 1,
|
|
75
|
+
"items": { "$ref": "#/$defs/clause" }
|
|
76
|
+
},
|
|
77
|
+
"message": { "type": "string" },
|
|
78
|
+
"messageTemplate": { "type": "string" }
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"clause": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"additionalProperties": false,
|
|
84
|
+
"properties": {
|
|
85
|
+
"match": { "$ref": "#/$defs/match" },
|
|
86
|
+
"beforeEquals": true,
|
|
87
|
+
"afterEquals": true,
|
|
88
|
+
"beforeIn": { "type": "array" },
|
|
89
|
+
"afterIn": { "type": "array" },
|
|
90
|
+
"beforeTruthy": { "const": true },
|
|
91
|
+
"afterTruthy": { "const": true },
|
|
92
|
+
"afterMatches": { "type": "string" },
|
|
93
|
+
"numericJump": { "$ref": "#/$defs/numericJump" },
|
|
94
|
+
"numericDelta": { "$ref": "#/$defs/numericDelta" }
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"match": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"additionalProperties": false,
|
|
100
|
+
"properties": {
|
|
101
|
+
"path": { "type": "string" },
|
|
102
|
+
"pathFlags": { "type": "string" },
|
|
103
|
+
"pathEquals": { "type": "string" },
|
|
104
|
+
"pathPrefix": { "type": "string" }
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"numericJump": {
|
|
108
|
+
"type": "object",
|
|
109
|
+
"additionalProperties": false,
|
|
110
|
+
"required": ["minMultiple"],
|
|
111
|
+
"properties": {
|
|
112
|
+
"minMultiple": { "type": "number", "exclusiveMinimum": 0 }
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"numericDelta": {
|
|
116
|
+
"type": "object",
|
|
117
|
+
"additionalProperties": false,
|
|
118
|
+
"required": ["min"],
|
|
119
|
+
"properties": {
|
|
120
|
+
"min": { "type": "number", "minimum": 0 }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/alerter.js
CHANGED
|
@@ -11,12 +11,13 @@ const MAX_ENV_CHANGES_CHARS = 16_000;
|
|
|
11
11
|
let alertQueue = Promise.resolve();
|
|
12
12
|
|
|
13
13
|
function enqueue(fn) {
|
|
14
|
-
|
|
15
|
-
.then(
|
|
14
|
+
const result = alertQueue
|
|
15
|
+
.then(() => fn());
|
|
16
|
+
alertQueue = result
|
|
16
17
|
.catch((err) => {
|
|
17
18
|
renderWarn(`Alert pipeline error: ${err?.message ?? String(err)}`);
|
|
18
19
|
});
|
|
19
|
-
return
|
|
20
|
+
return result;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/**
|