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/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 { renderChanges, renderDiff, renderError, renderInfo, renderWarn, renderPolicyFindings } from './src/renderer.js';
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 { loadRcConfig, resolveEffectiveOptions, resolveFiles, initRcFile } from './src/config.js';
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
- console.log(`::warning file=${result.file},title=${title}::${event.path}`);
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
- console.log(`::${level} file=${result.file},title=policy::${finding.path} ${finding.message}`);
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
- .action(async (files, opts) => {
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 effective = resolveEffectiveOptions(config, opts.profile, opts);
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) || !isSupported(filepath)) continue;
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
- writeFileSync(snapshotPath, JSON.stringify({ file: filepath, state }, null, 2), 'utf8');
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, { ignorePaths });
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
- const policyFindings = evaluatePolicies(event.events);
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: event.events,
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
- .action(async (files, opts) => {
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 effective = resolveEffectiveOptions(config, opts.profile, opts);
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) || !isSupported(filepath)) continue;
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
- before = {};
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, { ignorePaths });
344
- const policies = evaluatePolicies(events);
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: events,
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(policies, failOn)) {
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 >= 18.');
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.parse(process.argv);
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.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": ">=18"
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": "^3.6.0",
51
+ "chokidar": "^5.0.0",
50
52
  "commander": "^12.1.0",
51
- "dotenv": "^16.4.5",
53
+ "dotenv": "^17.4.2",
52
54
  "fast-glob": "^3.3.3",
53
- "js-yaml": "^4.1.0"
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
+ }