flecto 2.1.0 → 3.0.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, readdirSync, statSync } from 'fs';
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, realpathSync } from 'fs';
5
5
  import { resolve, relative, dirname, join } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { createHash } from 'crypto';
@@ -9,20 +9,37 @@ import { execFileSync } from 'child_process';
9
9
  import chalk from 'chalk';
10
10
 
11
11
  import { parseFile, isSupported, parseContent } from './src/parser.js';
12
- import { diffTrees } from './src/differ.js';
12
+ import { diffTrees, secretMatchPath } from './src/differ.js';
13
+ import { documentKeysOf, withDocumentKeys } from './src/documents.js';
13
14
  import { startWatcher } from './src/watcher.js';
14
15
  import {
15
16
  renderChanges,
16
17
  renderDiff,
17
18
  renderError,
18
19
  renderInfo,
20
+ renderNote,
19
21
  renderWarn,
20
22
  renderPolicyFindings,
21
23
  maskChangeEvent,
24
+ maskSensitiveValue,
22
25
  } from './src/renderer.js';
26
+ import { deliverPrComment, renderPrComment } from './src/pr-comment.js';
27
+ import {
28
+ diffTerraformPlan,
29
+ formatPlanSummary,
30
+ readTerraformPlanFile,
31
+ } from './src/terraform.js';
32
+ import { renderReportHtml } from './src/report.js';
33
+ import { redactSecretString } from './src/secrets.js';
23
34
  import { fireAlerts } from './src/alerter.js';
35
+ import { resolveWebhookFormat, WEBHOOK_FORMAT_CHOICES } from './src/notifiers.js';
24
36
  import { createEnvelope } from './src/envelope.js';
25
- import { evaluatePolicies, highestSeverity, listPolicyPacks } from './src/policy.js';
37
+ import {
38
+ evaluatePolicies,
39
+ highestSeverity,
40
+ listPolicyPacks,
41
+ addPolicyPackFromPackage,
42
+ } from './src/policy.js';
26
43
  import { testPolicyFixture } from './src/policy-test.js';
27
44
  import {
28
45
  loadRcConfig,
@@ -38,6 +55,17 @@ const PKG = JSON.parse(
38
55
  );
39
56
 
40
57
  const SNAPSHOT_DIR = '.flecto-snapshots';
58
+ const FAIL_ON_CHOICES = ['changed', 'added', 'removed', 'policy', 'error', 'warn'];
59
+
60
+ /**
61
+ * `flecto plan` defaults. A plan is expected to contain changes — that is the
62
+ * point of running one — so gating on `changed` the way `ci` does would fail
63
+ * every non-empty plan. The `terraform` pack reserves `error` for the patterns
64
+ * that genuinely should block a merge and leaves cost and sizing advice at
65
+ * `warn`, so `error` is the default gate; `--fail-on policy` or `warn` widens it.
66
+ */
67
+ const PLAN_DEFAULT_FAIL_ON = 'error';
68
+ const PLAN_DEFAULT_POLICIES = 'terraform';
41
69
 
42
70
  function snapshotIdForPath(absPath) {
43
71
  const normalized = absPath.replaceAll('\\', '/');
@@ -60,14 +88,27 @@ function snapshotHistoryPathForFile(absPath) {
60
88
  return path;
61
89
  }
62
90
 
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));
91
+ /**
92
+ * Snapshot ids that already have at least one timestamped history entry.
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>}
98
+ */
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;
67
108
  }
68
109
 
