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/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
- const out = { ...opts };
85
- // Don't let Commander defaults wipe .flectorc values for optional features
86
- for (const key of [
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: effective.arrayIdKey || null,
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
- console.log(`::${level} file=${result.file},title=${title}::${finding.path}: ${finding.message}`);
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 (opt-in)')
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
- .action(async (files, opts) => {
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) || !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
+ }
261
360
  const state = parseFile(filepath);
262
361
  const snapshotPath = snapshotPathForFile(filepath);
263
- 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');
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
- if (String(effective.onAlertFailure) === 'exit') process.exitCode = 1;
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 (opt-in)')
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
- .action(async (files, opts) => {
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) || !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
+ }
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 >= 18.');
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.parse(process.argv);
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.0.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": ">=18"
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": "^3.6.0",
51
+ "chokidar": "^5.0.0",
51
52
  "commander": "^12.1.0",
52
- "dotenv": "^16.4.5",
53
+ "dotenv": "^17.4.2",
53
54
  "fast-glob": "^3.3.3",
54
- "js-yaml": "^4.1.0"
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
- alertQueue = alertQueue
15
- .then(async () => { await fn(); })
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 alertQueue;
20
+ return result;
20
21
  }
21
22
 
22
23
  /**