flecto 2.1.0 → 3.0.1

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')
@@ -328,8 +484,9 @@ program
328
484
  try {
329
485
  const { config } = loadRcConfig(process.cwd());
330
486
  const profile = resolveProfileName(opts.profile);
331
- const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
332
- const { policies, plugins, severityRemap } = resolvePolicyOptions(effective);
487
+ const cliOverrides = stripUnsetCliOverrides(opts, command);
488
+ const effective = resolveEffectiveOptions(config, profile, cliOverrides);
489
+ const { policies, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
333
490
  const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
334
491
  if (targets.length === 0) {
335
492
  throw new Error('No files matched. Provide files or configure .flectorc files/include.');
@@ -343,10 +500,12 @@ program
343
500
  validateMode(mode);
344
501
  const maskSecrets = Boolean(effective.maskSecrets);
345
502
  const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
503
+ const webhookFormat = resolveWebhookFormat(effective.webhookFormat, effective.webhook);
346
504
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
347
505
 
348
506
  if (effective.snapshot) {
349
507
  mkdirSync(SNAPSHOT_DIR, { recursive: true });
508
+ const idsWithHistory = snapshotIdsWithHistory();
350
509
  let written = 0;
351
510
  for (const filepath of targets) {
352
511
  if (!existsSync(filepath)) {
@@ -359,10 +518,21 @@ program
359
518
  }
360
519
  const state = parseFile(filepath);
361
520
  const snapshotPath = snapshotPathForFile(filepath);
362
- preserveLegacySnapshotForHistory(filepath, snapshotPath);
363
- const snapshot = { file: filepath, state, createdAt: new Date().toISOString() };
521
+ preserveLegacySnapshotForHistory(filepath, snapshotPath, idsWithHistory);
522
+ // Only a multi-document file records `documents`, so an ordinary
523
+ // snapshot is byte-for-byte what it was before this field existed.
524
+ const documents = documentKeysOf(state) ?? [];
525
+ const snapshot = {
526
+ file: filepath,
527
+ state,
528
+ ...(documents.length > 0 ? { documents: [...documents] } : {}),
529
+ createdAt: new Date().toISOString(),
530
+ };
364
531
  writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf8');
365
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));
366
536
  console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
367
537
  written += 1;
368
538
  }
@@ -393,6 +563,33 @@ program
393
563
  }
394
564
 
395
565
  const watchers = [];
566
+ let closing = false;
567
+ const closeAll = async (exitCode) => {
568
+ if (closing) return;
569
+ closing = true;
570
+ await Promise.all(watchers.map((w) => w.close()));
571
+ if (exitCode === 0) {
572
+ console.log(chalk.dim('\nflecto stopped.'));
573
+ }
574
+ process.exit(exitCode);
575
+ };
576
+ const alertOptions = {
577
+ command: effective.command,
578
+ webhook: effective.webhook,
579
+ webhookHeaders,
580
+ webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
581
+ webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
582
+ webhookFormat,
583
+ deliveryMode: effective.deliveryMode,
584
+ onAlertFailure: effective.onAlertFailure,
585
+ };
586
+ const deliverAlert = async (envelope) => {
587
+ const result = await fireAlerts(alertOptions, envelope);
588
+ if (!result.ok && effective.onAlertFailure === 'exit') {
589
+ await closeAll(1);
590
+ }
591
+ };
592
+
396
593
  for (const filepath of targets) {
397
594
  if (!existsSync(filepath)) {
398
595
  renderWarn(`Skipping missing file: ${filepath}`);
@@ -425,24 +622,20 @@ program
425
622
  renderError(`policy evaluation failed: ${err.message}`);
426
623
  process.exit(1);
427
624
  }
428
- renderPolicyFindings(policyFindings);
625
+ renderPolicyFindings(maybeMaskFindings(policyFindings, event.events, maskSecrets));
429
626
  if (effective.command || effective.webhook) {
430
627
  const outboundChanges = maybeMaskChanges(event.events, maskSecretsWebhooks);
431
628
  const envelope = createEnvelope({
432
629
  source: 'watch',
433
630
  file: event.filepath,
434
631
  changes: outboundChanges,
435
- policies: policyFindings,
632
+ policies: maybeMaskFindings(
633
+ policyFindings,
634
+ event.events,
635
+ maskSecretsWebhooks,
636
+ ),
436
637
  });
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);
638
+ await deliverAlert(envelope);
446
639
  }
447
640
  } else {
448
641
  renderInfo(`[lifecycle] ${event.filepath}: ${event.lifecycle.type} - ${event.lifecycle.message}`);
@@ -452,15 +645,7 @@ program
452
645
  file: event.filepath,
453
646
  lifecycle: event.lifecycle,
454
647
  });
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);
648
+ await deliverAlert(envelope);
464
649
  }