69
- function preserveLegacySnapshotForHistory(absPath, snapshotPath) {
70
- if (!existsSync(snapshotPath) || hasSnapshotHistoryForFile(absPath)) return;
110
+ function preserveLegacySnapshotForHistory(absPath, snapshotPath, idsWithHistory) {
111
+ if (!existsSync(snapshotPath) || idsWithHistory.has(snapshotIdForPath(absPath))) return;
71
112
 
72
113
  const legacy = JSON.parse(readFileSync(snapshotPath, 'utf8'));
73
114
  writeFileSync(
@@ -75,6 +116,7 @@ function preserveLegacySnapshotForHistory(absPath, snapshotPath) {
75
116
  JSON.stringify({
76
117
  file: legacy.file ?? absPath,
77
118
  state: legacy.state ?? legacy,
119
+ ...(Array.isArray(legacy.documents) ? { documents: legacy.documents } : {}),
78
120
  createdAt: legacy.createdAt ?? statSync(snapshotPath).mtime.toISOString(),
79
121
  }, null, 2),
80
122
  'utf8',
@@ -95,7 +137,7 @@ function readLocalSnapshotHistory() {
95
137
  return snapshotEntries.map((entry) => {
96
138
  const path = resolve(SNAPSHOT_DIR, entry.name);
97
139
  const snapshot = JSON.parse(readFileSync(path, 'utf8'));
98
- const state = snapshot?.state ?? snapshot;
140
+ const state = restoreSnapshotDocumentKeys(snapshot?.state ?? snapshot, snapshot);
99
141
  if (typeof snapshot?.file !== 'string') {
100
142
  throw new Error(`Invalid snapshot file: ${path}`);
101
143
  }
@@ -119,11 +161,18 @@ function summarizeSnapshotHistory(snapshots, limit, diffOpts = {}) {
119
161
  for (const records of byFile.values()) {
120
162
  records.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
121
163
  for (let index = 0; index < records.length; index += 1) {
164
+ // The previous snapshot of the *same file* is the baseline, and it may
165
+ // fall outside the limit window — so carry it here rather than letting
166
+ // callers infer it from the truncated result.
167
+ const previous = index === 0 ? null : records[index - 1];
168
+ const changes = previous
169
+ ? diffTrees(previous.state, records[index].state, diffOpts)
170
+ : [];
122
171
  summaries.push({
123
172
  ...records[index],
124
- changeCount: index === 0
125
- ? 0
126
- : diffTrees(records[index - 1].state, records[index].state, diffOpts).length,
173
+ previousCreatedAt: previous ? previous.createdAt : null,
174
+ changes,
175
+ changeCount: changes.length,
127
176
  });
128
177
  }
129
178
  }
@@ -139,6 +188,18 @@ function parseCsv(value) {
139
188
  return String(value).split(',').map((s) => s.trim()).filter(Boolean);
140
189
  }
141
190
 
191
+ function parseFailOn(value) {
192
+ const rules = parseCsv(value);
193
+ const invalid = rules.filter((rule) => !FAIL_ON_CHOICES.includes(rule));
194
+ if (invalid.length > 0) {
195
+ throw new Error(
196
+ `--fail-on contains unknown trigger${invalid.length === 1 ? '' : 's'}: ${invalid.join(', ')}. `
197
+ + `Valid triggers: ${FAIL_ON_CHOICES.join(', ')}`,
198
+ );
199
+ }
200
+ return new Set(rules);
201
+ }
202
+
142
203
  function parseHeaders(headerList) {
143
204
  const webhookHeaders = {};
144
205
  if (!Array.isArray(headerList)) return webhookHeaders;
@@ -188,6 +249,32 @@ function maybeMaskChanges(events, maskSecrets) {
188
249
  return events.map(maskChangeEvent);
189
250
  }
190
251
 
252
+ /**
253
+ * Redact secret-shaped text from policy messages. A rule using
254
+ * `messageTemplate` can interpolate `{before}` / `{after}`, so a finding can
255
+ * carry a credential even when the change events beside it are masked. Replace
256
+ * exact interpolated values using the same path-aware masking as change events,
257
+ * then catch any other recognizable secret fragments in free-form messages.
258
+ * @param {import('./src/policy.js').PolicyFinding[]} findings
259
+ * @param {import('./src/differ.js').ChangeEvent[]} changes
260
+ * @param {boolean} maskSecrets
261
+ * @returns {import('./src/policy.js').PolicyFinding[]}
262
+ */
263
+ function maybeMaskFindings(findings, changes, maskSecrets) {
264
+ if (!maskSecrets) return findings;
265
+ return findings.map((finding) => {
266
+ let message = String(finding.message ?? '');
267
+ for (const change of changes.filter((event) => event.path === finding.path)) {
268
+ for (const value of [change.before, change.after]) {
269
+ const original = String(value);
270
+ const masked = String(maskSensitiveValue(value, secretMatchPath(change)));
271
+ if (original && original !== masked) message = message.replaceAll(original, masked);
272
+ }
273
+ }
274
+ return { ...finding, message: redactSecretString(message) };
275
+ });
276
+ }
277
+
191
278
  async function resolveTargetFiles(cliFiles, rcConfig) {
192
279
  if (cliFiles && cliFiles.length > 0) {
193
280
  const direct = [];
@@ -212,14 +299,62 @@ async function resolveTargetFiles(cliFiles, rcConfig) {
212
299
 
213
300
  return resolveFiles({
214
301
  cwd: process.cwd(),
215
- files: rcConfig?.files ?? rcConfig?.include ?? [],
302
+ files: rcConfig?.files ?? [],
303
+ include: rcConfig?.include ?? [],
216
304
  exclude: rcConfig?.exclude ?? [],
217
305
  });
218
306
  }
219
307
 
308
+ /**
309
+ * Restore the parser's multi-document signal onto a state read back from a
310
+ * snapshot. A snapshot is plain JSON, so the in-memory marking is gone; a
311
+ * snapshot written before this field existed simply leaves the provenance
312
+ * unknown, which is what it is.
313
+ * @param {unknown} state
314
+ * @param {unknown} snapshot the parsed snapshot envelope
315
+ * @returns {unknown} the same state
316
+ */
317
+ function restoreSnapshotDocumentKeys(state, snapshot) {
318
+ const documents = /** @type {{ documents?: unknown }} */ (snapshot)?.documents;
319
+ if (!Array.isArray(documents)) return state;
320
+ return withDocumentKeys(state, documents.map(String));
321
+ }
322
+
220
323
  function readSnapshotStateFromFile(snapshotPath) {
221
324
  const snap = JSON.parse(readFileSync(snapshotPath, 'utf8'));
222
- return snap?.state ?? snap;
325
+ return restoreSnapshotDocumentKeys(snap?.state ?? snap, snap);
326
+ }
327
+
328
+ /**
329
+ * Resolve a file to a path relative to its git repository root.
330
+ *
331
+ * `git show <rev>:<path>` interprets <path> from the repository root, so a
332
+ * cwd-relative path breaks whenever Flecto runs from a subdirectory. Both sides
333
+ * are canonicalized first: process.cwd() reports a symlink-resolved path while
334
+ * CLI arguments keep the symlinks the user typed, and on macOS /tmp and
335
+ * /var/folders are symlinks, so comparing the two forms directly misresolves.
336
+ * @param {string} filePath
337
+ * @returns {string} POSIX-style path relative to the repository root
338
+ */
339
+ function gitRepoRelativePath(filePath) {
340
+ const top = execFileSync('git', ['-C', dirname(filePath), 'rev-parse', '--show-toplevel'], {
341
+ encoding: 'utf8',
342
+ }).trim();
343
+ return relative(canonicalPath(top), canonicalPath(filePath)).replaceAll('\\', '/');
344
+ }
345
+
346
+ /**
347
+ * Resolve symlinks where possible, falling back to the input when the path does
348
+ * not exist on disk.
349
+ * @param {string} path
350
+ * @returns {string}
351
+ */
352
+ function canonicalPath(path) {
353
+ try {
354
+ return realpathSync(path);
355
+ } catch {
356
+ return resolve(path);
357
+ }
223
358
  }
224
359
 
225
360
  function readSnapshotStateFromRef(filePath, snapshotRef) {
@@ -229,7 +364,7 @@ function readSnapshotStateFromRef(filePath, snapshotRef) {
229
364
  return readSnapshotStateFromFile(maybePath);
230
365
  }
231
366
 
232
- const rel = relative(process.cwd(), filePath).replaceAll('\\', '/');
367
+ const rel = gitRepoRelativePath(filePath);
233
368
  const raw = execFileSync('git', ['show', `${snapshotRef}:${rel}`], { encoding: 'utf8' });
234
369
  return parseContent(filePath, raw);
235
370
  }
@@ -291,6 +426,26 @@ function printCiOutput(results, format) {
291
426
  }
292
427
  }
293
428
 
429
+ /**
430
+ * Deliver the sticky PR comment without ever changing the CI outcome: a
431
+ * delivery problem warns, and the exit code stays with the diff/policy result.
432
+ * @param {string} body
433
+ * @param {boolean} enabled
434
+ */
435
+ async function deliverPrCommentSafely(body, enabled) {
436
+ if (!enabled) return;
437
+ try {
438
+ const result = await deliverPrComment(body, { enabled: true });
439
+ if (result.posted) {
440
+ renderNote(`PR comment ${result.action}${result.url ? `: ${result.url}` : ''}`);
441
+ return;
442
+ }
443
+ renderWarn(`Could not post the PR comment: ${result.reason}`);
444
+ } catch (err) {
445
+ renderWarn(`Could not post the PR comment: ${err.message}`);
446
+ }
447
+ }
448
+
294
449
  program
295
450
  .name('flecto')
296
451
  .description('Flecto — semantic config watcher for meaningful structured file changes')
@@ -309,6 +464,7 @@ program
309
464
  acc.push(v);
310
465
  return acc;
311
466
  }, [])
467
+ .option('--webhook-format <service>', `Webhook payload format: ${WEBHOOK_FORMAT_CHOICES.join(' | ')}`)
312
468
  .option('--delivery-mode <mode>', 'Alert delivery mode: best-effort | at-least-once', 'best-effort')
313
469
  .option('--on-alert-failure <mode>', 'Alert failure behavior: warn | exit | retry', 'warn')
314
470
  .option('--webhook-timeout <ms>', 'Webhook timeout in ms', '5000')
@@ -343,10 +499,12 @@ program
343
499
  validateMode(mode);
344
500
  const maskSecrets = Boolean(effective.maskSecrets);
345
501
  const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
502
+ const webhookFormat = resolveWebhookFormat(effective.webhookFormat, effective.webhook);
346
503
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
347
504
 
348
505
  if (effective.snapshot) {
349
506
  mkdirSync(SNAPSHOT_DIR, { recursive: true });
507
+ const idsWithHistory = snapshotIdsWithHistory();
350
508
  let written = 0;
351
509
  for (const filepath of targets) {
352
510
  if (!existsSync(filepath)) {
@@ -359,10 +517,21 @@ program
359
517
  }
360
518
  const state = parseFile(filepath);
361
519
  const snapshotPath = snapshotPathForFile(filepath);
362
- preserveLegacySnapshotForHistory(filepath, snapshotPath);
363
- const snapshot = { file: filepath, state, createdAt: new Date().toISOString() };
520
+ preserveLegacySnapshotForHistory(filepath, snapshotPath, idsWithHistory);
521
+ // Only a multi-document file records `documents`, so an ordinary
522
+ // snapshot is byte-for-byte what it was before this field existed.
523
+ const documents = documentKeysOf(state) ?? [];
524
+ const snapshot = {
525
+ file: filepath,
526
+ state,
527
+ ...(documents.length > 0 ? { documents: [...documents] } : {}),
528
+ createdAt: new Date().toISOString(),
529
+ };
364
530
  writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf8');
365
531
  writeFileSync(snapshotHistoryPathForFile(filepath), JSON.stringify(snapshot, null, 2), 'utf8');
532
+ // Keep the set in step with what this run has written, so a repeated
533
+ // target behaves exactly as it did when the check hit the disk.
534
+ idsWithHistory.add(snapshotIdForPath(filepath));
366
535
  console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
367
536
  written += 1;
368
537
  }
@@ -393,6 +562,33 @@ program
393
562
  }
394
563
 
395
564
  const watchers = [];
565
+ let closing = false;
566
+ const closeAll = async (exitCode) => {
567
+ if (closing) return;
568
+ closing = true;
569
+ await Promise.all(watchers.map((w) => w.close()));
570
+ if (exitCode === 0) {
571
+ console.log(chalk.dim('\nflecto stopped.'));
572
+ }
573
+ process.exit(exitCode);
574
+ };
575
+ const alertOptions = {
576
+ command: effective.command,
577
+ webhook: effective.webhook,
578
+ webhookHeaders,
579
+ webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
580
+ webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
581
+ webhookFormat,
582
+ deliveryMode: effective.deliveryMode,
583
+ onAlertFailure: effective.onAlertFailure,
584
+ };
585
+ const deliverAlert = async (envelope) => {
586
+ const result = await fireAlerts(alertOptions, envelope);
587
+ if (!result.ok && effective.onAlertFailure === 'exit') {
588
+ await closeAll(1);
589
+ }
590
+ };
591
+
396
592
  for (const filepath of targets) {
397
593
  if (!existsSync(filepath)) {
398
594
  renderWarn(`Skipping missing file: ${filepath}`);
@@ -425,24 +621,20 @@ program
425
621
  renderError(`policy evaluation failed: ${err.message}`);
426
622
  process.exit(1);
427
623
  }
428
- renderPolicyFindings(policyFindings);
624
+ renderPolicyFindings(maybeMaskFindings(policyFindings, event.events, maskSecrets));
429
625
  if (effective.command || effective.webhook) {
430
626
  const outboundChanges = maybeMaskChanges(event.events, maskSecretsWebhooks);
431
627
  const envelope = createEnvelope({
432
628
  source: 'watch',
433
629
  file: event.filepath,
434
630
  changes: outboundChanges,
435
- policies: policyFindings,
631
+ policies: maybeMaskFindings(
632
+ policyFindings,
633
+ event.events,
634
+ maskSecretsWebhooks,
635
+ ),
436
636
  });
437
- await fireAlerts({
438
- command: effective.command,
439
- webhook: effective.webhook,
440
- webhookHeaders,
441
- webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
442
- webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
443
- deliveryMode: effective.deliveryMode,
444
- onAlertFailure: effective.onAlertFailure,
445
- }, envelope);
637
+ await deliverAlert(envelope);
446
638
  }
447
639
  } else {
448
640
  renderInfo(`[lifecycle] ${event.filepath}: ${event.lifecycle.type} - ${event.lifecycle.message}`);
@@ -452,15 +644,7 @@ program
452
644
  file: event.filepath,
453
645
  lifecycle: event.lifecycle,
454
646
  });
455
- await fireAlerts({
456
- command: effective.command,
457
- webhook: effective.webhook,
458
- webhookHeaders,
459
- webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
460
- webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
461
- deliveryMode: effective.deliveryMode,
462
- onAlertFailure: effective.onAlertFailure,
463
- }, envelope);
647
+ await deliverAlert(envelope);
464
648
  }
465
649
  }
466
650
  }
@@ -473,13 +657,6 @@ program
473
657
  }
474
658
  renderInfo('Press Ctrl+C to stop.\n');
475
659
 
476
- const closeAll = async (exitCode) => {
477
- await Promise.all(watchers.map((w) => w.close()));
478
- if (exitCode === 0) {
479
- console.log(chalk.dim('\nflecto stopped.'));
480
- }
481
- process.exit(exitCode);
482
- };
483
660
  process.on('SIGINT', () => void closeAll(0));
484
661
  process.on('SIGTERM', () => void closeAll(0));
485
662
  } catch (err) {
@@ -538,12 +715,104 @@ program
538
715
  }
539
716
  });
