flecto 1.0.2 → 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 +297 -109
- package/index.js +362 -35
- package/package.json +8 -6
- package/schemas/flecto-envelope-2.0.json +65 -0
- package/schemas/flecto-policy-pack-2.0.json +124 -0
- package/src/alerter.js +11 -10
- package/src/config.js +59 -2
- package/src/differ.js +153 -16
- package/src/envelope.js +6 -4
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +37 -0
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/strict-prod.json +37 -0
- package/src/parser.js +80 -14
- package/src/policy-test.js +124 -0
- package/src/policy.js +541 -34
- package/src/renderer.js +62 -13
- package/src/watcher.js +28 -11
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';
|
|
@@ -11,11 +11,27 @@ import chalk from 'chalk';
|
|
|
11
11
|
import { parseFile, isSupported, parseContent } from './src/parser.js';
|
|
12
12
|
import { diffTrees } from './src/differ.js';
|
|
13
13
|
import { startWatcher } from './src/watcher.js';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
renderChanges,
|
|
16
|
+
renderDiff,
|
|
17
|
+
renderError,
|
|
18
|
+
renderInfo,
|
|
19
|
+
renderWarn,
|
|
20
|
+
renderPolicyFindings,
|
|
21
|
+
maskChangeEvent,
|
|
22
|
+
} from './src/renderer.js';
|
|
15
23
|
import { fireAlerts } from './src/alerter.js';
|
|
16
24
|
import { createEnvelope } from './src/envelope.js';
|
|
17
|
-
import { evaluatePolicies, highestSeverity } from './src/policy.js';
|
|
18
|
-
import {
|
|
25
|
+
import { evaluatePolicies, highestSeverity, listPolicyPacks } from './src/policy.js';
|
|
26
|
+
import { testPolicyFixture } from './src/policy-test.js';
|
|
27
|
+
import {
|
|
28
|
+
loadRcConfig,
|
|
29
|
+
resolveEffectiveOptions,
|
|
30
|
+
resolveFiles,
|
|
31
|
+
initRcFile,
|
|
32
|
+
resolveProfileName,
|
|
33
|
+
resolvePolicyOptions,
|
|
34
|
+
} from './src/config.js';
|
|
19
35
|
|
|
20
36
|
const PKG = JSON.parse(
|
|
21
37
|
readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'),
|
|
@@ -24,7 +40,6 @@ const PKG = JSON.parse(
|
|
|
24
40
|
const SNAPSHOT_DIR = '.flecto-snapshots';
|
|
25
41
|
|
|
26
42
|
function snapshotIdForPath(absPath) {
|
|
27
|
-
// Stable across platforms and avoids basename collisions
|
|
28
43
|
const normalized = absPath.replaceAll('\\', '/');
|
|
29
44
|
return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
|
30
45
|
}
|
|
@@ -34,6 +49,90 @@ function snapshotPathForFile(absPath) {
|
|
|
34
49
|
return resolve(`${SNAPSHOT_DIR}/${id}.json`);
|
|
35
50
|
}
|
|
36
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
|
+
|
|
37
136
|
function parseCsv(value) {
|
|
38
137
|
if (!value) return [];
|
|
39
138
|
if (Array.isArray(value)) return value;
|
|
@@ -66,6 +165,29 @@ function validateInterval(interval) {
|
|
|
66
165
|
}
|
|
67
166
|
}
|
|
68
167
|
|
|
168
|
+
function stripUnsetCliOverrides(opts, command) {
|
|
169
|
+
return Object.fromEntries(
|
|
170
|
+
Object.entries(opts).filter(([key]) => command.getOptionValueSource(key) === 'cli'),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function diffOptionsFromEffective(effective, ignorePaths) {
|
|
175
|
+
const arrayIdKey = effective.arrayIdKey || null;
|
|
176
|
+
return {
|
|
177
|
+
ignorePaths,
|
|
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,
|
|
182
|
+
arrayIgnoreOrder: Boolean(effective.arrayIgnoreOrder),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function maybeMaskChanges(events, maskSecrets) {
|
|
187
|
+
if (!maskSecrets) return events;
|
|
188
|
+
return events.map(maskChangeEvent);
|
|
189
|
+
}
|
|
190
|
+
|
|
69
191
|
async function resolveTargetFiles(cliFiles, rcConfig) {
|
|
70
192
|
if (cliFiles && cliFiles.length > 0) {
|
|
71
193
|
const direct = [];
|
|
@@ -107,7 +229,6 @@ function readSnapshotStateFromRef(filePath, snapshotRef) {
|
|
|
107
229
|
return readSnapshotStateFromFile(maybePath);
|
|
108
230
|
}
|
|
109
231
|
|
|
110
|
-
// git ref mode: flecto ci file --snapshot-ref HEAD~1
|
|
111
232
|
const rel = relative(process.cwd(), filePath).replaceAll('\\', '/');
|
|
112
233
|
const raw = execFileSync('git', ['show', `${snapshotRef}:${rel}`], { encoding: 'utf8' });
|
|
113
234
|
return parseContent(filePath, raw);
|
|
@@ -128,6 +249,19 @@ function shouldFailFromChanges(events, failOn) {
|
|
|
128
249
|
return false;
|
|
129
250
|
}
|
|
130
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
|
+
|
|
131
265
|
function printCiOutput(results, format) {
|
|
132
266
|
if (format === 'json') {
|
|
133
267
|
console.log(JSON.stringify(results, null, 2));
|
|
@@ -143,11 +277,15 @@ function printCiOutput(results, format) {
|
|
|
143
277
|
for (const result of results) {
|
|
144
278
|
for (const event of result.envelope.changes) {
|
|
145
279
|
const title = `flecto ${event.type}`;
|
|
146
|
-
|
|
280
|
+
const detail = event.note ? `${event.path} (${event.note})` : event.path;
|
|
281
|
+
console.log(`::warning file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
|
|
147
282
|
}
|
|
148
283
|
for (const finding of result.policies) {
|
|
149
284
|
const level = finding.severity === 'error' ? 'error' : 'warning';
|
|
150
|
-
|
|
285
|
+
const pack = finding.pack ? ` [${finding.pack}]` : '';
|
|
286
|
+
const title = `flecto policy ${finding.id}${pack}`;
|
|
287
|
+
const detail = `${finding.path}: ${finding.message}`;
|
|
288
|
+
console.log(`::${level} file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
|
|
151
289
|
}
|
|
152
290
|
}
|
|
153
291
|
}
|
|
@@ -161,7 +299,7 @@ program
|
|
|
161
299
|
program
|
|
162
300
|
.command('watch [files...]')
|
|
163
301
|
.description('Watch config files/globs for semantic changes')
|
|
164
|
-
.option('-p, --profile <name>', 'Use profile from .flectorc')
|
|
302
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
165
303
|
.option('-i, --interval <ms>', 'Polling fallback interval in ms', '100')
|
|
166
304
|
.option('--polling', 'Force polling mode (useful on network drives / some editors)', false)
|
|
167
305
|
.option('-m, --mode <mode>', 'Output mode: compact | verbose', 'compact')
|
|
@@ -176,12 +314,22 @@ program
|
|
|
176
314
|
.option('--webhook-timeout <ms>', 'Webhook timeout in ms', '5000')
|
|
177
315
|
.option('--webhook-retries <n>', 'Webhook retries', '2')
|
|
178
316
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
|
|
317
|
+
.option('--policies <ids>', 'Comma-separated policy pack ids (default: default)')
|
|
318
|
+
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
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')
|
|
321
|
+
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
322
|
+
.option('--mask-secrets', 'Mask secret-like values in human output', false)
|
|
323
|
+
.option('--mask-secrets-webhooks', 'Also mask secrets in webhook payloads', false)
|
|
179
324
|
.option('--snapshot', 'Save current state as baseline instead of watching')
|
|
180
325
|
.option('--diff', 'Diff current file against saved baseline and exit')
|
|
181
|
-
.
|
|
326
|
+
.option('--allow-empty', 'Allow --snapshot to succeed when nothing was written', false)
|
|
327
|
+
.action(async (files, opts, command) => {
|
|
182
328
|
try {
|
|
183
329
|
const { config } = loadRcConfig(process.cwd());
|
|
184
|
-
const
|
|
330
|
+
const profile = resolveProfileName(opts.profile);
|
|
331
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
|
|
332
|
+
const { policies, plugins, severityRemap } = resolvePolicyOptions(effective);
|
|
185
333
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
186
334
|
if (targets.length === 0) {
|
|
187
335
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
@@ -193,15 +341,36 @@ program
|
|
|
193
341
|
validateInterval(interval);
|
|
194
342
|
const mode = String(effective.mode ?? 'compact');
|
|
195
343
|
validateMode(mode);
|
|
344
|
+
const maskSecrets = Boolean(effective.maskSecrets);
|
|
345
|
+
const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
|
|
346
|
+
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
196
347
|
|
|
197
348
|
if (effective.snapshot) {
|
|
198
349
|
mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
350
|
+
let written = 0;
|
|
199
351
|
for (const filepath of targets) {
|
|
200
|
-
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
|
+
}
|
|
201
360
|
const state = parseFile(filepath);
|
|
202
361
|
const snapshotPath = snapshotPathForFile(filepath);
|
|
203
|
-
|
|
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');
|
|
204
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
|
+
);
|
|
205
374
|
}
|
|
206
375
|
return;
|
|
207
376
|
}
|
|
@@ -216,8 +385,8 @@ program
|
|
|
216
385
|
}
|
|
217
386
|
const before = readSnapshotStateFromFile(snapshotPath);
|
|
218
387
|
const after = parseFile(filepath);
|
|
219
|
-
const events = diffTrees(before, after,
|
|
220
|
-
renderDiff(filepath, events);
|
|
388
|
+
const events = diffTrees(before, after, dOpts);
|
|
389
|
+
renderDiff(filepath, events, { maskSecrets });
|
|
221
390
|
if (events.length > 0) hasChanges = true;
|
|
222
391
|
}
|
|
223
392
|
process.exit(hasChanges ? 1 : 0);
|
|
@@ -237,17 +406,33 @@ program
|
|
|
237
406
|
renderInfo(`flecto watching ${chalk.cyan(filepath)}`);
|
|
238
407
|
const watcher = startWatcher(
|
|
239
408
|
filepath,
|
|
240
|
-
{ interval, mode, ignorePaths, polling: Boolean(effective.polling) },
|
|
409
|
+
{ interval, mode, ignorePaths, polling: Boolean(effective.polling), ...dOpts },
|
|
241
410
|
async (event) => {
|
|
242
411
|
if (event.kind === 'changes') {
|
|
243
|
-
renderChanges(event.filepath, event.events, mode);
|
|
244
|
-
|
|
412
|
+
renderChanges(event.filepath, event.events, mode, { maskSecrets });
|
|
413
|
+
let policyFindings = [];
|
|
414
|
+
try {
|
|
415
|
+
policyFindings = await evaluatePolicies(event.events, {
|
|
416
|
+
cwd: process.cwd(),
|
|
417
|
+
file: event.filepath,
|
|
418
|
+
profile: profile ?? null,
|
|
419
|
+
source: 'watch',
|
|
420
|
+
policies,
|
|
421
|
+
plugins,
|
|
422
|
+
severityRemap,
|
|
423
|
+
});
|
|
424
|
+
} catch (err) {
|
|
425
|
+
renderError(`policy evaluation failed: ${err.message}`);
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
245
428
|
renderPolicyFindings(policyFindings);
|
|
246
429
|
if (effective.command || effective.webhook) {
|
|
430
|
+
const outboundChanges = maybeMaskChanges(event.events, maskSecretsWebhooks);
|
|
247
431
|
const envelope = createEnvelope({
|
|
248
432
|
source: 'watch',
|
|
249
433
|
file: event.filepath,
|
|
250
|
-
changes:
|
|
434
|
+
changes: outboundChanges,
|
|
435
|
+
policies: policyFindings,
|
|
251
436
|
});
|
|
252
437
|
await fireAlerts({
|
|
253
438
|
command: effective.command,
|
|
@@ -303,57 +488,147 @@ program
|
|
|
303
488
|
}
|
|
304
489
|
});
|
|
305
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
|
+
|
|
306
541
|
program
|
|
307
542
|
.command('ci [files...]')
|
|
308
543
|
.description('Run semantic diff in CI mode')
|
|
309
|
-
.option('-p, --profile <name>', 'Use profile from .flectorc')
|
|
544
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
310
545
|
.option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
|
|
311
546
|
.option('--format <type>', 'Output format: json | ndjson | github-annotations', 'json')
|
|
312
547
|
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
|
|
313
548
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore')
|
|
314
|
-
.
|
|
549
|
+
.option('--policies <ids>', 'Comma-separated policy pack ids')
|
|
550
|
+
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
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')
|
|
553
|
+
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
554
|
+
.option('--mask-secrets', 'Mask secret-like values in CI output', false)
|
|
555
|
+
.option('--allow-empty', 'Allow CI to succeed when no files were diffed', false)
|
|
556
|
+
.action(async (files, opts, command) => {
|
|
315
557
|
try {
|
|
316
558
|
const { config } = loadRcConfig(process.cwd());
|
|
317
|
-
const
|
|
559
|
+
const profile = resolveProfileName(opts.profile);
|
|
560
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
|
|
561
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective);
|
|
318
562
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
319
563
|
if (targets.length === 0) {
|
|
320
564
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
321
565
|
}
|
|
322
566
|
|
|
323
567
|
const ignorePaths = parseCsv(effective.ignore);
|
|
324
|
-
const failOn = new Set(parseCsv(effective.failOn));
|
|
568
|
+
const failOn = new Set(parseCsv(effective.failOn ?? 'changed,policy,error'));
|
|
325
569
|
const format = String(effective.format ?? 'json');
|
|
326
570
|
if (!['json', 'ndjson', 'github-annotations'].includes(format)) {
|
|
327
571
|
throw new Error('--format must be json, ndjson, or github-annotations');
|
|
328
572
|
}
|
|
573
|
+
const maskSecrets = Boolean(effective.maskSecrets);
|
|
574
|
+
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
329
575
|
|
|
330
576
|
/** @type {any[]} */
|
|
331
577
|
const results = [];
|
|
332
578
|
let shouldFail = false;
|
|
579
|
+
let diffed = 0;
|
|
333
580
|
|
|
334
581
|
for (const filepath of targets) {
|
|
335
|
-
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
|
+
}
|
|
336
590
|
const after = parseFile(filepath);
|
|
337
|
-
let before
|
|
591
|
+
let before;
|
|
338
592
|
try {
|
|
339
593
|
before = readSnapshotStateFromRef(filepath, effective.snapshotRef);
|
|
340
|
-
} catch {
|
|
341
|
-
|
|
594
|
+
} catch (err) {
|
|
595
|
+
throw new Error(
|
|
596
|
+
`Failed to resolve snapshot baseline for "${filepath}"` +
|
|
597
|
+
`${effective.snapshotRef ? ` (ref: ${effective.snapshotRef})` : ''}: ${err.message}`
|
|
598
|
+
);
|
|
342
599
|
}
|
|
343
|
-
const events = diffTrees(before, after,
|
|
344
|
-
const
|
|
600
|
+
const events = diffTrees(before, after, dOpts);
|
|
601
|
+
const policyFindings = await evaluatePolicies(events, {
|
|
602
|
+
cwd: process.cwd(),
|
|
603
|
+
file: filepath,
|
|
604
|
+
profile: profile ?? null,
|
|
605
|
+
source: 'ci',
|
|
606
|
+
policies: packIds,
|
|
607
|
+
plugins,
|
|
608
|
+
severityRemap,
|
|
609
|
+
});
|
|
610
|
+
const outboundChanges = maybeMaskChanges(events, maskSecrets);
|
|
345
611
|
const envelope = createEnvelope({
|
|
346
612
|
source: 'ci',
|
|
347
613
|
file: filepath,
|
|
348
|
-
changes:
|
|
614
|
+
changes: outboundChanges,
|
|
615
|
+
policies: policyFindings,
|
|
349
616
|
});
|
|
350
|
-
results.push({ file: filepath, envelope, policies });
|
|
617
|
+
results.push({ file: filepath, envelope, policies: policyFindings });
|
|
618
|
+
diffed += 1;
|
|
351
619
|
|
|
352
|
-
if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(
|
|
620
|
+
if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
|
|
353
621
|
shouldFail = true;
|
|
354
622
|
}
|
|
355
623
|
}
|
|
356
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
|
+
|
|
357
632
|
printCiOutput(results, format);
|
|
358
633
|
process.exit(shouldFail ? 1 : 0);
|
|
359
634
|
} catch (err) {
|
|
@@ -362,6 +637,53 @@ program
|
|
|
362
637
|
}
|
|
363
638
|
});
|
|
364
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
|
+
|
|
365
687
|
program
|
|
366
688
|
.command('init')
|
|
367
689
|
.description('Create starter .flectorc configuration')
|
|
@@ -389,10 +711,16 @@ program
|
|
|
389
711
|
exclude: config?.exclude ?? [],
|
|
390
712
|
});
|
|
391
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}`);
|
|
392
719
|
if (typeof fetch !== 'function') {
|
|
393
|
-
throw new Error('Global fetch unavailable. Use Node.js >=
|
|
720
|
+
throw new Error('Global fetch unavailable. Use Node.js >= 20.19.0.');
|
|
394
721
|
}
|
|
395
722
|
renderInfo('fetch: available');
|
|
723
|
+
renderInfo(`version: ${PKG.version}`);
|
|
396
724
|
renderInfo('doctor: OK');
|
|
397
725
|
} catch (err) {
|
|
398
726
|
renderError(`doctor failed: ${err.message}`);
|
|
@@ -400,9 +728,8 @@ program
|
|
|
400
728
|
}
|
|
401
729
|
});
|
|
402
730
|
|
|
403
|
-
program.
|
|
731
|
+
await program.parseAsync(process.argv);
|
|
404
732
|
|
|
405
|
-
// Show help if no command given
|
|
406
733
|
if (!process.argv.slice(2).length) {
|
|
407
734
|
program.help();
|
|
408
735
|
}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"access": "public",
|
|
5
5
|
"provenance": true
|
|
6
6
|
},
|
|
7
|
-
"version": "1.0
|
|
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",
|
|
@@ -35,8 +35,10 @@
|
|
|
35
35
|
"files": [
|
|
36
36
|
"index.js",
|
|
37
37
|
"src/**/*",
|
|
38
|
+
"schemas/**/*",
|
|
38
39
|
"README.md",
|
|
39
|
-
"LICENSE"
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"CHANGELOG.md"
|
|
40
42
|
],
|
|
41
43
|
"scripts": {
|
|
42
44
|
"test": "node --test test/*.test.js",
|
|
@@ -46,10 +48,10 @@
|
|
|
46
48
|
"dependencies": {
|
|
47
49
|
"@iarna/toml": "^2.2.5",
|
|
48
50
|
"chalk": "^5.3.0",
|
|
49
|
-
"chokidar": "^
|
|
51
|
+
"chokidar": "^5.0.0",
|
|
50
52
|
"commander": "^12.1.0",
|
|
51
|
-
"dotenv": "^
|
|
53
|
+
"dotenv": "^17.4.2",
|
|
52
54
|
"fast-glob": "^3.3.3",
|
|
53
|
-
"js-yaml": "^4.
|
|
55
|
+
"js-yaml": "^4.3.0"
|
|
54
56
|
}
|
|
55
57
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/myselfsiddharth/Flecto/schemas/flecto-envelope-2.0.json",
|
|
4
|
+
"title": "FlectoEnvelope",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"schema_version",
|
|
9
|
+
"event_id",
|
|
10
|
+
"batch_id",
|
|
11
|
+
"event_type",
|
|
12
|
+
"source",
|
|
13
|
+
"emitted_at",
|
|
14
|
+
"file",
|
|
15
|
+
"changes"
|
|
16
|
+
],
|
|
17
|
+
"properties": {
|
|
18
|
+
"schema_version": { "const": "2.0" },
|
|
19
|
+
"event_id": { "type": "string", "minLength": 1 },
|
|
20
|
+
"batch_id": { "type": "string", "minLength": 1 },
|
|
21
|
+
"event_type": { "enum": ["changes", "lifecycle"] },
|
|
22
|
+
"source": { "enum": ["watch", "ci", "diff"] },
|
|
23
|
+
"emitted_at": { "type": "string", "format": "date-time" },
|
|
24
|
+
"file": { "type": "string" },
|
|
25
|
+
"changes": {
|
|
26
|
+
"type": "array",
|
|
27
|
+
"items": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"required": ["type", "path"],
|
|
30
|
+
"properties": {
|
|
31
|
+
"type": { "enum": ["added", "removed", "changed"] },
|
|
32
|
+
"path": { "type": "string" },
|
|
33
|
+
"before": true,
|
|
34
|
+
"after": true,
|
|
35
|
+
"note": { "type": "string" }
|
|
36
|
+
},
|
|
37
|
+
"additionalProperties": false
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"policies": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"items": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"required": ["id", "severity", "path", "message"],
|
|
45
|
+
"properties": {
|
|
46
|
+
"id": { "type": "string" },
|
|
47
|
+
"severity": { "enum": ["info", "warn", "error"] },
|
|
48
|
+
"path": { "type": "string" },
|
|
49
|
+
"message": { "type": "string" },
|
|
50
|
+
"pack": { "type": "string" }
|
|
51
|
+
},
|
|
52
|
+
"additionalProperties": false
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"lifecycle": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"required": ["type", "message"],
|
|
58
|
+
"properties": {
|
|
59
|
+
"type": { "type": "string" },
|
|
60
|
+
"message": { "type": "string" }
|
|
61
|
+
},
|
|
62
|
+
"additionalProperties": false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|