flecto 3.0.1 → 3.0.2

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
@@ -24,6 +24,7 @@ import {
24
24
  maskSensitiveValue,
25
25
  } from './src/renderer.js';
26
26
  import { deliverPrComment, renderPrComment } from './src/pr-comment.js';
27
+ import { PR_PROVIDER_IDS } from './src/pr-providers.js';
27
28
  import {
28
29
  diffTerraformPlan,
29
30
  formatPlanSummary,
@@ -34,6 +35,19 @@ import { redactSecretString } from './src/secrets.js';
34
35
  import { fireAlerts } from './src/alerter.js';
35
36
  import { resolveWebhookFormat, WEBHOOK_FORMAT_CHOICES } from './src/notifiers.js';
36
37
  import { createEnvelope } from './src/envelope.js';
38
+ import { buildSarif } from './src/sarif.js';
39
+ import {
40
+ loadBaseline,
41
+ applyBaseline,
42
+ buildBaselineFile,
43
+ writeBaselineFile,
44
+ baselineRelativePath,
45
+ } from './src/baseline.js';
46
+ import {
47
+ suppressionFormat,
48
+ parseSuppressions,
49
+ applySuppressions,
50
+ } from './src/suppressions.js';
37
51
  import {
38
52
  evaluatePolicies,
39
53
  highestSeverity,
@@ -48,6 +62,7 @@ import {
48
62
  initRcFile,
49
63
  resolveProfileName,
50
64
  resolvePolicyOptions,
65
+ assertTargetContained,
51
66
  } from './src/config.js';
52
67
 
53
68
  const PKG = JSON.parse(
@@ -275,6 +290,18 @@ function maybeMaskFindings(findings, changes, maskSecrets) {
275
290
  });
276
291
  }
277
292
 
293
+ /**
294
+ * Every command's targets pass through here, which makes it the one place the
295
+ * symlink-escape check has to run: a target that leaves the project through a
296
+ * link is refused before anything reads it.
297
+ * @param {string[]} files
298
+ * @returns {string[]} the same list
299
+ */
300
+ function assertTargetsContained(files) {
301
+ for (const file of files) assertTargetContained(file, process.cwd());
302
+ return files;
303
+ }
304
+
278
305
  async function resolveTargetFiles(cliFiles, rcConfig) {
279
306
  if (cliFiles && cliFiles.length > 0) {
280
307
  const direct = [];
@@ -294,15 +321,15 @@ async function resolveTargetFiles(cliFiles, rcConfig) {
294
321
  exclude: rcConfig?.exclude ?? [],
295
322
  });
296
323
  }
297
- return [...new Set([...direct, ...expanded])];
324
+ return assertTargetsContained([...new Set([...direct, ...expanded])]);
298
325
  }
299
326
 
300
- return resolveFiles({
327
+ return assertTargetsContained(await resolveFiles({
301
328
  cwd: process.cwd(),
302
329
  files: rcConfig?.files ?? [],
303
330
  include: rcConfig?.include ?? [],
304
331
  exclude: rcConfig?.exclude ?? [],
305
- });
332
+ }));
306
333
  }
307
334
 
308
335
  /**
@@ -346,10 +373,28 @@ function gitRepoRelativePath(filePath) {
346
373
  /**
347
374
  * Resolve symlinks where possible, falling back to the input when the path does
348
375
  * not exist on disk.
376
+ *
377
+ * `realpathSync.native` is tried first because on Windows it asks the OS for the
378
+ * final path, which resolves 8.3 short names and normalizes case. Those are not
379
+ * cosmetic here: `git rev-parse --show-toplevel` reports the long form, while
380
+ * `os.tmpdir()` and many shells hand Flecto the short one
381
+ * (`C:\Users\RUNNER~1\...`). The JS `realpathSync` leaves both as written, so
382
+ * the two spellings of one directory compare as different and `relative()`
383
+ * produces a path that climbs out of the repository -- making
384
+ * `--snapshot-ref <git-ref>` fail on a file that is plainly tracked.
385
+ *
386
+ * On Linux and macOS the two agree for any path that exists, so this only ever
387
+ * changes the Windows result.
349
388
  * @param {string} path
350
389
  * @returns {string}
351
390
  */