540
717
 
718
+ program
719
+ .command('report [files...]')
720
+ .description('Render local snapshot history as a self-contained HTML report')
721
+ .option('-o, --output <path>', 'Write the report to this path', 'flecto-report.html')
722
+ .option('-l, --limit <n>', 'Number of recent snapshots to include', '10')
723
+ .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
724
+ .option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
725
+ .option('--policies <ids>', 'Comma-separated policy pack ids (default: default)')
726
+ .option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
727
+ .option('--array-id-key <key>', 'Diff arrays by this object identity key')
728
+ .option('--no-array-id', 'Diff arrays by index instead of object identity')
729
+ .option('--array-ignore-order', 'Treat array order as insignificant', false)
730
+ .option('--mask-secrets', 'Mask secret-like values in the report', false)
731
+ .action(async (files, opts, command) => {
732
+ try {
733
+ const { config } = loadRcConfig(process.cwd());
734
+ const profile = resolveProfileName(opts.profile);
735
+ const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
736
+ const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective);
737
+
738
+ const limit = Number.parseInt(String(effective.limit ?? '10'), 10);
739
+ if (!Number.isInteger(limit) || limit < 1) {
740
+ throw new Error('--limit must be a positive integer');
741
+ }
742
+ const ignorePaths = parseCsv(effective.ignore);
743
+ const dOpts = diffOptionsFromEffective(effective, ignorePaths);
744
+ const maskSecrets = Boolean(effective.maskSecrets);
745
+ const outputPath = resolve(String(effective.output ?? 'flecto-report.html'));
746
+
747
+ // Same snapshot source, filtering, and errors as `flecto history` — this
748
+ // command only changes how that history is rendered.
749
+ const allSnapshots = readLocalSnapshotHistory();
750
+ let snapshots = allSnapshots;
751
+ if (files.length > 0) {
752
+ const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
753
+ snapshots = snapshots.filter((snapshot) => targets.has(resolve(snapshot.file)));
754
+ }
755
+
756
+ const summaries = summarizeSnapshotHistory(snapshots, limit, dOpts);
757
+ if (summaries.length === 0) {
758
+ if (files.length > 0 && allSnapshots.length > 0) {
759
+ throw new Error(
760
+ 'No local snapshots matched the given files. Omit files to report on all saved snapshot history.',
761
+ );
762
+ }
763
+ throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
764
+ }
765
+
766
+ const reportSnapshots = [];
767
+ for (const summary of summaries) {
768
+ // Policies run on the unmasked events: masking first would hide the
769
+ // very values the secret rules match on. Redaction happens after, on
770
+ // everything that reaches the page.
771
+ const findings = await evaluatePolicies(summary.changes, {
772
+ cwd: process.cwd(),
773
+ file: summary.file,
774
+ profile: profile ?? null,
775
+ source: 'diff',
776
+ policies: packIds,
777
+ plugins,
778
+ severityRemap,
779
+ });
780
+ reportSnapshots.push({
781
+ file: summary.file,
782
+ createdAt: summary.createdAt,
783
+ previousCreatedAt: summary.previousCreatedAt,
784
+ changeCount: summary.changeCount,
785
+ changes: maybeMaskChanges(summary.changes, maskSecrets),
786
+ policies: maybeMaskFindings(findings, summary.changes, maskSecrets),
787
+ });
788
+ }
789
+
790
+ const html = renderReportHtml({
791
+ snapshots: reportSnapshots,
792
+ generatedAt: new Date().toISOString(),
793
+ cwd: process.cwd(),
794
+ version: PKG.version,
795
+ limit,
796
+ maskSecrets,
797
+ });
798
+ mkdirSync(dirname(outputPath), { recursive: true });
799
+ writeFileSync(outputPath, html, 'utf8');
800
+ console.log(chalk.green(
801
+ `✓ Report written: ${outputPath} (${summaries.length} snapshot${summaries.length === 1 ? '' : 's'})`,
802
+ ));
803
+ } catch (err) {
804
+ renderError(err.message);
805
+ process.exit(1);
806
+ }
807
+ });
808
+
541
809
  program