465
650
  }
466
651
  }
@@ -473,13 +658,6 @@ program
473
658
  }
474
659
  renderInfo('Press Ctrl+C to stop.\n');
475
660
 
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
661
  process.on('SIGINT', () => void closeAll(0));
484
662
  process.on('SIGTERM', () => void closeAll(0));
485
663
  } catch (err) {
@@ -505,7 +683,8 @@ program
505
683
 
506
684
  const { config } = loadRcConfig(process.cwd());
507
685
  const profile = resolveProfileName(opts.profile);
508
- const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
686
+ const cliOverrides = stripUnsetCliOverrides(opts, command);
687
+ const effective = resolveEffectiveOptions(config, profile, cliOverrides);
509
688
  const ignorePaths = parseCsv(effective.ignore);
510
689
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
511
690
 
@@ -538,12 +717,105 @@ program
538
717
  }
539
718
  });
540
719
 
720
+ program
721
+ .command('report [files...]')
722
+ .description('Render local snapshot history as a self-contained HTML report')
723
+ .option('-o, --output <path>', 'Write the report to this path', 'flecto-report.html')
724
+ .option('-l, --limit <n>', 'Number of recent snapshots to include', '10')
725
+ .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
726
+ .option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
727
+ .option('--policies <ids>', 'Comma-separated policy pack ids (default: default)')
728
+ .option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
729
+ .option('--array-id-key <key>', 'Diff arrays by this object identity key')
730
+ .option('--no-array-id', 'Diff arrays by index instead of object identity')
731
+ .option('--array-ignore-order', 'Treat array order as insignificant', false)
732
+ .option('--mask-secrets', 'Mask secret-like values in the report', false)
733
+ .action(async (files, opts, command) => {
734
+ try {
735
+ const { config } = loadRcConfig(process.cwd());
736
+ const profile = resolveProfileName(opts.profile);
737
+ const cliOverrides = stripUnsetCliOverrides(opts, command);
738
+ const effective = resolveEffectiveOptions(config, profile, cliOverrides);
739
+ const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
740
+
741
+ const limit = Number.parseInt(String(effective.limit ?? '10'), 10);
742
+ if (!Number.isInteger(limit) || limit < 1) {
743
+ throw new Error('--limit must be a positive integer');
744
+ }
745
+ const ignorePaths = parseCsv(effective.ignore);
746
+ const dOpts = diffOptionsFromEffective(effective, ignorePaths);
747
+ const maskSecrets = Boolean(effective.maskSecrets);
748
+ const outputPath = resolve(String(effective.output ?? 'flecto-report.html'));
749
+
750
+ // Same snapshot source, filtering, and errors as `flecto history` — this
751
+ // command only changes how that history is rendered.
752
+ const allSnapshots = readLocalSnapshotHistory();
753
+ let snapshots = allSnapshots;
754
+ if (files.length > 0) {
755
+ const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
756
+ snapshots = snapshots.filter((snapshot) => targets.has(resolve(snapshot.file)));
757
+ }
758
+
759
+ const summaries = summarizeSnapshotHistory(snapshots, limit, dOpts);
760
+ if (summaries.length === 0) {
761
+ if (files.length > 0 && allSnapshots.length > 0) {
762
+ throw new Error(
763
+ 'No local snapshots matched the given files. Omit files to report on all saved snapshot history.',
764
+ );
765
+ }
766
+ throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
767
+ }
768
+
769
+ const reportSnapshots = [];
770
+ for (const summary of summaries) {
771
+ // Policies run on the unmasked events: masking first would hide the
772
+ // very values the secret rules match on. Redaction happens after, on
773
+ // everything that reaches the page.
774
+ const findings = await evaluatePolicies(summary.changes, {
775
+ cwd: process.cwd(),
776
+ file: summary.file,
777
+ profile: profile ?? null,
778
+ source: 'diff',
779
+ policies: packIds,
780
+ plugins,
781
+ severityRemap,
782
+ });
783
+ reportSnapshots.push({
784
+ file: summary.file,
785
+ createdAt: summary.createdAt,
786
+ previousCreatedAt: summary.previousCreatedAt,
787
+ changeCount: summary.changeCount,
788
+ changes: maybeMaskChanges(summary.changes, maskSecrets),
789
+ policies: maybeMaskFindings(findings, summary.changes, maskSecrets),
790
+ });
791
+ }
792
+
793
+ const html = renderReportHtml({
794
+ snapshots: reportSnapshots,
795
+ generatedAt: new Date().toISOString(),
796
+ cwd: process.cwd(),
797
+ version: PKG.version,
798
+ limit,
799
+ maskSecrets,
800
+ });
801
+ mkdirSync(dirname(outputPath), { recursive: true });
802
+ writeFileSync(outputPath, html, 'utf8');
803
+ console.log(chalk.green(
804
+ `✓ Report written: ${outputPath} (${summaries.length} snapshot${summaries.length === 1 ? '' : 's'})`,
805
+ ));
806
+ } catch (err) {
807
+ renderError(err.message);
808
+ process.exit(1);
809
+ }
810
+ });
811
+
541
812
  program