352
391
  function canonicalPath(path) {
392
+ try {
393
+ return realpathSync.native(path);
394
+ } catch {
395
+ // Falls back for a path that does not exist yet, and for the rare platform
396
+ // where the native call is unavailable.
397
+ }
353
398
  try {
354
399
  return realpathSync(path);
355
400
  } catch {
@@ -358,7 +403,21 @@ function canonicalPath(path) {
358
403
  }
359
404
 
360
405
  function readSnapshotStateFromRef(filePath, snapshotRef) {
361
- if (!snapshotRef) return readSnapshotStateFromFile(snapshotPathForFile(filePath));
406
+ if (!snapshotRef) {
407
+ const snapshotPath = snapshotPathForFile(filePath);
408
+ // Failing closed here is right — a diff with no baseline is not a clean
409
+ // diff — but an ENOENT on a hashed filename explains nothing. Snapshot
410
+ // history is local to the working directory, so this is what an ephemeral
411
+ // CI runner hits on every run (#141).
412
+ if (!existsSync(snapshotPath)) {
413
+ throw new Error(
414
+ `no local snapshot has been saved for this file (${SNAPSHOT_DIR}/ holds none).`
415
+ + ' Save one with "flecto watch <file> --snapshot", or pass --snapshot-ref'
416
+ + ' <git-ref> to diff against a committed revision instead',
417
+ );
418
+ }
419
+ return readSnapshotStateFromFile(snapshotPath);
420
+ }
362
421
  const maybePath = resolve(snapshotRef);
363
422
  if (existsSync(maybePath)) {
364
423
  return readSnapshotStateFromFile(maybePath);
@@ -376,6 +435,40 @@ function shouldFailFromPolicy(findings, failOn) {
376
435
  return false;
377
436
  }
378
437
 
438
+ /**
439
+ * Parse a file's inline suppressions and split its findings into active and
440
+ * suppressed. A directive missing its mandatory reason is a hard error: applying
441
+ * it would hide a finding with no justification, and skipping it silently would
442
+ * fail the build confusingly.
443
+ *
444
+ * A directive that resolves to nothing — an array element, or a file type with
445
+ * no comment syntax — is a warning instead of an error. It already fails closed,
446
+ * because the finding it meant to accept still fires and still gates, so failing
447
+ * the build a second time adds nothing; what was missing was any signal at all
448
+ * that the directive did not take effect.
449
+ * @param {string} filepath
450
+ * @param {import('./src/policy.js').PolicyFinding[]} findings
451
+ * @returns {{ active: any[], suppressed: Array<{ finding: any, reason: string }> }}
452
+ */
453
+ function resolveSuppressed(filepath, findings) {
454
+ const format = suppressionFormat(filepath);
455
+
456
+ let raw;
457
+ try {
458
+ raw = readFileSync(filepath, 'utf8');
459
+ } catch {
460
+ return { active: findings, suppressed: [] };
461
+ }
462
+ const { suppressions, errors, warnings } = parseSuppressions(raw, format);
463
+ const rel = relative(process.cwd(), filepath) || filepath;
464
+ if (errors.length > 0) {
465
+ const detail = errors.map((e) => ` ${rel}:${e.line}: ${e.message}`).join('\n');
466
+ throw new Error(`Inline suppression is missing a required reason:\n${detail}`);
467
+ }
468
+ for (const warning of warnings) renderNote(`${rel}:${warning.line}: ${warning.message}`);
469
+ return applySuppressions(findings, suppressions);
470
+ }
471
+
379
472
  function shouldFailFromChanges(events, failOn) {
380
473
  if (events.length === 0) return false;
381
474
  if (failOn.has('changed') && events.some((e) => e.type === 'changed')) return true;
@@ -397,32 +490,143 @@ function escapeWorkflowCommandProperty(value) {
397
490
  .replaceAll(',', '%2C');
398
491
  }
399
492
 
400
- function printCiOutput(results, format) {
493
+ /**
494
+ * Write to stdout and resolve only once the bytes have actually left.
495
+ *
496
+ * `process.exit()` does not flush a pending stdout write, and Node writes to a
497
+ * pipe asynchronously. So `flecto ci --format json | jq`, or any CI harness
498
+ * capturing stdout, silently lost everything past the 64 KB pipe buffer -- and
499
+ * still saw exit 0. A truncated envelope stream that reports success is the
500
+ * worst shape a machine consumer can be handed: it does not look like a
501
+ * failure, it looks like a clean run over fewer files.
502
+ *
503
+ * Redirecting to a file hid this, because Node writes to a file descriptor
504
+ * synchronously. It only appeared through a pipe, which is how every consumer
505
+ * that matters reads it.
506
+ *
507
+ * The callback form fires when that specific chunk drains, and stream writes
508
+ * are ordered, so awaiting the last one means every earlier one is out too.
509
+ *
510
+ * The trailing newline is appended unconditionally, which is exactly what
511
+ * `console.log` did. Adding it only when one is missing would silently drop a
512
+ * byte from any payload that already ends in a newline -- `--format pr-comment`
513
+ * does -- and the point of this change is that the rendered output is identical.
514
+ * @param {string} text
515
+ * @returns {Promise<void>}
516
+ */
517
+ function writeStdout(text) {
518
+ return new Promise((resolveWrite) => {
519
+ process.stdout.write(`${text}\n`, () => resolveWrite());
520
+ });
521
+ }
522
+
523
+ /**
524
+ * Collapse the envelopes for files that were scanned and had nothing to report
525
+ * into a single manifest entry.
526
+ *
527
+ * `ci` emits one envelope per *scanned* file rather than per *changed* file, so
528
+ * the output grows with the size of the repository instead of the size of the
529
+ * change -- measured on 250 service configs with one file edited, 113.4 KB of
530
+ * output for 0.2 KB of semantic content. For a human that is invisible, because
531
+ * the renderer already prints only what changed; it is the machine consumers
532
+ * (webhooks, NDJSON sinks, and any agent handed the JSON) that pay for it.
533
+ *
534
+ * Dropping those files outright is not safe. An envelope for a scanned but
535
+ * unchanged file is *evidence Flecto looked*, and a consumer diffing two runs
536
+ * can tell "checked and clean" from "not checked at all" -- silently removing
537
+ * that distinction would weaken a gate someone relies on, in the same way a
538
+ * silently skipped plugin would. So the evidence is kept, in the one place it
539
+ * costs almost nothing: a single `lifecycle` envelope carrying the list of
540
+ * paths, instead of a full envelope with its own pair of UUIDs, timestamp, and
541
+ * absolute path for every file.
542
+ *
543
+ * The path list rides on the result wrapper rather than the envelope, which is
544
+ * closed by schemas/flecto-envelope-2.0.json -- the same arrangement `baseline`
545
+ * already uses. Nothing about schema 2.0 changes, and the default output is
546
+ * untouched, so this is opt-in rather than a reshaping of a documented contract.
547
+ *
548
+ * Each envelope keeps its own `batch_id`: that field is documented as grouping
549
+ * the events from one file change, not one run.
550
+ * @param {any[]} results
551
+ * @returns {any[]}
552
+ */
553
+ function collapseUnchangedResults(results) {
554
+ const reported = [];
555
+ /** @type {string[]} */
556
+ const scanned = [];
557
+
558
+ for (const result of results) {
559
+ const hasChanges = (result.envelope.changes?.length ?? 0) > 0;
560
+ const hasFindings = (result.envelope.policies?.length ?? 0) > 0;
561
+ if (hasChanges || hasFindings) reported.push(result);
562
+ else scanned.push(result.file);
563
+ }
564
+
565
+ if (scanned.length === 0) return reported;
566
+ return [
567
+ ...reported,
568
+ {
569
+ // No `file`: this entry is not about one file. Consumers discriminate on
570
+ // `envelope.event_type === "lifecycle"`, which schema 2.0 already carries.
571
+ scanned,
572
+ envelope: createEnvelope({
573
+ source: 'ci',
574
+ file: '',
575
+ lifecycle: {
576
+ type: 'scanned',
577
+ message:
578
+ `${scanned.length} file${scanned.length === 1 ? '' : 's'} scanned `
579
+ + 'with no changes and no policy findings',
580
+ },
581
+ }),
582
+ policies: [],
583
+ },
584
+ ];
585
+ }
586
+
587
+ /**
588
+ * Render the machine-readable CI output and wait for it to flush.
589
+ *
590
+ * The payload is assembled and written once rather than line by line, so a
591
+ * caller has a single write to await -- see {@link writeStdout} for why that
592
+ * matters. The rendered bytes are unchanged.
593
+ * @param {any[]} results
594
+ * @param {string} format
595
+ * @returns {Promise<void>}
596
+ */
597
+ async function printCiOutput(results, format) {
401
598
  if (format === 'json') {
402
- console.log(JSON.stringify(results, null, 2));
599
+ await writeStdout(JSON.stringify(results, null, 2));
403
600
  return;
404
601
  }
405
602
  if (format === 'ndjson') {
406
- for (const result of results) {
407
- console.log(JSON.stringify(result));
408
- }
603
+ if (results.length === 0) return;
604
+ await writeStdout(results.map((result) => JSON.stringify(result)).join('\n'));
605
+ return;
606
+ }
607
+ if (format === 'sarif') {
608
+ const sarif = buildSarif(results, { cwd: process.cwd(), toolVersion: PKG.version });
609
+ await writeStdout(JSON.stringify(sarif, null, 2));
409
610
  return;
410
611
  }
411
612
  if (format === 'github-annotations') {
613
+ const lines = [];
412
614
  for (const result of results) {
413
615
  for (const event of result.envelope.changes) {
414
616
  const title = `flecto ${event.type}`;
415
617
  const detail = event.note ? `${event.path} (${event.note})` : event.path;
416
- console.log(`::warning file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
618
+ lines.push(`::warning file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
417
619
  }
418
620
  for (const finding of result.policies) {
419
621
  const level = finding.severity === 'error' ? 'error' : 'warning';
420
622
  const pack = finding.pack ? ` [${finding.pack}]` : '';
421
623
  const title = `flecto policy ${finding.id}${pack}`;
422
624
  const detail = `${finding.path}: ${finding.message}`;
423
- console.log(`::${level} file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
625
+ lines.push(`::${level} file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
424
626
  }
425
627
  }
628
+ if (lines.length === 0) return;
629
+ await writeStdout(lines.join('\n'));
426
630
  }
427
631
  }
428
632
 
@@ -431,11 +635,12 @@ function printCiOutput(results, format) {
431
635
  * delivery problem warns, and the exit code stays with the diff/policy result.
432
636
  * @param {string} body
433
637
  * @param {boolean} enabled
638
+ * @param {string} [provider] force a delivery adapter instead of detecting one
434
639
  */
435
- async function deliverPrCommentSafely(body, enabled) {
640
+ async function deliverPrCommentSafely(body, enabled, provider) {
436
641
  if (!enabled) return;
437
642
  try {
438
- const result = await deliverPrComment(body, { enabled: true });
643
+ const result = await deliverPrComment(body, { enabled: true, provider });
439
644
  if (result.posted) {
440
645
  renderNote(`PR comment ${result.action}${result.url ? `: ${result.url}` : ''}`);
441
646
  return;
@@ -505,6 +710,11 @@ program
505
710
 
506
711
  if (effective.snapshot) {
507
712
  mkdirSync(SNAPSHOT_DIR, { recursive: true });
713
+ // Snapshots carry config values, so a .flecto-snapshots/ that is itself a
714
+ // link out of the project would write them somewhere the repository does
715
+ // not control. Same rule as a target, checked after mkdir so an existing
716
+ // link is seen rather than a path that does not exist yet.
717
+ assertTargetContained(resolve(SNAPSHOT_DIR), process.cwd());
508
718
  const idsWithHistory = snapshotIdsWithHistory();
509
719
  let written = 0;
510
720
  for (const filepath of targets) {
@@ -547,18 +757,37 @@ program
547
757
 
548
758
  if (effective.diff) {
549
759
  let hasChanges = false;
760
+ let compared = 0;
761
+ let missing = 0;
550
762
  for (const filepath of targets) {
551
763
  const snapshotPath = snapshotPathForFile(filepath);
552
764
  if (!existsSync(snapshotPath)) {
553
765
  renderWarn(`No snapshot found for "${filepath}"`);
766
+ missing += 1;
554
767
  continue;
555
768
  }
556
769
  const before = readSnapshotStateFromFile(snapshotPath);
557
770
  const after = parseFile(filepath);
558
771
  const events = diffTrees(before, after, dOpts);
559
772
  renderDiff(filepath, events, { maskSecrets });
773
+ compared += 1;
560
774
  if (events.length > 0) hasChanges = true;
561
775
  }
776
+ // Exiting 0 having compared nothing is the worst answer available: the
777
+ // caller reads it as "no drift" when the truth is "no baseline to drift
778
+ // from" (#141). Snapshot history lives in the working directory, so this
779
+ // is the normal state of a fresh CI runner.
780
+ if (compared === 0) {
781
+ throw new Error(
782
+ 'No snapshot found for any target, so nothing was compared.'
783
+ + ' Run "flecto watch <file> --snapshot" first — no history is not no drift.',
784
+ );
785
+ }
786
+ if (missing > 0) {
787
+ renderNote(
788
+ `${missing} of ${targets.length} targets had no snapshot and were not compared.`,
789
+ );
790
+ }
562
791
  process.exit(hasChanges ? 1 : 0);
563
792
  }
564
793
 
@@ -706,11 +935,28 @@ program
706
935
  }
707
936
 
708
937
  console.log(`Local snapshot history (${summaries.length} snapshots)`);
938
+ let baselines = 0;
709
939
  for (const snapshot of summaries) {
710
940
  const file = relative(process.cwd(), snapshot.file) || snapshot.file;
711
- const changes = `${snapshot.changeCount} change${snapshot.changeCount === 1 ? '' : 's'}`;
941
+ // A snapshot with nothing before it was never compared, so printing
942
+ // "0 changes" for it states a result that was never computed (#141).
943
+ if (!snapshot.previousCreatedAt) baselines += 1;
944
+ const changes = snapshot.previousCreatedAt
945
+ ? `${snapshot.changeCount} change${snapshot.changeCount === 1 ? '' : 's'}`
946
+ : 'baseline (no earlier snapshot to compare against)';
712
947
  console.log(`${snapshot.createdAt} ${file} — ${changes}`);
713
948
  }
949
+ if (baselines === summaries.length) {
950
+ renderNote(
951
+ 'Nothing was compared: every snapshot shown is the first of its file.'
952
+ + ' That is no history, not no drift.',
953
+ );
954
+ } else if (baselines > 0) {
955
+ renderNote(
956
+ `${baselines} of ${summaries.length} snapshots shown are the first of their file`
957
+ + ' and were not compared against anything.',
958
+ );
959
+ }
714
960
  } catch (err) {
715
961
  renderError(err.message);
716
962
  process.exit(1);
@@ -814,9 +1060,12 @@ program
814
1060
  .description('Run semantic diff in CI mode')
815
1061
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
816
1062
  .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)
1063
+ .option('--format <type>', 'Output format: json | ndjson | sarif | github-annotations | pr-comment', 'json')
1064
+ .option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs a token + merge request context)', false)
1065
+ .option('--pr-provider <name>', `Force the comment delivery target: ${PR_PROVIDER_IDS.join(' | ')} (default: detect from CI)`)
819
1066
  .option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
1067
+ .option('--baseline <file>', 'Gate only on findings not already recorded in this baseline file')
1068
+ .option('--update-baseline', 'Rewrite the --baseline file from the current findings (explicit, never automatic)', false)
820
1069
  .option('--ignore <keys>', 'Comma-separated key paths to ignore')
821
1070
  .option('--policies <ids>', 'Comma-separated policy pack ids')
822
1071
  .option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
@@ -824,6 +1073,8 @@ program
824
1073
  .option('--no-array-id', 'Diff arrays by index instead of object identity')
825
1074
  .option('--array-ignore-order', 'Treat array order as insignificant', false)
826
1075
  .option('--mask-secrets', 'Mask secret-like values in CI output', false)
1076
+ .option('--show-suppressed', 'List inline-suppressed findings instead of only counting them', false)
1077
+ .option('--changed-only', 'With --format json|ndjson, replace envelopes for unchanged files with one scanned manifest', false)
827
1078
  .option('--allow-empty', 'Allow CI to succeed when no files were diffed', false)
828
1079
  .action(async (files, opts, command) => {
829
1080
  try {
@@ -840,19 +1091,38 @@ program
840
1091
  const ignorePaths = parseCsv(effective.ignore);
841
1092
  const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
842
1093
  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');
1094
+ if (!['json', 'ndjson', 'sarif', 'github-annotations', 'pr-comment'].includes(format)) {
1095
+ throw new Error('--format must be json, ndjson, sarif, github-annotations, or pr-comment');
845
1096
  }
846
1097
  const prCommentPost = Boolean(effective.prCommentPost);
1098
+ if (effective.prProvider && !PR_PROVIDER_IDS.includes(String(effective.prProvider))) {
1099
+ throw new Error(`--pr-provider must be one of: ${PR_PROVIDER_IDS.join(', ')}`);
1100
+ }
847
1101
  if (prCommentPost && format !== 'pr-comment') {
848
1102
  renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
849
1103
  }
850
1104
  const maskSecrets = Boolean(effective.maskSecrets);
1105
+ const showSuppressed = Boolean(effective.showSuppressed);
1106
+ const changedOnly = Boolean(effective.changedOnly);
1107
+ // github-annotations and pr-comment already render only what changed, so
1108
+ // there is nothing for the flag to collapse there. Say so rather than
1109
+ // accepting it and quietly doing nothing.
1110
+ if (changedOnly && format !== 'json' && format !== 'ndjson') {
1111
+ renderWarn(`Ignoring --changed-only: it only applies to --format json or ndjson (got ${format}).`);
1112
+ }
851
1113
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
852
1114
 
853
- /** @type {any[]} */
854
- const results = [];
855
- let shouldFail = false;
1115
+ const cwd = process.cwd();
1116
+ const baselinePath = effective.baseline ? resolve(cwd, String(effective.baseline)) : null;
1117
+ const updateBaseline = Boolean(effective.updateBaseline);
1118
+ if (updateBaseline && !baselinePath) {
1119
+ throw new Error('--update-baseline requires --baseline <file> naming the file to write.');
1120
+ }
1121
+
1122
+ /** @type {Array<{ filepath: string, relFile: string, outboundChanges: any[], outboundFindings: any[], changesFail: boolean }>} */
1123
+ const perFile = [];
1124
+ /** @type {Array<{ file: string, finding: any, reason: string }>} */
1125
+ const allSuppressed = [];
856
1126
  let diffed = 0;
857
1127
 
858
1128
  for (const filepath of targets) {
@@ -875,8 +1145,8 @@ program
875
1145
  );
876
1146
  }
877
1147
  const events = diffTrees(before, after, dOpts);
878
- const policyFindings = await evaluatePolicies(events, {
879
- cwd: process.cwd(),
1148
+ const rawFindings = await evaluatePolicies(events, {
1149
+ cwd,
880
1150
  file: filepath,
881
1151
  profile: profile ?? null,
882
1152
  source: 'ci',
@@ -884,20 +1154,24 @@ program
884
1154
  plugins,
885
1155
  severityRemap,
886
1156
  });
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
1157
 
898
- if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
899
- shouldFail = true;
1158
+ // Inline suppressions run first: a deliberately-accepted finding is
1159
+ // removed before the baseline, the gate, and the output ever see it, so
1160
+ // it is never also counted by a baseline. A directive missing its
1161
+ // mandatory reason is refused loudly rather than applied.
1162
+ const { active: policyFindings, suppressed } = resolveSuppressed(filepath, rawFindings);
1163
+ for (const item of suppressed) {
1164
+ allSuppressed.push({ file: filepath, finding: item.finding, reason: item.reason });
900
1165
  }
1166
+
1167
+ perFile.push({
1168
+ filepath,
1169
+ relFile: baselineRelativePath(filepath, cwd),
1170
+ outboundChanges: maybeMaskChanges(events, maskSecrets),
1171
+ outboundFindings: maybeMaskFindings(policyFindings, events, maskSecrets),
1172
+ changesFail: shouldFailFromChanges(events, failOn),
1173
+ });
1174
+ diffed += 1;
901
1175
  }
902
1176
 
903
1177
  if (diffed === 0 && !effective.allowEmpty) {
@@ -907,12 +1181,100 @@ program
907
1181
  );
908
1182
  }
909
1183
 
1184
+ // Suppressed findings are still surfaced — a count by default, the full
1185
+ // list with --show-suppressed — so a gate you cannot see the shape of does
1186
+ // not quietly grow. All of this goes to stderr, leaving machine output clean.
1187
+ if (allSuppressed.length > 0) {
1188
+ renderNote(
1189
+ `${allSuppressed.length} finding${allSuppressed.length === 1 ? '' : 's'} suppressed inline`
1190
+ + `${showSuppressed ? ':' : ' (use --show-suppressed to list them).'}`,
1191
+ );
1192
+ if (showSuppressed) {
1193
+ for (const { file, finding, reason } of allSuppressed) {
1194
+ const rel = relative(cwd, file) || file;
1195
+ renderNote(` ${rel} ${finding.path}: ${finding.id} — ${reason}`);
1196
+ }
1197
+ }
1198
+ }
1199
+
1200
+ // Every finding this run produced, paired with its repo-relative file, so
1201
+ // the baseline can be matched, updated, and stale-checked on stable keys.
1202
+ const located = perFile.flatMap((f) =>
1203
+ f.outboundFindings.map((finding) => ({ file: f.relFile, finding })));
1204
+
1205
+ // Without a baseline, every finding is active — behavior is unchanged.
1206
+ let activeByFile = new Map(perFile.map((f) => [f.relFile, f.outboundFindings]));
1207
+ let baselineSummary = null;
1208
+ if (baselinePath) {
1209
+ const { entries: recorded } = loadBaseline(baselinePath);
1210
+
1211
+ if (updateBaseline) {
1212
+ // Recording the current state accepts all of it: the file is rewritten
1213
+ // from every finding, and nothing is "new" relative to what was just
1214
+ // written, so the gate passes on the policy axis.
1215
+ writeBaselineFile(baselinePath, buildBaselineFile(located, recorded));
1216
+ renderNote(
1217
+ `Baseline updated: ${baselineRelativePath(baselinePath, cwd)} `
1218
+ + `(${located.length} finding${located.length === 1 ? '' : 's'} recorded)`,
1219
+ );
1220
+ activeByFile = new Map();
1221
+ baselineSummary = { active: 0, accepted: located.length, stale: [] };
1222
+ } else {
1223
+ const { active, accepted, stale } = applyBaseline(located, recorded);
1224
+ const activeMap = new Map();
1225
+ for (const { file, finding } of active) {
1226
+ if (!activeMap.has(file)) activeMap.set(file, []);
1227
+ activeMap.get(file).push(finding);
1228
+ }
1229
+ activeByFile = activeMap;
1230
+ baselineSummary = { active: active.length, accepted: accepted.length, stale };
1231
+ }
1232
+ }
1233
+
1234
+ // Results reflect the *active* findings: with a baseline in effect, an
1235
+ // accepted finding is suppressed from output as well as from the gate, so a
1236
+ // green run is not buried under hundreds of already-accepted findings.
1237
+ const results = perFile.map((f) => {
1238
+ const active = activeByFile.get(f.relFile) ?? [];
1239
+ return {
1240
+ file: f.filepath,
1241
+ envelope: createEnvelope({
1242
+ source: 'ci',
1243
+ file: f.filepath,
1244
+ changes: f.outboundChanges,
1245
+ policies: active,
1246
+ }),
1247
+ policies: active,
1248
+ };
1249
+ });
1250
+
1251
+ // With --update-baseline everything is now accepted, so there are no active
1252
+ // findings to gate on; the policy gate simply passes. Change-based triggers
1253
+ // are about the diff, not the findings, so they still apply.
1254
+ const activeFindings = results.flatMap((r) => r.policies);
1255
+ const shouldFail = perFile.some((f) => f.changesFail)
1256
+ || shouldFailFromPolicy(activeFindings, failOn);
1257
+
1258
+ if (baselineSummary) {
1259
+ const parts = [`${baselineSummary.active} new`, `${baselineSummary.accepted} baselined`];
1260
+ if (baselineSummary.stale.length > 0) parts.push(`${baselineSummary.stale.length} stale`);
1261
+ renderNote(`Baseline: ${parts.join(', ')}.`);
1262
+ if (baselineSummary.stale.length > 0 && !updateBaseline) {
1263
+ renderWarn(
1264
+ `${baselineSummary.stale.length} baseline `
1265
+ + `${baselineSummary.stale.length === 1 ? 'entry no longer occurs' : 'entries no longer occur'}; `
1266
+ + 're-run with --update-baseline to prune.',
1267
+ );
1268
+ }
1269
+ }
1270
+
910
1271
  if (format === 'pr-comment') {
911
- const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
912
- console.log(body);
913
- await deliverPrCommentSafely(body, prCommentPost);
1272
+ const body = renderPrComment(results, { cwd, failed: shouldFail });
1273
+ await writeStdout(body);
1274
+ await deliverPrCommentSafely(body, prCommentPost, effective.prProvider);
914
1275
  } else {
915
- printCiOutput(results, format);
1276
+ const collapsible = changedOnly && (format === 'json' || format === 'ndjson');
1277
+ await printCiOutput(collapsible ? collapseUnchangedResults(results) : results, format);
916
1278
  }
917
1279
  process.exit(shouldFail ? 1 : 0);
918
1280
  } catch (err) {
@@ -926,7 +1288,8 @@ program
926
1288
  .description('Diff Terraform plan JSON (terraform show -json) and run policies on it')
927
1289
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
928
1290
  .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)
1291
+ .option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs a token + merge request context)', false)
1292
+ .option('--pr-provider <name>', `Force the comment delivery target: ${PR_PROVIDER_IDS.join(' | ')} (default: detect from CI)`)
930
1293
  .option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', PLAN_DEFAULT_FAIL_ON)
931
1294
  .option('--ignore <keys>', 'Comma-separated key paths to ignore, e.g. "**.tags_all,**.#action"')
932
1295
  .option('--policies <ids>', `Comma-separated policy pack ids (default: ${PLAN_DEFAULT_POLICIES})`)
@@ -955,6 +1318,9 @@ program
955
1318
  throw new Error('--format must be human, json, ndjson, github-annotations, or pr-comment');
956
1319
  }
957
1320
  const prCommentPost = Boolean(effective.prCommentPost);
1321
+ if (effective.prProvider && !PR_PROVIDER_IDS.includes(String(effective.prProvider))) {
1322
+ throw new Error(`--pr-provider must be one of: ${PR_PROVIDER_IDS.join(', ')}`);
1323
+ }
958
1324
  if (prCommentPost && format !== 'pr-comment') {
959
1325
  renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
960
1326
  }
@@ -1013,10 +1379,10 @@ program
1013
1379
 
1014
1380
  if (format === 'pr-comment') {
1015
1381
  const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
1016
- console.log(body);
1017
- await deliverPrCommentSafely(body, prCommentPost);
1382
+ await writeStdout(body);
1383
+ await deliverPrCommentSafely(body, prCommentPost, effective.prProvider);
1018
1384
  } else if (format !== 'human') {
1019
- printCiOutput(results, format);
1385
+ await printCiOutput(results, format);
1020
1386
  }
1021
1387
  process.exit(shouldFail ? 1 : 0);
1022
1388
  } catch (err) {
@@ -1099,7 +1465,7 @@ program
1099
1465
  // Same envelope and printer as `ci`, so machine consumers see one shape.
1100
1466
  // `baseline` rides on the result wrapper rather than the envelope, which
1101
1467
  // is closed by schemas/flecto-envelope-2.0.json.
1102
- printCiOutput(
1468
+ await printCiOutput(
1103
1469
  [{ file: targetPath, baseline: baselinePath, envelope, policies: outboundFindings }],
1104
1470
  format,
1105
1471
  );