542
810
  .command('ci [files...]')
543
811
  .description('Run semantic diff in CI mode')
544
812
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
545
813
  .option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
546
- .option('--format <type>', 'Output format: json | ndjson | github-annotations', 'json')
814
+ .option('--format <type>', 'Output format: json | ndjson | github-annotations | pr-comment', 'json')
815
+ .option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs GITHUB_TOKEN + PR context)', false)
547
816
  .option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
548
817
  .option('--ignore <keys>', 'Comma-separated key paths to ignore')
549
818
  .option('--policies <ids>', 'Comma-separated policy pack ids')
@@ -565,10 +834,14 @@ program
565
834
  }
566
835
 
567
836
  const ignorePaths = parseCsv(effective.ignore);
568
- const failOn = new Set(parseCsv(effective.failOn ?? 'changed,policy,error'));
837
+ const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
569
838
  const format = String(effective.format ?? 'json');
570
- if (!['json', 'ndjson', 'github-annotations'].includes(format)) {
571
- throw new Error('--format must be json, ndjson, or github-annotations');
839
+ if (!['json', 'ndjson', 'github-annotations', 'pr-comment'].includes(format)) {
840
+ throw new Error('--format must be json, ndjson, github-annotations, or pr-comment');
841
+ }
842
+ const prCommentPost = Boolean(effective.prCommentPost);
843
+ if (prCommentPost && format !== 'pr-comment') {
844
+ renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
572
845
  }
573
846
  const maskSecrets = Boolean(effective.maskSecrets);
574
847
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
@@ -608,13 +881,14 @@ program
608
881
  severityRemap,
609
882
  });