542
813
  .command('ci [files...]')
543
814
  .description('Run semantic diff in CI mode')
544
815
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
545
816
  .option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
546
- .option('--format <type>', 'Output format: json | ndjson | github-annotations', 'json')
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)
547
819
  .option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
548
820
  .option('--ignore <keys>', 'Comma-separated key paths to ignore')
549
821
  .option('--policies <ids>', 'Comma-separated policy pack ids')
@@ -557,18 +829,23 @@ program
557
829
  try {
558
830
  const { config } = loadRcConfig(process.cwd());
559
831
  const profile = resolveProfileName(opts.profile);
560
- const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
561
- const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective);
832
+ const cliOverrides = stripUnsetCliOverrides(opts, command);
833
+ const effective = resolveEffectiveOptions(config, profile, cliOverrides);
834
+ const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
562
835
  const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
563
836
  if (targets.length === 0) {
564
837
  throw new Error('No files matched. Provide files or configure .flectorc files/include.');
565
838
  }
566
839
 
567
840
  const ignorePaths = parseCsv(effective.ignore);
568
- const failOn = new Set(parseCsv(effective.failOn ?? 'changed,policy,error'));
841
+ const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
569
842
  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');
843
+ if (!['json', 'ndjson', 'github-annotations', 'pr-comment'].includes(format)) {
844
+ throw new Error('--format must be json, ndjson, github-annotations, or pr-comment');
845
+ }
846
+ const prCommentPost = Boolean(effective.prCommentPost);
847
+ if (prCommentPost && format !== 'pr-comment') {
848
+ renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
572
849
  }
573
850
  const maskSecrets = Boolean(effective.maskSecrets);
574
851
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
@@ -608,13 +885,14 @@ program
608
885
  severityRemap,
609
886
  });
610
887
  const outboundChanges = maybeMaskChanges(events, maskSecrets);
888
+ const outboundFindings = maybeMaskFindings(policyFindings, events, maskSecrets);
611
889
  const envelope = createEnvelope({
612
890
  source: 'ci',
613
891
  file: filepath,
614
892
  changes: outboundChanges,
615
- policies: policyFindings,
893
+ policies: outboundFindings,
616
894
  });
617
- results.push({ file: filepath, envelope, policies: policyFindings });
895
+ results.push({ file: filepath, envelope, policies: outboundFindings });
618
896
  diffed += 1;
619
897
 
620
898
  if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
@@ -629,7 +907,206 @@ program
629
907
  );
630
908
  }
631
909
 
