flecto 3.0.1 → 3.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,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { program } from 'commander';
4
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, realpathSync } from 'fs';
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs';
5
5
  import { resolve, relative, dirname, join } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
- import { createHash } from 'crypto';
8
7
  import { execFileSync } from 'child_process';
9
8
  import chalk from 'chalk';
10
9
 
@@ -24,6 +23,7 @@ import {
24
23
  maskSensitiveValue,
25
24
  } from './src/renderer.js';
26
25
  import { deliverPrComment, renderPrComment } from './src/pr-comment.js';
26
+ import { PR_PROVIDER_IDS } from './src/pr-providers.js';
27
27
  import {
28
28
  diffTerraformPlan,
29
29
  formatPlanSummary,
@@ -34,6 +34,25 @@ import { redactSecretString } from './src/secrets.js';
34
34
  import { fireAlerts } from './src/alerter.js';
35
35
  import { resolveWebhookFormat, WEBHOOK_FORMAT_CHOICES } from './src/notifiers.js';
36
36
  import { createEnvelope } from './src/envelope.js';
37
+ import { buildSarif } from './src/sarif.js';
38
+ import {
39
+ maskState,
40
+ resolveSnapshotStore,
41
+ SNAPSHOT_MASK_MODES,
42
+ SNAPSHOT_STORE_IDS,
43
+ } from './src/snapshot-store.js';
44
+ import {
45
+ loadBaseline,
46
+ applyBaseline,
47
+ buildBaselineFile,
48
+ writeBaselineFile,
49
+ baselineRelativePath,
50
+ } from './src/baseline.js';
51
+ import {
52
+ suppressionFormat,
53
+ parseSuppressions,
54
+ applySuppressions,
55
+ } from './src/suppressions.js';
37
56
  import {
38
57
  evaluatePolicies,
39
58
  highestSeverity,
@@ -48,13 +67,14 @@ import {
48
67
  initRcFile,
49
68
  resolveProfileName,
50
69
  resolvePolicyOptions,
70
+ assertTargetContained,
71
+ assertWriteDestinationContained,
51
72
  } from './src/config.js';
52
73
 
53
74
  const PKG = JSON.parse(
54
75
  readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'),
55
76
  );
56
77
 
57
- const SNAPSHOT_DIR = '.flecto-snapshots';
58
78
  const FAIL_ON_CHOICES = ['changed', 'added', 'removed', 'policy', 'error', 'warn'];
59
79
 
60
80
  /**
@@ -67,85 +87,42 @@ const FAIL_ON_CHOICES = ['changed', 'added', 'removed', 'policy', 'error', 'warn
67
87
  const PLAN_DEFAULT_FAIL_ON = 'error';
68
88
  const PLAN_DEFAULT_POLICIES = 'terraform';
69
89
 
70
- function snapshotIdForPath(absPath) {
71
- const normalized = absPath.replaceAll('\\', '/');
72
- return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
73
- }
74
-
75
- function snapshotPathForFile(absPath) {
76
- const id = snapshotIdForPath(absPath);
77
- return resolve(`${SNAPSHOT_DIR}/${id}.json`);
78
- }
79
-
80
- function snapshotHistoryPathForFile(absPath) {
81
- const id = snapshotIdForPath(absPath);
82
- let timestamp = Date.now();
83
- let path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
84
- while (existsSync(path)) {
85
- timestamp += 1;
86
- path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
87
- }
88
- return path;
89
- }
90
-
91
90
  /**
92
- * Snapshot ids that already have at least one timestamped history entry.
91
+ * Put the live side of a diff in the same form the store recorded the baseline
92
+ * in.
93
93
  *
94
- * Listed once per run and threaded through the snapshot loop: probing the
95
- * directory per file made writing N baselines cost N listings of O(N) entries
96
- * each, which is quadratic in the number of tracked files.
97
- * @returns {Set<string>}
94
+ * A masked store holds `flecto:sha256:…` where a secret was. Diffing that
95
+ * against the plaintext on disk would report every secret in the file as changed
96
+ * on every single run — noise that would train a team to ignore the tool, from a
97
+ * store whose whole purpose is to be trusted. Masking both sides compares digest
98
+ * to digest, so a rotated credential still reports as changed and an untouched
99
+ * one reports nothing.
100
+ * @template T
101
+ * @param {T} state
102
+ * @param {import('./src/snapshot-store.js').SnapshotStore} store
103
+ * @returns {T}
98
104
  */
99
- function snapshotIdsWithHistory() {
100
- /** @type {Set<string>} */
101
- const ids = new Set();
102
- if (!existsSync(SNAPSHOT_DIR)) return ids;
103
- for (const name of readdirSync(SNAPSHOT_DIR)) {
104
- const match = /^([a-f0-9]{16})\.\d+\.json$/.exec(name);
105
- if (match) ids.add(match[1]);
106
- }
107
- return ids;
108
- }
109
-
110
- function preserveLegacySnapshotForHistory(absPath, snapshotPath, idsWithHistory) {
111
- if (!existsSync(snapshotPath) || idsWithHistory.has(snapshotIdForPath(absPath))) return;
112
-
113
- const legacy = JSON.parse(readFileSync(snapshotPath, 'utf8'));
114
- writeFileSync(
115
- snapshotHistoryPathForFile(absPath),
116
- JSON.stringify({
117
- file: legacy.file ?? absPath,
118
- state: legacy.state ?? legacy,
119
- ...(Array.isArray(legacy.documents) ? { documents: legacy.documents } : {}),
120
- createdAt: legacy.createdAt ?? statSync(snapshotPath).mtime.toISOString(),
121
- }, null, 2),
122
- 'utf8',
123
- );
105
+ function alignStateWithStore(state, store) {
106
+ return store.maskMode === 'hash' ? /** @type {T} */ (maskState(state)) : state;
124
107
  }
125
108
 
126
- function readLocalSnapshotHistory() {
127
- if (!existsSync(SNAPSHOT_DIR)) return [];
128
-
129
- const entries = readdirSync(SNAPSHOT_DIR, { withFileTypes: true })
130
- .filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
131
- const historyEntries = entries.filter((entry) => /^[a-f0-9]{16}\.\d+\.json$/.test(entry.name));
132
- const historyIds = new Set(historyEntries.map((entry) => entry.name.slice(0, 16)));
133
- const legacyEntries = entries.filter((entry) =>
134
- /^[a-f0-9]{16}\.json$/.test(entry.name) && !historyIds.has(entry.name.slice(0, 16)));
135
- const snapshotEntries = [...historyEntries, ...legacyEntries];
136
-
137
- return snapshotEntries.map((entry) => {
138
- const path = resolve(SNAPSHOT_DIR, entry.name);
139
- const snapshot = JSON.parse(readFileSync(path, 'utf8'));
140
- const state = restoreSnapshotDocumentKeys(snapshot?.state ?? snapshot, snapshot);
141
- if (typeof snapshot?.file !== 'string') {
142
- throw new Error(`Invalid snapshot file: ${path}`);
143
- }
144
- return {
145
- file: snapshot.file,
146
- state,
147
- createdAt: snapshot.createdAt ?? statSync(path).mtime.toISOString(),
148
- };
109
+ /**
110
+ * Resolve the snapshot store a command should read and write (#141).
111
+ *
112
+ * Every snapshot consumer goes through this, so `--snapshot-store shared` in
113
+ * `.flectorc` means the same thing to `watch`, `ci`, `history`, and `report` —
114
+ * a store the editor of the config and the runner gating it disagree about
115
+ * would be worse than having only the local one.
116
+ * @param {Record<string, unknown>} effective
117
+ * @returns {import('./src/snapshot-store.js').SnapshotStore}
118
+ */
119
+ function snapshotStoreFromEffective(effective) {
120
+ return resolveSnapshotStore({
121
+ store: effective.snapshotStore,
122
+ dir: effective.snapshotDir,
123
+ mask: effective.snapshotMask,
124
+ retention: effective.snapshotRetention,
125
+ cwd: process.cwd(),
149
126
  });
150
127
  }
151
128
 
@@ -275,6 +252,18 @@ function maybeMaskFindings(findings, changes, maskSecrets) {
275
252
  });
276
253
  }
277
254
 
255
+ /**
256
+ * Every command's targets pass through here, which makes it the one place the
257
+ * symlink-escape check has to run: a target that leaves the project through a
258
+ * link is refused before anything reads it.
259
+ * @param {string[]} files
260
+ * @returns {string[]} the same list
261
+ */
262
+ function assertTargetsContained(files) {
263
+ for (const file of files) assertTargetContained(file, process.cwd());
264
+ return files;
265
+ }
266
+
278
267
  async function resolveTargetFiles(cliFiles, rcConfig) {
279
268
  if (cliFiles && cliFiles.length > 0) {
280
269
  const direct = [];
@@ -294,15 +283,15 @@ async function resolveTargetFiles(cliFiles, rcConfig) {
294
283
  exclude: rcConfig?.exclude ?? [],
295
284
  });
296
285
  }
297
- return [...new Set([...direct, ...expanded])];
286
+ return assertTargetsContained([...new Set([...direct, ...expanded])]);
298
287
  }
299
288
 
300
- return resolveFiles({
289
+ return assertTargetsContained(await resolveFiles({
301
290
  cwd: process.cwd(),
302
291
  files: rcConfig?.files ?? [],
303
292
  include: rcConfig?.include ?? [],
304
293
  exclude: rcConfig?.exclude ?? [],
305
- });
294
+ }));
306
295
  }
307
296
 
308
297
  /**
@@ -346,10 +335,28 @@ function gitRepoRelativePath(filePath) {
346
335
  /**
347
336
  * Resolve symlinks where possible, falling back to the input when the path does
348
337
  * not exist on disk.
338
+ *
339
+ * `realpathSync.native` is tried first because on Windows it asks the OS for the
340
+ * final path, which resolves 8.3 short names and normalizes case. Those are not
341
+ * cosmetic here: `git rev-parse --show-toplevel` reports the long form, while
342
+ * `os.tmpdir()` and many shells hand Flecto the short one
343
+ * (`C:\Users\RUNNER~1\...`). The JS `realpathSync` leaves both as written, so
344
+ * the two spellings of one directory compare as different and `relative()`
345
+ * produces a path that climbs out of the repository -- making
346
+ * `--snapshot-ref <git-ref>` fail on a file that is plainly tracked.
347
+ *
348
+ * On Linux and macOS the two agree for any path that exists, so this only ever
349
+ * changes the Windows result.
349
350
  * @param {string} path
350
351
  * @returns {string}
351
352
  */
352
353
  function canonicalPath(path) {
354
+ try {
355
+ return realpathSync.native(path);
356
+ } catch {
357
+ // Falls back for a path that does not exist yet, and for the rare platform
358
+ // where the native call is unavailable.
359
+ }
353
360
  try {
354
361
  return realpathSync(path);
355
362
  } catch {
@@ -357,8 +364,19 @@ function canonicalPath(path) {
357
364
  }
358
365
  }
359
366
 
360
- function readSnapshotStateFromRef(filePath, snapshotRef) {
361
- if (!snapshotRef) return readSnapshotStateFromFile(snapshotPathForFile(filePath));
367
+ function readSnapshotStateFromRef(filePath, snapshotRef, store) {
368
+ if (!snapshotRef) {
369
+ // Failing closed here is right — a diff with no baseline is not a clean
370
+ // diff — but an ENOENT on a hashed filename explains nothing. The default
371
+ // store is local to the working directory, so this is what an ephemeral CI
372
+ // runner hits on every run; the message names the store it looked in and
373
+ // the two ways to give it one (#141).
374
+ const record = store.readLatest(filePath);
375
+ if (!record) {
376
+ throw new Error(`no snapshot has been saved for this file (${store.emptyHint})`);
377
+ }
378
+ return record.state;
379
+ }
362
380
  const maybePath = resolve(snapshotRef);
363
381
  if (existsSync(maybePath)) {
364
382
  return readSnapshotStateFromFile(maybePath);
@@ -376,6 +394,40 @@ function shouldFailFromPolicy(findings, failOn) {
376
394
  return false;
377
395
  }
378
396
 
397
+ /**
398
+ * Parse a file's inline suppressions and split its findings into active and
399
+ * suppressed. A directive missing its mandatory reason is a hard error: applying
400
+ * it would hide a finding with no justification, and skipping it silently would
401
+ * fail the build confusingly.
402
+ *
403
+ * A directive that resolves to nothing — an array element, or a file type with
404
+ * no comment syntax — is a warning instead of an error. It already fails closed,
405
+ * because the finding it meant to accept still fires and still gates, so failing
406
+ * the build a second time adds nothing; what was missing was any signal at all
407
+ * that the directive did not take effect.
408
+ * @param {string} filepath
409
+ * @param {import('./src/policy.js').PolicyFinding[]} findings
410
+ * @returns {{ active: any[], suppressed: Array<{ finding: any, reason: string }> }}
411
+ */
412
+ function resolveSuppressed(filepath, findings) {
413
+ const format = suppressionFormat(filepath);
414
+
415
+ let raw;
416
+ try {
417
+ raw = readFileSync(filepath, 'utf8');
418
+ } catch {
419
+ return { active: findings, suppressed: [] };
420
+ }
421
+ const { suppressions, errors, warnings } = parseSuppressions(raw, format);
422
+ const rel = relative(process.cwd(), filepath) || filepath;
423
+ if (errors.length > 0) {
424
+ const detail = errors.map((e) => ` ${rel}:${e.line}: ${e.message}`).join('\n');
425
+ throw new Error(`Inline suppression is missing a required reason:\n${detail}`);
426
+ }
427
+ for (const warning of warnings) renderNote(`${rel}:${warning.line}: ${warning.message}`);
428
+ return applySuppressions(findings, suppressions);
429
+ }
430
+
379
431
  function shouldFailFromChanges(events, failOn) {
380
432
  if (events.length === 0) return false;
381
433
  if (failOn.has('changed') && events.some((e) => e.type === 'changed')) return true;
@@ -397,32 +449,143 @@ function escapeWorkflowCommandProperty(value) {
397
449
  .replaceAll(',', '%2C');
398
450
  }
399
451
 
400
- function printCiOutput(results, format) {
452
+ /**
453
+ * Write to stdout and resolve only once the bytes have actually left.
454
+ *
455
+ * `process.exit()` does not flush a pending stdout write, and Node writes to a
456
+ * pipe asynchronously. So `flecto ci --format json | jq`, or any CI harness
457
+ * capturing stdout, silently lost everything past the 64 KB pipe buffer -- and
458
+ * still saw exit 0. A truncated envelope stream that reports success is the
459
+ * worst shape a machine consumer can be handed: it does not look like a
460
+ * failure, it looks like a clean run over fewer files.
461
+ *
462
+ * Redirecting to a file hid this, because Node writes to a file descriptor
463
+ * synchronously. It only appeared through a pipe, which is how every consumer
464
+ * that matters reads it.
465
+ *
466
+ * The callback form fires when that specific chunk drains, and stream writes
467
+ * are ordered, so awaiting the last one means every earlier one is out too.
468
+ *
469
+ * The trailing newline is appended unconditionally, which is exactly what
470
+ * `console.log` did. Adding it only when one is missing would silently drop a
471
+ * byte from any payload that already ends in a newline -- `--format pr-comment`
472
+ * does -- and the point of this change is that the rendered output is identical.
473
+ * @param {string} text
474
+ * @returns {Promise<void>}
475
+ */
476
+ function writeStdout(text) {
477
+ return new Promise((resolveWrite) => {
478
+ process.stdout.write(`${text}\n`, () => resolveWrite());
479
+ });
480
+ }
481
+
482
+ /**
483
+ * Collapse the envelopes for files that were scanned and had nothing to report
484
+ * into a single manifest entry.
485
+ *
486
+ * `ci` emits one envelope per *scanned* file rather than per *changed* file, so
487
+ * the output grows with the size of the repository instead of the size of the
488
+ * change -- measured on 250 service configs with one file edited, 113.4 KB of
489
+ * output for 0.2 KB of semantic content. For a human that is invisible, because
490
+ * the renderer already prints only what changed; it is the machine consumers
491
+ * (webhooks, NDJSON sinks, and any agent handed the JSON) that pay for it.
492
+ *
493
+ * Dropping those files outright is not safe. An envelope for a scanned but
494
+ * unchanged file is *evidence Flecto looked*, and a consumer diffing two runs
495
+ * can tell "checked and clean" from "not checked at all" -- silently removing
496
+ * that distinction would weaken a gate someone relies on, in the same way a
497
+ * silently skipped plugin would. So the evidence is kept, in the one place it
498
+ * costs almost nothing: a single `lifecycle` envelope carrying the list of
499
+ * paths, instead of a full envelope with its own pair of UUIDs, timestamp, and
500
+ * absolute path for every file.
501
+ *
502
+ * The path list rides on the result wrapper rather than the envelope, which is
503
+ * closed by schemas/flecto-envelope-2.0.json -- the same arrangement `baseline`
504
+ * already uses. Nothing about schema 2.0 changes, and the default output is
505
+ * untouched, so this is opt-in rather than a reshaping of a documented contract.
506
+ *
507
+ * Each envelope keeps its own `batch_id`: that field is documented as grouping
508
+ * the events from one file change, not one run.
509
+ * @param {any[]} results
510
+ * @returns {any[]}
511
+ */
512
+ function collapseUnchangedResults(results) {
513
+ const reported = [];
514
+ /** @type {string[]} */
515
+ const scanned = [];
516
+
517
+ for (const result of results) {
518
+ const hasChanges = (result.envelope.changes?.length ?? 0) > 0;
519
+ const hasFindings = (result.envelope.policies?.length ?? 0) > 0;
520
+ if (hasChanges || hasFindings) reported.push(result);
521
+ else scanned.push(result.file);
522
+ }
523
+
524
+ if (scanned.length === 0) return reported;
525
+ return [
526
+ ...reported,
527
+ {
528
+ // No `file`: this entry is not about one file. Consumers discriminate on
529
+ // `envelope.event_type === "lifecycle"`, which schema 2.0 already carries.
530
+ scanned,
531
+ envelope: createEnvelope({
532
+ source: 'ci',
533
+ file: '',
534
+ lifecycle: {
535
+ type: 'scanned',
536
+ message:
537
+ `${scanned.length} file${scanned.length === 1 ? '' : 's'} scanned `
538
+ + 'with no changes and no policy findings',
539
+ },
540
+ }),
541
+ policies: [],
542
+ },
543
+ ];
544
+ }
545
+
546
+ /**
547
+ * Render the machine-readable CI output and wait for it to flush.
548
+ *
549
+ * The payload is assembled and written once rather than line by line, so a
550
+ * caller has a single write to await -- see {@link writeStdout} for why that
551
+ * matters. The rendered bytes are unchanged.
552
+ * @param {any[]} results
553
+ * @param {string} format
554
+ * @returns {Promise<void>}
555
+ */
556
+ async function printCiOutput(results, format) {
401
557
  if (format === 'json') {
402
- console.log(JSON.stringify(results, null, 2));
558
+ await writeStdout(JSON.stringify(results, null, 2));
403
559
  return;
404
560
  }
405
561
  if (format === 'ndjson') {
406
- for (const result of results) {
407
- console.log(JSON.stringify(result));
408
- }
562
+ if (results.length === 0) return;
563
+ await writeStdout(results.map((result) => JSON.stringify(result)).join('\n'));
564
+ return;
565
+ }
566
+ if (format === 'sarif') {
567
+ const sarif = buildSarif(results, { cwd: process.cwd(), toolVersion: PKG.version });
568
+ await writeStdout(JSON.stringify(sarif, null, 2));
409
569
  return;
410
570
  }
411
571
  if (format === 'github-annotations') {
572
+ const lines = [];
412
573
  for (const result of results) {
413
574
  for (const event of result.envelope.changes) {
414
575
  const title = `flecto ${event.type}`;
415
576
  const detail = event.note ? `${event.path} (${event.note})` : event.path;
416
- console.log(`::warning file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
577
+ lines.push(`::warning file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
417
578
  }
418
579
  for (const finding of result.policies) {
419
580
  const level = finding.severity === 'error' ? 'error' : 'warning';
420
581
  const pack = finding.pack ? ` [${finding.pack}]` : '';
421
582
  const title = `flecto policy ${finding.id}${pack}`;
422
583
  const detail = `${finding.path}: ${finding.message}`;
423
- console.log(`::${level} file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
584
+ lines.push(`::${level} file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
424
585
  }
425
586
  }
587
+ if (lines.length === 0) return;
588
+ await writeStdout(lines.join('\n'));
426
589
  }
427
590
  }
428
591
 
@@ -431,11 +594,12 @@ function printCiOutput(results, format) {
431
594
  * delivery problem warns, and the exit code stays with the diff/policy result.
432
595
  * @param {string} body
433
596
  * @param {boolean} enabled
597
+ * @param {string} [provider] force a delivery adapter instead of detecting one
434
598
  */
435
- async function deliverPrCommentSafely(body, enabled) {
599
+ async function deliverPrCommentSafely(body, enabled, provider) {
436
600
  if (!enabled) return;
437
601
  try {
438
- const result = await deliverPrComment(body, { enabled: true });
602
+ const result = await deliverPrComment(body, { enabled: true, provider });
439
603
  if (result.posted) {
440
604
  renderNote(`PR comment ${result.action}${result.url ? `: ${result.url}` : ''}`);
441
605
  return;
@@ -479,6 +643,10 @@ program
479
643
  .option('--mask-secrets-webhooks', 'Also mask secrets in webhook payloads', false)
480
644
  .option('--snapshot', 'Save current state as baseline instead of watching')
481
645
  .option('--diff', 'Diff current file against saved baseline and exit')
646
+ .option('--snapshot-store <id>', `Snapshot store: ${SNAPSHOT_STORE_IDS.join(' | ')} (shared is repo-relative and meant to be committed)`)
647
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
648
+ .option('--snapshot-mask <mode>', `How the store records secret-like values: ${SNAPSHOT_MASK_MODES.join(' | ')} (default: hash for shared, none for local)`)
649
+ .option('--snapshot-retention <n>', 'Snapshots kept per file, 0 keeps every one (default: 20 for shared, unlimited for local)')
482
650
  .option('--allow-empty', 'Allow --snapshot to succeed when nothing was written', false)
483
651
  .action(async (files, opts, command) => {
484
652
  try {
@@ -502,11 +670,18 @@ program
502
670
  const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
503
671
  const webhookFormat = resolveWebhookFormat(effective.webhookFormat, effective.webhook);
504
672
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
673
+ const snapshotStore = snapshotStoreFromEffective(effective);
505
674
 
506
675
  if (effective.snapshot) {
507
- mkdirSync(SNAPSHOT_DIR, { recursive: true });
508
- const idsWithHistory = snapshotIdsWithHistory();
676
+ mkdirSync(snapshotStore.root, { recursive: true });
677
+ // Snapshots carry config values, so a store directory that is itself a
678
+ // link out of the project would write them somewhere the repository does
679
+ // not control. Same rule as a target, checked after mkdir so an existing
680
+ // link is seen rather than a path that does not exist yet.
681
+ assertTargetContained(snapshotStore.root, process.cwd());
509
682
  let written = 0;
683
+ let pruned = 0;
684
+ const warnings = new Set();
510
685
  for (const filepath of targets) {
511
686
  if (!existsSync(filepath)) {
512
687
  renderWarn(`Skipping missing file: ${filepath}`);
@@ -517,25 +692,32 @@ program
517
692
  continue;
518
693
  }
519
694
  const state = parseFile(filepath);
520
- const snapshotPath = snapshotPathForFile(filepath);
521
- preserveLegacySnapshotForHistory(filepath, snapshotPath, idsWithHistory);
522
695
  // Only a multi-document file records `documents`, so an ordinary
523
696
  // snapshot is byte-for-byte what it was before this field existed.
524
- const documents = documentKeysOf(state) ?? [];
525
- const snapshot = {
526
- file: filepath,
697
+ const result = snapshotStore.write(filepath, {
527
698
  state,
528
- ...(documents.length > 0 ? { documents: [...documents] } : {}),
529
- createdAt: new Date().toISOString(),
530
- };
531
- writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf8');
532
- writeFileSync(snapshotHistoryPathForFile(filepath), JSON.stringify(snapshot, null, 2), 'utf8');
533
- // Keep the set in step with what this run has written, so a repeated
534
- // target behaves exactly as it did when the check hit the disk.
535
- idsWithHistory.add(snapshotIdForPath(filepath));
536
- console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
699
+ documents: documentKeysOf(state) ?? [],
700
+ });
701
+ if (result.warning) warnings.add(result.warning);
702
+ pruned += result.pruned;
703
+ console.log(chalk.green(`✓ Snapshot saved: ${result.path}`));
537
704
  written += 1;
538
705
  }
706
+ for (const warning of warnings) renderWarn(warning);
707
+ if (pruned > 0) {
708
+ renderNote(
709
+ `Pruned ${pruned} snapshot${pruned === 1 ? '' : 's'} beyond the`
710
+ + ` ${snapshotStore.retention}-entry retention of the ${snapshotStore.id} store.`,
711
+ );
712
+ }
713
+ if (written > 0 && snapshotStore.id === 'shared') {
714
+ renderNote(
715
+ `Shared store: commit ${snapshotStore.label} so every runner reads the same history.`
716
+ + (snapshotStore.maskMode === 'none'
717
+ ? ' Masking is off, so these files carry config values verbatim into git history.'
718
+ : ' Secret-like values are stored as digests, not plaintext.'),
719
+ );
720
+ }
539
721
  if (written === 0 && !effective.allowEmpty) {
540
722
  throw new Error(
541
723
  'No snapshots written — all targets were missing or unsupported.' +
@@ -547,18 +729,37 @@ program
547
729
 
548
730
  if (effective.diff) {
549
731
  let hasChanges = false;
732
+ let compared = 0;
733
+ let missing = 0;
550
734
  for (const filepath of targets) {
551
- const snapshotPath = snapshotPathForFile(filepath);
552
- if (!existsSync(snapshotPath)) {
553
- renderWarn(`No snapshot found for "${filepath}"`);
735
+ const record = snapshotStore.readLatest(filepath);
736
+ if (!record) {
737
+ renderWarn(`No snapshot found for "${filepath}" in ${snapshotStore.label}`);
738
+ missing += 1;
554
739
  continue;
555
740
  }
556
- const before = readSnapshotStateFromFile(snapshotPath);
557
- const after = parseFile(filepath);
741
+ const before = record.state;
742
+ const after = alignStateWithStore(parseFile(filepath), snapshotStore);
558
743
  const events = diffTrees(before, after, dOpts);
559
744
  renderDiff(filepath, events, { maskSecrets });
745
+ compared += 1;
560
746
  if (events.length > 0) hasChanges = true;
561
747
  }
748
+ // Exiting 0 having compared nothing is the worst answer available: the
749
+ // caller reads it as "no drift" when the truth is "no baseline to drift
750
+ // from" (#141). Snapshot history lives in the working directory, so this
751
+ // is the normal state of a fresh CI runner.
752
+ if (compared === 0) {
753
+ throw new Error(
754
+ `No snapshot found for any target in ${snapshotStore.label}, so nothing was compared.`
755
+ + ' Run "flecto watch <file> --snapshot" first — no history is not no drift.',
756
+ );
757
+ }
758
+ if (missing > 0) {
759
+ renderNote(
760
+ `${missing} of ${targets.length} targets had no snapshot and were not compared.`,
761
+ );
762
+ }
562
763
  process.exit(hasChanges ? 1 : 0);
563
764
  }
564
765
 
@@ -668,8 +869,10 @@ program
668
869
 
669
870
  program
670
871
  .command('history [files...]')
671
- .description('Summarize drift across local snapshots')
872
+ .description('Summarize drift across saved snapshots')
672
873
  .option('-l, --limit <n>', 'Number of recent snapshots to show', '10')
874
+ .option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
875
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
673
876
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
674
877
  .option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
675
878
  .option('--array-id-key <key>', 'Diff arrays by this object identity key (opt-in)')
@@ -688,7 +891,8 @@ program
688
891
  const ignorePaths = parseCsv(effective.ignore);
689
892
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
690
893
 
691
- const allSnapshots = readLocalSnapshotHistory();
894
+ const snapshotStore = snapshotStoreFromEffective(effective);
895
+ const allSnapshots = snapshotStore.readHistory();
692
896
  let snapshots = allSnapshots;
693
897
  if (files.length > 0) {
694
898
  const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
@@ -699,18 +903,42 @@ program
699
903
  if (summaries.length === 0) {
700
904
  if (files.length > 0 && allSnapshots.length > 0) {
701
905
  throw new Error(
702
- 'No local snapshots matched the given files. Omit files to view all saved snapshot history.',
906
+ `No snapshots in ${snapshotStore.label} matched the given files.`
907
+ + ' Omit files to view all saved snapshot history.',
703
908
  );
704
909
  }
705
- throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
910
+ throw new Error(
911
+ `No snapshots found in ${snapshotStore.label}.`
912
+ + ` Run "flecto watch <file> --snapshot${snapshotStore.id === 'shared' ? ' --snapshot-store shared' : ''}" first`
913
+ + ' — no history is not no drift.',
914
+ );
706
915
  }
707
916
 
708
- console.log(`Local snapshot history (${summaries.length} snapshots)`);
917
+ console.log(
918
+ `Snapshot history from ${snapshotStore.label} (${summaries.length} snapshots, ${snapshotStore.id} store)`,
919
+ );
920
+ let baselines = 0;
709
921
  for (const snapshot of summaries) {
710
922
  const file = relative(process.cwd(), snapshot.file) || snapshot.file;
711
- const changes = `${snapshot.changeCount} change${snapshot.changeCount === 1 ? '' : 's'}`;
923
+ // A snapshot with nothing before it was never compared, so printing
924
+ // "0 changes" for it states a result that was never computed (#141).
925
+ if (!snapshot.previousCreatedAt) baselines += 1;
926
+ const changes = snapshot.previousCreatedAt
927
+ ? `${snapshot.changeCount} change${snapshot.changeCount === 1 ? '' : 's'}`
928
+ : 'baseline (no earlier snapshot to compare against)';
712
929
  console.log(`${snapshot.createdAt} ${file} — ${changes}`);
713
930
  }
931
+ if (baselines === summaries.length) {
932
+ renderNote(
933
+ 'Nothing was compared: every snapshot shown is the first of its file.'
934
+ + ' That is no history, not no drift.',
935
+ );
936
+ } else if (baselines > 0) {
937
+ renderNote(
938
+ `${baselines} of ${summaries.length} snapshots shown are the first of their file`
939
+ + ' and were not compared against anything.',
940
+ );
941
+ }
714
942
  } catch (err) {
715
943
  renderError(err.message);
716
944
  process.exit(1);
@@ -719,8 +947,10 @@ program
719
947
 
720
948
  program
721
949
  .command('report [files...]')
722
- .description('Render local snapshot history as a self-contained HTML report')
950
+ .description('Render saved snapshot history as a self-contained HTML report')
723
951
  .option('-o, --output <path>', 'Write the report to this path', 'flecto-report.html')
952
+ .option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
953
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
724
954
  .option('-l, --limit <n>', 'Number of recent snapshots to include', '10')
725
955
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
726
956
  .option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
@@ -746,10 +976,15 @@ program
746
976
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
747
977
  const maskSecrets = Boolean(effective.maskSecrets);
748
978
  const outputPath = resolve(String(effective.output ?? 'flecto-report.html'));
979
+ assertWriteDestinationContained(outputPath, {
980
+ option: '--output',
981
+ fromCli: cliOverrides.output !== undefined,
982
+ });
749
983
 
750
984
  // Same snapshot source, filtering, and errors as `flecto history` — this
751
985
  // command only changes how that history is rendered.
752
- const allSnapshots = readLocalSnapshotHistory();
986
+ const snapshotStore = snapshotStoreFromEffective(effective);
987
+ const allSnapshots = snapshotStore.readHistory();
753
988
  let snapshots = allSnapshots;
754
989
  if (files.length > 0) {
755
990
  const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
@@ -760,10 +995,15 @@ program
760
995
  if (summaries.length === 0) {
761
996
  if (files.length > 0 && allSnapshots.length > 0) {
762
997
  throw new Error(
763
- 'No local snapshots matched the given files. Omit files to report on all saved snapshot history.',
998
+ `No snapshots in ${snapshotStore.label} matched the given files.`
999
+ + ' Omit files to report on all saved snapshot history.',
764
1000
  );
765
1001
  }
766
- throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
1002
+ throw new Error(
1003
+ `No snapshots found in ${snapshotStore.label}.`
1004
+ + ` Run "flecto watch <file> --snapshot${snapshotStore.id === 'shared' ? ' --snapshot-store shared' : ''}" first`
1005
+ + ' — no history is not no drift.',
1006
+ );
767
1007
  }
768
1008
 
769
1009
  const reportSnapshots = [];
@@ -797,6 +1037,7 @@ program
797
1037
  version: PKG.version,
798
1038
  limit,
799
1039
  maskSecrets,
1040
+ store: snapshotStore.label,
800
1041
  });
801
1042
  mkdirSync(dirname(outputPath), { recursive: true });
802
1043
  writeFileSync(outputPath, html, 'utf8');
@@ -814,9 +1055,14 @@ program
814
1055
  .description('Run semantic diff in CI mode')
815
1056
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
816
1057
  .option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
817
- .option('--format <type>', 'Output format: json | ndjson | github-annotations | pr-comment', 'json')
818
- .option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs GITHUB_TOKEN + PR context)', false)
1058
+ .option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
1059
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
1060
+ .option('--format <type>', 'Output format: json | ndjson | sarif | github-annotations | pr-comment', 'json')
1061
+ .option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs a token + merge request context)', false)
1062
+ .option('--pr-provider <name>', `Force the comment delivery target: ${PR_PROVIDER_IDS.join(' | ')} (default: detect from CI)`)
819
1063
  .option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
1064
+ .option('--baseline <file>', 'Gate only on findings not already recorded in this baseline file')
1065
+ .option('--update-baseline', 'Rewrite the --baseline file from the current findings (explicit, never automatic)', false)
820
1066
  .option('--ignore <keys>', 'Comma-separated key paths to ignore')
821
1067
  .option('--policies <ids>', 'Comma-separated policy pack ids')
822
1068
  .option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
@@ -824,6 +1070,8 @@ program
824
1070
  .option('--no-array-id', 'Diff arrays by index instead of object identity')
825
1071
  .option('--array-ignore-order', 'Treat array order as insignificant', false)
826
1072
  .option('--mask-secrets', 'Mask secret-like values in CI output', false)
1073
+ .option('--show-suppressed', 'List inline-suppressed findings instead of only counting them', false)
1074
+ .option('--changed-only', 'With --format json|ndjson, replace envelopes for unchanged files with one scanned manifest', false)
827
1075
  .option('--allow-empty', 'Allow CI to succeed when no files were diffed', false)
828
1076
  .action(async (files, opts, command) => {
829
1077
  try {
@@ -840,19 +1088,61 @@ program
840
1088
  const ignorePaths = parseCsv(effective.ignore);
841
1089
  const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
842
1090
  const format = String(effective.format ?? 'json');
843
- if (!['json', 'ndjson', 'github-annotations', 'pr-comment'].includes(format)) {
844
- throw new Error('--format must be json, ndjson, github-annotations, or pr-comment');
1091
+ if (!['json', 'ndjson', 'sarif', 'github-annotations', 'pr-comment'].includes(format)) {
1092
+ throw new Error('--format must be json, ndjson, sarif, github-annotations, or pr-comment');
845
1093
  }
846
1094
  const prCommentPost = Boolean(effective.prCommentPost);
1095
+ if (effective.prProvider && !PR_PROVIDER_IDS.includes(String(effective.prProvider))) {
1096
+ throw new Error(`--pr-provider must be one of: ${PR_PROVIDER_IDS.join(', ')}`);
1097
+ }
847
1098
  if (prCommentPost && format !== 'pr-comment') {
848
1099
  renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
849
1100
  }
850
1101
  const maskSecrets = Boolean(effective.maskSecrets);
1102
+ const showSuppressed = Boolean(effective.showSuppressed);
1103
+ const changedOnly = Boolean(effective.changedOnly);
1104
+ // github-annotations and pr-comment already render only what changed, so
1105
+ // there is nothing for the flag to collapse there. Say so rather than
1106
+ // accepting it and quietly doing nothing.
1107
+ if (changedOnly && format !== 'json' && format !== 'ndjson') {
1108
+ renderWarn(`Ignoring --changed-only: it only applies to --format json or ndjson (got ${format}).`);
1109
+ }
851
1110
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
852
1111
 
853
- /** @type {any[]} */
854
- const results = [];
855
- let shouldFail = false;
1112
+ const snapshotStore = snapshotStoreFromEffective(effective);
1113
+
1114
+ const cwd = process.cwd();
1115
+ const baselinePath = effective.baseline ? resolve(cwd, String(effective.baseline)) : null;
1116
+ if (baselinePath) {
1117
+ assertWriteDestinationContained(baselinePath, {
1118
+ option: '--baseline',
1119
+ fromCli: cliOverrides.baseline !== undefined,
1120
+ cwd,
1121
+ });
1122
+ }
1123
+ // `--update-baseline` accepts every finding this run produced, so honoring
1124
+ // it from `.flectorc` would let a pull request turn its own failing gate
1125
+ // green — including one whose `--fail-on` was set explicitly on the command
1126
+ // line. It is an action, not a setting: the CLI is the only place it can
1127
+ // come from, and a declaration in the rc file is refused rather than
1128
+ // ignored, so a repository that meant it finds out.
1129
+ if (effective.updateBaseline && cliOverrides.updateBaseline === undefined) {
1130
+ throw new Error(
1131
+ 'updateBaseline is declared in .flectorc, and it is refused there: it accepts every'
1132
+ + ' current finding, which would turn a failing gate green from a file a pull request'
1133
+ + ' can edit. Pass --update-baseline on the command line when you mean to record a'
1134
+ + ' baseline.',
1135
+ );
1136
+ }
1137
+ const updateBaseline = Boolean(effective.updateBaseline);
1138
+ if (updateBaseline && !baselinePath) {
1139
+ throw new Error('--update-baseline requires --baseline <file> naming the file to write.');
1140
+ }
1141
+
1142
+ /** @type {Array<{ filepath: string, relFile: string, outboundChanges: any[], outboundFindings: any[], changesFail: boolean }>} */
1143
+ const perFile = [];
1144
+ /** @type {Array<{ file: string, finding: any, reason: string }>} */
1145
+ const allSuppressed = [];
856
1146
  let diffed = 0;
857
1147
 
858
1148
  for (const filepath of targets) {
@@ -864,10 +1154,14 @@ program
864
1154
  renderWarn(`Skipping unsupported file: ${filepath}`);
865
1155
  continue;
866
1156
  }
867
- const after = parseFile(filepath);
1157
+ // A `--snapshot-ref` baseline is read straight from git, so it is never
1158
+ // masked; only a store-provided baseline needs the live side aligned.
1159
+ const after = effective.snapshotRef
1160
+ ? parseFile(filepath)
1161
+ : alignStateWithStore(parseFile(filepath), snapshotStore);
868
1162
  let before;
869
1163
  try {
870
- before = readSnapshotStateFromRef(filepath, effective.snapshotRef);
1164
+ before = readSnapshotStateFromRef(filepath, effective.snapshotRef, snapshotStore);
871
1165
  } catch (err) {
872
1166
  throw new Error(
873
1167
  `Failed to resolve snapshot baseline for "${filepath}"` +
@@ -875,8 +1169,8 @@ program
875
1169
  );
876
1170
  }
877
1171
  const events = diffTrees(before, after, dOpts);
878
- const policyFindings = await evaluatePolicies(events, {
879
- cwd: process.cwd(),
1172
+ const rawFindings = await evaluatePolicies(events, {
1173
+ cwd,
880
1174
  file: filepath,
881
1175
  profile: profile ?? null,
882
1176
  source: 'ci',
@@ -884,20 +1178,24 @@ program
884
1178
  plugins,
885
1179
  severityRemap,
886
1180
  });
887
- const outboundChanges = maybeMaskChanges(events, maskSecrets);
888
- const outboundFindings = maybeMaskFindings(policyFindings, events, maskSecrets);
889
- const envelope = createEnvelope({
890
- source: 'ci',
891
- file: filepath,
892
- changes: outboundChanges,
893
- policies: outboundFindings,
894
- });
895
- results.push({ file: filepath, envelope, policies: outboundFindings });
896
- diffed += 1;
897
1181
 
898
- if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
899
- shouldFail = true;
1182
+ // Inline suppressions run first: a deliberately-accepted finding is
1183
+ // removed before the baseline, the gate, and the output ever see it, so
1184
+ // it is never also counted by a baseline. A directive missing its
1185
+ // mandatory reason is refused loudly rather than applied.
1186
+ const { active: policyFindings, suppressed } = resolveSuppressed(filepath, rawFindings);
1187
+ for (const item of suppressed) {
1188
+ allSuppressed.push({ file: filepath, finding: item.finding, reason: item.reason });
900
1189
  }
1190
+
1191
+ perFile.push({
1192
+ filepath,
1193
+ relFile: baselineRelativePath(filepath, cwd),
1194
+ outboundChanges: maybeMaskChanges(events, maskSecrets),
1195
+ outboundFindings: maybeMaskFindings(policyFindings, events, maskSecrets),
1196
+ changesFail: shouldFailFromChanges(events, failOn),
1197
+ });
1198
+ diffed += 1;
901
1199
  }
902
1200
 
903
1201
  if (diffed === 0 && !effective.allowEmpty) {
@@ -907,12 +1205,100 @@ program
907
1205
  );
908
1206
  }
909
1207
 
1208
+ // Suppressed findings are still surfaced — a count by default, the full
1209
+ // list with --show-suppressed — so a gate you cannot see the shape of does
1210
+ // not quietly grow. All of this goes to stderr, leaving machine output clean.
1211
+ if (allSuppressed.length > 0) {
1212
+ renderNote(
1213
+ `${allSuppressed.length} finding${allSuppressed.length === 1 ? '' : 's'} suppressed inline`
1214
+ + `${showSuppressed ? ':' : ' (use --show-suppressed to list them).'}`,
1215
+ );
1216
+ if (showSuppressed) {
1217
+ for (const { file, finding, reason } of allSuppressed) {
1218
+ const rel = relative(cwd, file) || file;
1219
+ renderNote(` ${rel} ${finding.path}: ${finding.id} — ${reason}`);
1220
+ }
1221
+ }
1222
+ }
1223
+
1224
+ // Every finding this run produced, paired with its repo-relative file, so
1225
+ // the baseline can be matched, updated, and stale-checked on stable keys.
1226
+ const located = perFile.flatMap((f) =>
1227
+ f.outboundFindings.map((finding) => ({ file: f.relFile, finding })));
1228
+
1229
+ // Without a baseline, every finding is active — behavior is unchanged.
1230
+ let activeByFile = new Map(perFile.map((f) => [f.relFile, f.outboundFindings]));
1231
+ let baselineSummary = null;
1232
+ if (baselinePath) {
1233
+ const { entries: recorded } = loadBaseline(baselinePath);
1234
+
1235
+ if (updateBaseline) {
1236
+ // Recording the current state accepts all of it: the file is rewritten
1237
+ // from every finding, and nothing is "new" relative to what was just
1238
+ // written, so the gate passes on the policy axis.
1239
+ writeBaselineFile(baselinePath, buildBaselineFile(located, recorded));
1240
+ renderNote(
1241
+ `Baseline updated: ${baselineRelativePath(baselinePath, cwd)} `
1242
+ + `(${located.length} finding${located.length === 1 ? '' : 's'} recorded)`,
1243
+ );
1244
+ activeByFile = new Map();
1245
+ baselineSummary = { active: 0, accepted: located.length, stale: [] };
1246
+ } else {
1247
+ const { active, accepted, stale } = applyBaseline(located, recorded);
1248
+ const activeMap = new Map();
1249
+ for (const { file, finding } of active) {
1250
+ if (!activeMap.has(file)) activeMap.set(file, []);
1251
+ activeMap.get(file).push(finding);
1252
+ }
1253
+ activeByFile = activeMap;
1254
+ baselineSummary = { active: active.length, accepted: accepted.length, stale };
1255
+ }
1256
+ }
1257
+
1258
+ // Results reflect the *active* findings: with a baseline in effect, an
1259
+ // accepted finding is suppressed from output as well as from the gate, so a
1260
+ // green run is not buried under hundreds of already-accepted findings.
1261
+ const results = perFile.map((f) => {
1262
+ const active = activeByFile.get(f.relFile) ?? [];
1263
+ return {
1264
+ file: f.filepath,
1265
+ envelope: createEnvelope({
1266
+ source: 'ci',
1267
+ file: f.filepath,
1268
+ changes: f.outboundChanges,
1269
+ policies: active,
1270
+ }),
1271
+ policies: active,
1272
+ };
1273
+ });
1274
+
1275
+ // With --update-baseline everything is now accepted, so there are no active
1276
+ // findings to gate on; the policy gate simply passes. Change-based triggers
1277
+ // are about the diff, not the findings, so they still apply.
1278
+ const activeFindings = results.flatMap((r) => r.policies);
1279
+ const shouldFail = perFile.some((f) => f.changesFail)
1280
+ || shouldFailFromPolicy(activeFindings, failOn);
1281
+
1282
+ if (baselineSummary) {
1283
+ const parts = [`${baselineSummary.active} new`, `${baselineSummary.accepted} baselined`];
1284
+ if (baselineSummary.stale.length > 0) parts.push(`${baselineSummary.stale.length} stale`);
1285
+ renderNote(`Baseline: ${parts.join(', ')}.`);
1286
+ if (baselineSummary.stale.length > 0 && !updateBaseline) {
1287
+ renderWarn(
1288
+ `${baselineSummary.stale.length} baseline `
1289
+ + `${baselineSummary.stale.length === 1 ? 'entry no longer occurs' : 'entries no longer occur'}; `
1290
+ + 're-run with --update-baseline to prune.',
1291
+ );
1292
+ }
1293
+ }
1294
+
910
1295
  if (format === 'pr-comment') {
911
- const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
912
- console.log(body);
913
- await deliverPrCommentSafely(body, prCommentPost);
1296
+ const body = renderPrComment(results, { cwd, failed: shouldFail });
1297
+ await writeStdout(body);
1298
+ await deliverPrCommentSafely(body, prCommentPost, effective.prProvider);
914
1299
  } else {
915
- printCiOutput(results, format);
1300
+ const collapsible = changedOnly && (format === 'json' || format === 'ndjson');
1301
+ await printCiOutput(collapsible ? collapseUnchangedResults(results) : results, format);
916
1302
  }
917
1303
  process.exit(shouldFail ? 1 : 0);
918
1304
  } catch (err) {
@@ -926,7 +1312,8 @@ program
926
1312
  .description('Diff Terraform plan JSON (terraform show -json) and run policies on it')
927
1313
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
928
1314
  .option('--format <type>', 'Output format: human | json | ndjson | github-annotations | pr-comment', 'human')
929
- .option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs GITHUB_TOKEN + PR context)', false)
1315
+ .option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs a token + merge request context)', false)
1316
+ .option('--pr-provider <name>', `Force the comment delivery target: ${PR_PROVIDER_IDS.join(' | ')} (default: detect from CI)`)
930
1317
  .option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', PLAN_DEFAULT_FAIL_ON)
931
1318
  .option('--ignore <keys>', 'Comma-separated key paths to ignore, e.g. "**.tags_all,**.#action"')
932
1319
  .option('--policies <ids>', `Comma-separated policy pack ids (default: ${PLAN_DEFAULT_POLICIES})`)
@@ -955,6 +1342,9 @@ program
955
1342
  throw new Error('--format must be human, json, ndjson, github-annotations, or pr-comment');
956
1343
  }
957
1344
  const prCommentPost = Boolean(effective.prCommentPost);
1345
+ if (effective.prProvider && !PR_PROVIDER_IDS.includes(String(effective.prProvider))) {
1346
+ throw new Error(`--pr-provider must be one of: ${PR_PROVIDER_IDS.join(', ')}`);
1347
+ }
958
1348
  if (prCommentPost && format !== 'pr-comment') {
959
1349
  renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
960
1350
  }
@@ -1013,10 +1403,10 @@ program
1013
1403
 
1014
1404
  if (format === 'pr-comment') {
1015
1405
  const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
1016
- console.log(body);
1017
- await deliverPrCommentSafely(body, prCommentPost);
1406
+ await writeStdout(body);
1407
+ await deliverPrCommentSafely(body, prCommentPost, effective.prProvider);
1018
1408
  } else if (format !== 'human') {
1019
- printCiOutput(results, format);
1409
+ await printCiOutput(results, format);
1020
1410
  }
1021
1411
  process.exit(shouldFail ? 1 : 0);
1022
1412
  } catch (err) {
@@ -1099,7 +1489,7 @@ program
1099
1489
  // Same envelope and printer as `ci`, so machine consumers see one shape.
1100
1490
  // `baseline` rides on the result wrapper rather than the envelope, which
1101
1491
  // is closed by schemas/flecto-envelope-2.0.json.
1102
- printCiOutput(
1492
+ await printCiOutput(
1103
1493
  [{ file: targetPath, baseline: baselinePath, envelope, policies: outboundFindings }],
1104
1494
  format,
1105
1495
  );