610
883
  const outboundChanges = maybeMaskChanges(events, maskSecrets);
884
+ const outboundFindings = maybeMaskFindings(policyFindings, events, maskSecrets);
611
885
  const envelope = createEnvelope({
612
886
  source: 'ci',
613
887
  file: filepath,
614
888
  changes: outboundChanges,
615
- policies: policyFindings,
889
+ policies: outboundFindings,
616
890
  });
617
- results.push({ file: filepath, envelope, policies: policyFindings });
891
+ results.push({ file: filepath, envelope, policies: outboundFindings });
618
892
  diffed += 1;
619
893
 
620
894
  if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
@@ -629,7 +903,203 @@ program
629
903
  );
630
904
  }
631
905
 
632
- printCiOutput(results, format);
906
+ if (format === 'pr-comment') {
907
+ const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
908
+ console.log(body);
909
+ await deliverPrCommentSafely(body, prCommentPost);
910
+ } else {
911
+ printCiOutput(results, format);
912
+ }
913
+ process.exit(shouldFail ? 1 : 0);
914
+ } catch (err) {
915
+ renderError(err.message);
916
+ process.exit(1);
917
+ }
918
+ });
919
+
920
+ program
921
+ .command('plan <planFiles...>')
922
+ .description('Diff Terraform plan JSON (terraform show -json) and run policies on it')
923
+ .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
924
+ .option('--format <type>', 'Output format: human | json | ndjson | github-annotations | pr-comment', 'human')
925
+ .option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs GITHUB_TOKEN + PR context)', false)
926
+ .option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', PLAN_DEFAULT_FAIL_ON)
927
+ .option('--ignore <keys>', 'Comma-separated key paths to ignore, e.g. "**.tags_all,**.#action"')
928
+ .option('--policies <ids>', `Comma-separated policy pack ids (default: ${PLAN_DEFAULT_POLICIES})`)
929
+ .option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
930
+ .option('--mask-secrets', 'Also mask Flecto-detected secret-like values (Terraform-sensitive values are always redacted)', false)
931
+ .action(async (planFiles, opts, command) => {
932
+ try {
933
+ const { config } = loadRcConfig(process.cwd());
934
+ const profile = resolveProfileName(opts.profile);
935
+ const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
936
+ // A plan carries Terraform-shaped paths, so the config-file packs are not
937
+ // the useful default here; `terraform` is. An explicit --policies or a
938
+ // .flectorc entry still wins.
939
+ const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(
940
+ effective.policies === undefined
941
+ ? { ...effective, policies: PLAN_DEFAULT_POLICIES }
942
+ : effective,
943
+ );
944
+
945
+ const ignorePaths = parseCsv(effective.ignore);
946
+ const failOn = new Set(parseCsv(effective.failOn ?? PLAN_DEFAULT_FAIL_ON));
947
+ const format = String(effective.format ?? 'human');
948
+ if (!['human', 'json', 'ndjson', 'github-annotations', 'pr-comment'].includes(format)) {
949
+ throw new Error('--format must be human, json, ndjson, github-annotations, or pr-comment');
950
+ }
951
+ const prCommentPost = Boolean(effective.prCommentPost);
952
+ if (prCommentPost && format !== 'pr-comment') {
953
+ renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
954
+ }
955
+ const maskSecrets = Boolean(effective.maskSecrets);
956
+
957
+ /** @type {any[]} */
958
+ const results = [];
959
+ let shouldFail = false;
960
+
961
+ for (const planFile of planFiles) {
962
+ const filepath = resolve(planFile);
963
+ if (!existsSync(filepath)) {
964
+ throw new Error(`File not found: ${filepath}`);
965
+ }
966
+ const plan = readTerraformPlanFile(filepath);
967
+ const { changes, summary, formatVersion, terraformVersion, warnings } =
968
+ diffTerraformPlan(plan, { ignorePaths });
969
+ for (const warning of warnings) renderWarn(warning);
970
+
971
+ // Terraform-sensitive values were already replaced during conversion,
972
+ // so policies never see them. --mask-secrets adds Flecto's own
973
+ // value-shaped detection on top, for credentials Terraform did not mark.
974
+ const policyFindings = await evaluatePolicies(changes, {
975
+ cwd: process.cwd(),
976
+ file: filepath,
977
+ profile: profile ?? null,
978
+ source: 'ci',
979
+ policies: packIds,
980
+ plugins,
981
+ severityRemap,
982
+ });
983
+ const outboundChanges = maybeMaskChanges(changes, maskSecrets);
984
+ const envelope = createEnvelope({
985
+ source: 'ci',
986
+ file: filepath,
987
+ changes: outboundChanges,
988
+ policies: maybeMaskFindings(policyFindings, maskSecrets),
989
+ });
990
+ results.push({ file: filepath, envelope, policies: envelope.policies });
991
+
992
+ if (format === 'human') {
993
+ const version = [
994
+ formatVersion ? `plan format ${formatVersion}` : null,
995
+ terraformVersion ? `terraform ${terraformVersion}` : null,
996
+ ].filter(Boolean).join(', ');
997
+ renderInfo(`${filepath}${version ? ` — ${version}` : ''}`);
998
+ renderInfo(formatPlanSummary(summary));
999
+ renderDiff(filepath, outboundChanges, { maskSecrets, baseline: 'the current state' });
1000
+ renderPolicyFindings(envelope.policies);
1001
+ }
1002
+
1003
+ if (shouldFailFromChanges(changes, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
1004
+ shouldFail = true;
1005
+ }
1006
+ }
1007
+
1008
+ if (format === 'pr-comment') {
1009
+ const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
1010
+ console.log(body);
1011
+ await deliverPrCommentSafely(body, prCommentPost);
1012
+ } else if (format !== 'human') {
1013
+ printCiOutput(results, format);
1014
+ }
1015
+ process.exit(shouldFail ? 1 : 0);
1016
+ } catch (err) {
1017
+ renderError(err.message);
1018
+ process.exit(1);
1019
+ }
1020
+ });
1021
+
1022
+ program
1023
+ .command('compare <fileA> <fileB>')
1024
+ .description('Diff two config files against each other (fileA is the baseline)')
1025
+ .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
1026
+ .option('--format <type>', 'Output format: human | json | ndjson | github-annotations', 'human')
1027
+ .option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,added,removed,policy,error')
1028
+ .option('--ignore <keys>', 'Comma-separated key paths to ignore')
1029
+ .option('--policies <ids>', 'Comma-separated policy pack ids')
1030
+ .option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
1031
+ .option('--array-id-key <key>', 'Diff arrays by this object identity key')
1032
+ .option('--no-array-id', 'Diff arrays by index instead of object identity')
1033
+ .option('--array-ignore-order', 'Treat array order as insignificant', false)
1034
+ .option('--mask-secrets', 'Mask secret-like values in output', false)
1035
+ .action(async (fileA, fileB, opts, command) => {
1036
+ try {
1037
+ const { config } = loadRcConfig(process.cwd());
1038
+ const profile = resolveProfileName(opts.profile);
1039
+ const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
1040
+ const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective);
1041
+
1042
+ const ignorePaths = parseCsv(effective.ignore);
1043
+ const failOn = parseFailOn(effective.failOn ?? 'changed,added,removed,policy,error');
1044
+ const format = String(effective.format ?? 'human');
1045
+ if (!['human', 'json', 'ndjson', 'github-annotations'].includes(format)) {
1046
+ throw new Error('--format must be human, json, ndjson, or github-annotations');
1047
+ }
1048
+ const maskSecrets = Boolean(effective.maskSecrets);
1049
+ const dOpts = diffOptionsFromEffective(effective, ignorePaths);
1050
+
1051
+ const baselinePath = resolve(fileA);
1052
+ const targetPath = resolve(fileB);
1053
+ // Both sides are named explicitly, so a missing one is an error rather
1054
+ // than the skip-and-warn `ci` applies to expanded globs.
1055
+ for (const filepath of [baselinePath, targetPath]) {
1056
+ if (!existsSync(filepath)) {
1057
+ throw new Error(`File not found: ${filepath}`);
1058
+ }
1059
+ }
1060
+
1061
+ // fileA is the baseline: "removed" is present only in fileA, "added" only
1062
+ // in fileB. Every format parses to a plain tree, so the two sides need not
1063
+ // share one — parseFile rejects unsupported extensions with the same
1064
+ // message every other command uses.
1065
+ const before = parseFile(baselinePath);
1066
+ const after = parseFile(targetPath);
1067
+ const events = diffTrees(before, after, dOpts);
1068
+ const policyFindings = await evaluatePolicies(events, {
1069
+ cwd: process.cwd(),
1070
+ file: targetPath,
1071
+ profile: profile ?? null,
1072
+ source: 'diff',
1073
+ policies: packIds,
1074
+ plugins,
1075
+ severityRemap,
1076
+ });
1077
+ const outboundFindings = maybeMaskFindings(policyFindings, events, maskSecrets);
1078
+
1079
+ if (format === 'human') {
1080
+ if (events.length > 0) {
1081
+ renderInfo('"+" exists only in the compared file, "-" only in the baseline, "~" differs');
1082
+ }
1083
+ renderDiff(targetPath, events, { maskSecrets, baseline: baselinePath });
1084
+ renderPolicyFindings(outboundFindings);
1085
+ } else {
1086
+ const envelope = createEnvelope({
1087
+ source: 'diff',
1088
+ file: targetPath,
1089
+ changes: maybeMaskChanges(events, maskSecrets),
1090
+ policies: outboundFindings,
1091
+ });
1092
+ // Same envelope and printer as `ci`, so machine consumers see one shape.
1093
+ // `baseline` rides on the result wrapper rather than the envelope, which
1094
+ // is closed by schemas/flecto-envelope-2.0.json.
1095
+ printCiOutput(
1096
+ [{ file: targetPath, baseline: baselinePath, envelope, policies: outboundFindings }],
1097
+ format,
1098
+ );
1099
+ }
1100
+
1101
+ const shouldFail = shouldFailFromChanges(events, failOn)
1102
+ || shouldFailFromPolicy(policyFindings, failOn);
633
1103
  process.exit(shouldFail ? 1 : 0);
634
1104
  } catch (err) {
635
1105
  renderError(err.message);
@@ -671,10 +1141,10 @@ program
671
1141
  }
672
1142
 
673
1143
  console.log('Resolution order: policies/<id>.json, .yaml, .yml, then built-in packs.');
674
- console.log('id\tsource path\trules\toverrides builtin');
1144
+ console.log('id\tsource path\trules\toverrides builtin\tpackage');
675
1145
  for (const pack of packs) {
676
1146
  console.log(
677
- `${pack.id}\t${pack.sourcePath}\t${pack.ruleCount}\t${pack.overridesBuiltin ? 'yes' : 'no'}`,
1147
+ `${pack.id}\t${pack.sourcePath}\t${pack.ruleCount}\t${pack.overridesBuiltin ? 'yes' : 'no'}\t${pack.package ?? '-'}`,
678
1148
  );
679
1149
  }
680
1150
  } catch (err) {
@@ -682,14 +1152,62 @@ program
682
1152
  process.exit(1);
683
1153
  }
684
1154
  });