632
- printCiOutput(results, format);
910
+ if (format === 'pr-comment') {
911
+ const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
912
+ console.log(body);
913
+ await deliverPrCommentSafely(body, prCommentPost);
914
+ } else {
915
+ printCiOutput(results, format);
916
+ }
917
+ process.exit(shouldFail ? 1 : 0);
918
+ } catch (err) {
919
+ renderError(err.message);
920
+ process.exit(1);
921
+ }
922
+ });
923
+
924
+ program
925
+ .command('plan <planFiles...>')
926
+ .description('Diff Terraform plan JSON (terraform show -json) and run policies on it')
927
+ .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
928
+ .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)
930
+ .option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', PLAN_DEFAULT_FAIL_ON)
931
+ .option('--ignore <keys>', 'Comma-separated key paths to ignore, e.g. "**.tags_all,**.#action"')
932
+ .option('--policies <ids>', `Comma-separated policy pack ids (default: ${PLAN_DEFAULT_POLICIES})`)
933
+ .option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
934
+ .option('--mask-secrets', 'Also mask Flecto-detected secret-like values (Terraform-sensitive values are always redacted)', false)
935
+ .action(async (planFiles, opts, command) => {
936
+ try {
937
+ const { config } = loadRcConfig(process.cwd());
938
+ const profile = resolveProfileName(opts.profile);
939
+ const cliOverrides = stripUnsetCliOverrides(opts, command);
940
+ const effective = resolveEffectiveOptions(config, profile, cliOverrides);
941
+ // A plan carries Terraform-shaped paths, so the config-file packs are not
942
+ // the useful default here; `terraform` is. An explicit --policies or a
943
+ // .flectorc entry still wins.
944
+ const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(
945
+ effective.policies === undefined
946
+ ? { ...effective, policies: PLAN_DEFAULT_POLICIES }
947
+ : effective,
948
+ { pluginsFromCli: cliOverrides.plugins !== undefined },
949
+ );
950
+
951
+ const ignorePaths = parseCsv(effective.ignore);
952
+ const failOn = new Set(parseCsv(effective.failOn ?? PLAN_DEFAULT_FAIL_ON));
953
+ const format = String(effective.format ?? 'human');
954
+ if (!['human', 'json', 'ndjson', 'github-annotations', 'pr-comment'].includes(format)) {
955
+ throw new Error('--format must be human, json, ndjson, github-annotations, or pr-comment');
956
+ }
957
+ const prCommentPost = Boolean(effective.prCommentPost);
958
+ if (prCommentPost && format !== 'pr-comment') {
959
+ renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
960
+ }
961
+ const maskSecrets = Boolean(effective.maskSecrets);
962
+
963
+ /** @type {any[]} */
964
+ const results = [];
965
+ let shouldFail = false;
966
+
967
+ for (const planFile of planFiles) {
968
+ const filepath = resolve(planFile);
969
+ if (!existsSync(filepath)) {
970
+ throw new Error(`File not found: ${filepath}`);
971
+ }
972
+ const plan = readTerraformPlanFile(filepath);
973
+ const { changes, summary, formatVersion, terraformVersion, warnings } =
974
+ diffTerraformPlan(plan, { ignorePaths });
975
+ for (const warning of warnings) renderWarn(warning);
976
+
977
+ // Terraform-sensitive values were already replaced during conversion,
978
+ // so policies never see them. --mask-secrets adds Flecto's own
979
+ // value-shaped detection on top, for credentials Terraform did not mark.
980
+ const policyFindings = await evaluatePolicies(changes, {
981
+ cwd: process.cwd(),
982
+ file: filepath,
983
+ profile: profile ?? null,
984
+ source: 'ci',
985
+ policies: packIds,
986
+ plugins,
987
+ severityRemap,
988
+ });
989
+ const outboundChanges = maybeMaskChanges(changes, maskSecrets);
990
+ const envelope = createEnvelope({
991
+ source: 'ci',
992
+ file: filepath,
993
+ changes: outboundChanges,
994
+ policies: maybeMaskFindings(policyFindings, maskSecrets),
995
+ });
996
+ results.push({ file: filepath, envelope, policies: envelope.policies });
997
+
998
+ if (format === 'human') {
999
+ const version = [
1000
+ formatVersion ? `plan format ${formatVersion}` : null,
1001
+ terraformVersion ? `terraform ${terraformVersion}` : null,
1002
+ ].filter(Boolean).join(', ');
1003
+ renderInfo(`${filepath}${version ? ` — ${version}` : ''}`);
1004
+ renderInfo(formatPlanSummary(summary));
1005
+ renderDiff(filepath, outboundChanges, { maskSecrets, baseline: 'the current state' });
1006
+ renderPolicyFindings(envelope.policies);
1007
+ }
1008
+
1009
+ if (shouldFailFromChanges(changes, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
1010
+ shouldFail = true;
1011
+ }
1012
+ }
1013
+
1014
+ if (format === 'pr-comment') {
1015
+ const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
1016
+ console.log(body);
1017
+ await deliverPrCommentSafely(body, prCommentPost);
1018
+ } else if (format !== 'human') {
1019
+ printCiOutput(results, format);
1020
+ }
1021
+ process.exit(shouldFail ? 1 : 0);
1022
+ } catch (err) {
1023
+ renderError(err.message);
1024
+ process.exit(1);
1025
+ }
1026
+ });
1027
+
1028
+ program
1029
+ .command('compare <fileA> <fileB>')
1030
+ .description('Diff two config files against each other (fileA is the baseline)')
1031
+ .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
1032
+ .option('--format <type>', 'Output format: human | json | ndjson | github-annotations', 'human')
1033
+ .option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,added,removed,policy,error')
1034
+ .option('--ignore <keys>', 'Comma-separated key paths to ignore')
1035
+ .option('--policies <ids>', 'Comma-separated policy pack ids')
1036
+ .option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
1037
+ .option('--array-id-key <key>', 'Diff arrays by this object identity key')
1038
+ .option('--no-array-id', 'Diff arrays by index instead of object identity')
1039
+ .option('--array-ignore-order', 'Treat array order as insignificant', false)
1040
+ .option('--mask-secrets', 'Mask secret-like values in output', false)
1041
+ .action(async (fileA, fileB, opts, command) => {
1042
+ try {
1043
+ const { config } = loadRcConfig(process.cwd());
1044
+ const profile = resolveProfileName(opts.profile);
1045
+ const cliOverrides = stripUnsetCliOverrides(opts, command);
1046
+ const effective = resolveEffectiveOptions(config, profile, cliOverrides);
1047
+ const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
1048
+
1049
+ const ignorePaths = parseCsv(effective.ignore);
1050
+ const failOn = parseFailOn(effective.failOn ?? 'changed,added,removed,policy,error');
1051
+ const format = String(effective.format ?? 'human');
1052
+ if (!['human', 'json', 'ndjson', 'github-annotations'].includes(format)) {
1053
+ throw new Error('--format must be human, json, ndjson, or github-annotations');
1054
+ }
1055
+ const maskSecrets = Boolean(effective.maskSecrets);
1056
+ const dOpts = diffOptionsFromEffective(effective, ignorePaths);
1057
+
1058
+ const baselinePath = resolve(fileA);
1059
+ const targetPath = resolve(fileB);
1060
+ // Both sides are named explicitly, so a missing one is an error rather
1061
+ // than the skip-and-warn `ci` applies to expanded globs.
1062
+ for (const filepath of [baselinePath, targetPath]) {
1063
+ if (!existsSync(filepath)) {
1064
+ throw new Error(`File not found: ${filepath}`);
1065
+ }
1066
+ }
1067
+
1068
+ // fileA is the baseline: "removed" is present only in fileA, "added" only
1069
+ // in fileB. Every format parses to a plain tree, so the two sides need not
1070
+ // share one — parseFile rejects unsupported extensions with the same
1071
+ // message every other command uses.
1072
+ const before = parseFile(baselinePath);
1073
+ const after = parseFile(targetPath);
1074
+ const events = diffTrees(before, after, dOpts);
1075
+ const policyFindings = await evaluatePolicies(events, {
1076
+ cwd: process.cwd(),
1077
+ file: targetPath,
1078
+ profile: profile ?? null,
1079
+ source: 'diff',
1080
+ policies: packIds,
1081
+ plugins,
1082
+ severityRemap,
1083
+ });
1084
+ const outboundFindings = maybeMaskFindings(policyFindings, events, maskSecrets);
1085
+
1086
+ if (format === 'human') {
1087
+ if (events.length > 0) {
1088
+ renderInfo('"+" exists only in the compared file, "-" only in the baseline, "~" differs');
1089
+ }
1090
+ renderDiff(targetPath, events, { maskSecrets, baseline: baselinePath });
1091
+ renderPolicyFindings(outboundFindings);
1092
+ } else {
1093
+ const envelope = createEnvelope({
1094
+ source: 'diff',
1095
+ file: targetPath,
1096
+ changes: maybeMaskChanges(events, maskSecrets),
1097
+ policies: outboundFindings,
1098
+ });
1099
+ // Same envelope and printer as `ci`, so machine consumers see one shape.
1100
+ // `baseline` rides on the result wrapper rather than the envelope, which
1101
+ // is closed by schemas/flecto-envelope-2.0.json.
1102
+ printCiOutput(
1103
+ [{ file: targetPath, baseline: baselinePath, envelope, policies: outboundFindings }],
1104
+ format,
1105
+ );
1106
+ }
1107
+
1108
+ const shouldFail = shouldFailFromChanges(events, failOn)
1109
+ || shouldFailFromPolicy(policyFindings, failOn);
633
1110
  process.exit(shouldFail ? 1 : 0);
634
1111
  } catch (err) {
635
1112
  renderError(err.message);
@@ -671,10 +1148,10 @@ program
671
1148
  }
672
1149
 
673
1150
  console.log('Resolution order: policies/<id>.json, .yaml, .yml, then built-in packs.');
674
- console.log('id\tsource path\trules\toverrides builtin');
1151
+ console.log('id\tsource path\trules\toverrides builtin\tpackage');
675
1152
  for (const pack of packs) {
676
1153
  console.log(
677
- `${pack.id}\t${pack.sourcePath}\t${pack.ruleCount}\t${pack.overridesBuiltin ? 'yes' : 'no'}`,
1154
+ `${pack.id}\t${pack.sourcePath}\t${pack.ruleCount}\t${pack.overridesBuiltin ? 'yes' : 'no'}\t${pack.package ?? '-'}`,
678
1155
  );
679
1156
  }
680
1157
  } catch (err) {
@@ -682,14 +1159,62 @@ program
682
1159
  process.exit(1);
683
1160
  }
684
1161
  });