1155
+
1156
+ policies
1157
+ .command('add <name>')
1158
+ .description('Install a policy pack from an installed flecto-pack-* npm package')
1159
+ .option('--force', 'Overwrite an existing local pack with the same id')
1160
+ .action((name, opts) => {
1161
+ try {
1162
+ const added = addPolicyPackFromPackage(name, {
1163
+ cwd: process.cwd(),
1164
+ force: Boolean(opts.force),
1165
+ });
1166
+ const version = added.packageVersion ? `@${added.packageVersion}` : '';
1167
+ const verb = added.overwritten ? 'Updated' : 'Added';
1168
+ renderInfo(
1169
+ `${verb} policy pack "${added.id}" from ${added.packageName}${version} `
1170
+ + `→ ${relative(process.cwd(), added.targetPath)} `
1171
+ + `(${added.ruleCount} rule${added.ruleCount === 1 ? '' : 's'})`,
1172
+ );
1173
+ if (added.overridesBuiltin) {
1174
+ renderWarn(`Pack "${added.id}" now overrides the built-in pack of the same id.`);
1175
+ }
1176
+ for (const path of added.shadowed) {
1177
+ renderWarn(`${relative(process.cwd(), path)} is no longer used: ${added.id}.json wins.`);
1178
+ }
1179
+ if (added.shipsCode) {
1180
+ renderInfo(
1181
+ `${added.packageName} also ships JavaScript. It was ignored: only the declarative `
1182
+ + 'pack file is read, and no package code is ever imported or run.',
1183
+ );
1184
+ }
1185
+ renderInfo(`Activate it with: flecto ci <files> --policies ${added.id}`);
1186
+ } catch (err) {
1187
+ renderError(err.message);
1188
+ process.exit(1);
1189
+ }
1190
+ });
685
1191
  }
686
1192
 
687
1193
  program
688
1194
  .command('init')
689
- .description('Create starter .flectorc configuration')
1195
+ .description('Create starter .flectorc configuration from detected stack signals')
690
1196
  .action(() => {
691
- const path = initRcFile(process.cwd());
1197
+ const { path, created, detection } = initRcFile(process.cwd());
1198
+ if (!created) {
1199
+ renderWarn(`Config already exists: ${path} (left unchanged)`);
1200
+ return;
1201
+ }
692
1202
  renderInfo(`Initialized config: ${path}`);
1203
+ if (detection.signals.length === 0) {
1204
+ renderInfo('No stack signals detected — wrote the generic starter config.');
1205
+ return;
1206
+ }
1207
+ for (const signal of detection.signals) {
1208
+ renderInfo(signal.summary);
1209
+ }
1210
+ renderInfo(`Policy packs: ${detection.packs.join(', ')}`);
693
1211
  });
694
1212
 
695
1213
  program