1162
+
1163
+ policies
1164
+ .command('add <name>')
1165
+ .description('Install a policy pack from an installed flecto-pack-* npm package')
1166
+ .option('--force', 'Overwrite an existing local pack with the same id')
1167
+ .action((name, opts) => {
1168
+ try {
1169
+ const added = addPolicyPackFromPackage(name, {
1170
+ cwd: process.cwd(),
1171
+ force: Boolean(opts.force),
1172
+ });
1173
+ const version = added.packageVersion ? `@${added.packageVersion}` : '';
1174
+ const verb = added.overwritten ? 'Updated' : 'Added';
1175
+ renderInfo(
1176
+ `${verb} policy pack "${added.id}" from ${added.packageName}${version} `
1177
+ + `→ ${relative(process.cwd(), added.targetPath)} `
1178
+ + `(${added.ruleCount} rule${added.ruleCount === 1 ? '' : 's'})`,
1179
+ );
1180
+ if (added.overridesBuiltin) {
1181
+ renderWarn(`Pack "${added.id}" now overrides the built-in pack of the same id.`);
1182
+ }
1183
+ for (const path of added.shadowed) {
1184
+ renderWarn(`${relative(process.cwd(), path)} is no longer used: ${added.id}.json wins.`);
1185
+ }
1186
+ if (added.shipsCode) {
1187
+ renderInfo(
1188
+ `${added.packageName} also ships JavaScript. It was ignored: only the declarative `
1189
+ + 'pack file is read, and no package code is ever imported or run.',
1190
+ );
1191
+ }
1192
+ renderInfo(`Activate it with: flecto ci <files> --policies ${added.id}`);
1193
+ } catch (err) {
1194
+ renderError(err.message);
1195
+ process.exit(1);
1196
+ }
1197
+ });
685
1198
  }
686
1199
 
687
1200
  program
688
1201
  .command('init')
689
- .description('Create starter .flectorc configuration')
1202
+ .description('Create starter .flectorc configuration from detected stack signals')
690
1203
  .action(() => {
691
- const path = initRcFile(process.cwd());
1204
+ const { path, created, detection } = initRcFile(process.cwd());
1205
+ if (!created) {
1206
+ renderWarn(`Config already exists: ${path} (left unchanged)`);
1207
+ return;
1208
+ }
692
1209
  renderInfo(`Initialized config: ${path}`);
1210
+ if (detection.signals.length === 0) {
1211
+ renderInfo('No stack signals detected — wrote the generic starter config.');
1212
+ return;
1213
+ }
1214
+ for (const signal of detection.signals) {
1215
+ renderInfo(signal.summary);
1216
+ }
1217
+ renderInfo(`Policy packs: ${detection.packs.join(', ')}`);
693
1218
  });
694
1219
 
695
1220
  program