flecto 2.0.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 } 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,38 @@ 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 } from './src/policy.js';
37
+ import {
38
+ evaluatePolicies,
39
+ highestSeverity,
40
+ listPolicyPacks,
41
+ addPolicyPackFromPackage,
42
+ } from './src/policy.js';
43
+ import { testPolicyFixture } from './src/policy-test.js';
26
44
  import {
27
45
  loadRcConfig,
28
46
  resolveEffectiveOptions,
@@ -37,6 +55,17 @@ const PKG = JSON.parse(
37
55
  );
38
56
 
39
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';
40
69
 
41
70
  function snapshotIdForPath(absPath) {
42
71
  const normalized = absPath.replaceAll('\\', '/');
@@ -48,12 +77,129 @@ function snapshotPathForFile(absPath) {
48
77
  return resolve(`${SNAPSHOT_DIR}/${id}.json`);
49
78
  }
50
79
 
80
+ function snapshotHistoryPathForFile(absPath) {
81
+ const id = snapshotIdForPath(absPath);
82
+ let timestamp = Date.now();
83
+ let path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
84
+ while (existsSync(path)) {
85
+ timestamp += 1;
86
+ path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
87
+ }
88
+ return path;
89
+ }
90
+
91
+ /**
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;
108
+ }
109
+
110
+ function preserveLegacySnapshotForHistory(absPath, snapshotPath, idsWithHistory) {
111
+ if (!existsSync(snapshotPath) || idsWithHistory.has(snapshotIdForPath(absPath))) return;
112
+
113
+ const legacy = JSON.parse(readFileSync(snapshotPath, 'utf8'));
114
+ writeFileSync(
115
+ snapshotHistoryPathForFile(absPath),
116
+ JSON.stringify({
117
+ file: legacy.file ?? absPath,
118
+ state: legacy.state ?? legacy,
119
+ ...(Array.isArray(legacy.documents) ? { documents: legacy.documents } : {}),
120
+ createdAt: legacy.createdAt ?? statSync(snapshotPath).mtime.toISOString(),
121
+ }, null, 2),
122
+ 'utf8',
123
+ );
124
+ }
125
+
126
+ function readLocalSnapshotHistory() {
127
+ if (!existsSync(SNAPSHOT_DIR)) return [];
128
+
129
+ const entries = readdirSync(SNAPSHOT_DIR, { withFileTypes: true })
130
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
131
+ const historyEntries = entries.filter((entry) => /^[a-f0-9]{16}\.\d+\.json$/.test(entry.name));
132
+ const historyIds = new Set(historyEntries.map((entry) => entry.name.slice(0, 16)));
133
+ const legacyEntries = entries.filter((entry) =>
134
+ /^[a-f0-9]{16}\.json$/.test(entry.name) && !historyIds.has(entry.name.slice(0, 16)));
135
+ const snapshotEntries = [...historyEntries, ...legacyEntries];
136
+
137
+ return snapshotEntries.map((entry) => {
138
+ const path = resolve(SNAPSHOT_DIR, entry.name);
139
+ const snapshot = JSON.parse(readFileSync(path, 'utf8'));
140
+ const state = restoreSnapshotDocumentKeys(snapshot?.state ?? snapshot, snapshot);
141
+ if (typeof snapshot?.file !== 'string') {
142
+ throw new Error(`Invalid snapshot file: ${path}`);
143
+ }
144
+ return {
145
+ file: snapshot.file,
146
+ state,
147
+ createdAt: snapshot.createdAt ?? statSync(path).mtime.toISOString(),
148
+ };
149
+ });
150
+ }
151
+
152
+ function summarizeSnapshotHistory(snapshots, limit, diffOpts = {}) {
153
+ const byFile = new Map();
154
+ for (const snapshot of snapshots) {
155
+ const records = byFile.get(snapshot.file) ?? [];
156
+ records.push(snapshot);
157
+ byFile.set(snapshot.file, records);
158
+ }
159
+
160
+ const summaries = [];
161
+ for (const records of byFile.values()) {
162
+ records.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
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
+ : [];
171
+ summaries.push({
172
+ ...records[index],
173
+ previousCreatedAt: previous ? previous.createdAt : null,
174
+ changes,
175
+ changeCount: changes.length,
176
+ });
177
+ }
178
+ }
179
+
180
+ return summaries
181
+ .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
182
+ .slice(0, limit);
183
+ }
184
+
51
185
  function parseCsv(value) {
52
186
  if (!value) return [];
53
187
  if (Array.isArray(value)) return value;
54
188
  return String(value).split(',').map((s) => s.trim()).filter(Boolean);
55
189
  }
56
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
+
57
203
  function parseHeaders(headerList) {
58
204
  const webhookHeaders = {};
59
205
  if (!Array.isArray(headerList)) return webhookHeaders;
@@ -80,30 +226,20 @@ function validateInterval(interval) {
80
226
  }
81
227
  }
82
228
 
83
- function stripUnsetCliOverrides(opts) {
84
- const out = { ...opts };
85
- // Don't let Commander defaults wipe .flectorc values for optional features
86
- for (const key of [
87
- 'policies',
88
- 'plugins',
89
- 'arrayIdKey',
90
- 'maskSecrets',
91
- 'maskSecretsWebhooks',
92
- 'arrayIgnoreOrder',
93
- 'snapshotRef',
94
- 'ignore',
95
- ]) {
96
- if (out[key] === undefined || out[key] === false || out[key] === null || out[key] === '') {
97
- delete out[key];
98
- }
99
- }
100
- return out;
229
+ function stripUnsetCliOverrides(opts, command) {
230
+ return Object.fromEntries(
231
+ Object.entries(opts).filter(([key]) => command.getOptionValueSource(key) === 'cli'),
232
+ );
101
233
  }
102
234
 
103
235
  function diffOptionsFromEffective(effective, ignorePaths) {
236
+ const arrayIdKey = effective.arrayIdKey || null;
104
237
  return {
105
238
  ignorePaths,
106
- arrayIdKey: effective.arrayIdKey || null,
239
+ arrayIdKey,
240
+ // Explicit --array-id-key / arrayIdKey enables identity matching even when
241
+ // .flectorc sets arrayId:false (index escape hatch for auto-detect only).
242
+ arrayIdentity: arrayIdKey ? true : effective.arrayId !== false,
107
243
  arrayIgnoreOrder: Boolean(effective.arrayIgnoreOrder),
108
244
  };
109
245
  }
@@ -113,6 +249,32 @@ function maybeMaskChanges(events, maskSecrets) {
113
249
  return events.map(maskChangeEvent);
114
250
  }
115
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
+
116
278
  async function resolveTargetFiles(cliFiles, rcConfig) {
117
279
  if (cliFiles && cliFiles.length > 0) {
118
280
  const direct = [];
@@ -137,14 +299,62 @@ async function resolveTargetFiles(cliFiles, rcConfig) {
137
299
 
138
300
  return resolveFiles({
139
301
  cwd: process.cwd(),
140
- files: rcConfig?.files ?? rcConfig?.include ?? [],
302
+ files: rcConfig?.files ?? [],
303
+ include: rcConfig?.include ?? [],
141
304
  exclude: rcConfig?.exclude ?? [],
142
305
  });
143
306
  }
144
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
+
145
323
  function readSnapshotStateFromFile(snapshotPath) {
146
324
  const snap = JSON.parse(readFileSync(snapshotPath, 'utf8'));
147
- 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
+ }
148
358
  }
149
359
 
150
360
  function readSnapshotStateFromRef(filePath, snapshotRef) {
@@ -154,7 +364,7 @@ function readSnapshotStateFromRef(filePath, snapshotRef) {
154
364
  return readSnapshotStateFromFile(maybePath);
155
365
  }
156
366
 
157
- const rel = relative(process.cwd(), filePath).replaceAll('\\', '/');
367
+ const rel = gitRepoRelativePath(filePath);
158
368
  const raw = execFileSync('git', ['show', `${snapshotRef}:${rel}`], { encoding: 'utf8' });
159
369
  return parseContent(filePath, raw);
160
370
  }
@@ -174,6 +384,19 @@ function shouldFailFromChanges(events, failOn) {
174
384
  return false;
175
385
  }
176
386
 
387
+ function escapeWorkflowCommandData(value) {
388
+ return String(value)
389
+ .replaceAll('%', '%25')
390
+ .replaceAll('\r', '%0D')
391
+ .replaceAll('\n', '%0A');
392
+ }
393
+
394
+ function escapeWorkflowCommandProperty(value) {
395
+ return escapeWorkflowCommandData(value)
396
+ .replaceAll(':', '%3A')
397
+ .replaceAll(',', '%2C');
398
+ }
399
+
177
400
  function printCiOutput(results, format) {
178
401
  if (format === 'json') {
179
402
  console.log(JSON.stringify(results, null, 2));
@@ -190,18 +413,39 @@ function printCiOutput(results, format) {
190
413
  for (const event of result.envelope.changes) {
191
414
  const title = `flecto ${event.type}`;
192
415
  const detail = event.note ? `${event.path} (${event.note})` : event.path;
193
- console.log(`::warning file=${result.file},title=${title}::${detail}`);
416
+ console.log(`::warning file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
194
417
  }
195
418
  for (const finding of result.policies) {
196
419
  const level = finding.severity === 'error' ? 'error' : 'warning';
197
420
  const pack = finding.pack ? ` [${finding.pack}]` : '';
198
421
  const title = `flecto policy ${finding.id}${pack}`;
199
- console.log(`::${level} file=${result.file},title=${title}::${finding.path}: ${finding.message}`);
422
+ const detail = `${finding.path}: ${finding.message}`;
423
+ console.log(`::${level} file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
200
424
  }
201
425
  }
202
426
  }
203
427
  }
204
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
+
205
449
  program
206
450
  .name('flecto')
207
451
  .description('Flecto — semantic config watcher for meaningful structured file changes')
@@ -220,6 +464,7 @@ program
220
464
  acc.push(v);
221
465
  return acc;
222
466
  }, [])
467
+ .option('--webhook-format <service>', `Webhook payload format: ${WEBHOOK_FORMAT_CHOICES.join(' | ')}`)
223
468
  .option('--delivery-mode <mode>', 'Alert delivery mode: best-effort | at-least-once', 'best-effort')
224
469
  .option('--on-alert-failure <mode>', 'Alert failure behavior: warn | exit | retry', 'warn')
225
470
  .option('--webhook-timeout <ms>', 'Webhook timeout in ms', '5000')
@@ -227,18 +472,20 @@ program
227
472
  .option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
228
473
  .option('--policies <ids>', 'Comma-separated policy pack ids (default: default)')
229
474
  .option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
230
- .option('--array-id-key <key>', 'Diff arrays by this object identity key (opt-in)')
475
+ .option('--array-id-key <key>', 'Diff arrays by this object identity key')
476
+ .option('--no-array-id', 'Diff arrays by index instead of object identity')
231
477
  .option('--array-ignore-order', 'Treat array order as insignificant', false)
232
478
  .option('--mask-secrets', 'Mask secret-like values in human output', false)
233
479
  .option('--mask-secrets-webhooks', 'Also mask secrets in webhook payloads', false)
234
480
  .option('--snapshot', 'Save current state as baseline instead of watching')
235
481
  .option('--diff', 'Diff current file against saved baseline and exit')
236
- .action(async (files, opts) => {
482
+ .option('--allow-empty', 'Allow --snapshot to succeed when nothing was written', false)
483
+ .action(async (files, opts, command) => {
237
484
  try {
238
485
  const { config } = loadRcConfig(process.cwd());
239
486
  const profile = resolveProfileName(opts.profile);
240
- const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts));
241
- const { policies, plugins } = resolvePolicyOptions(effective);
487
+ const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
488
+ const { policies, plugins, severityRemap } = resolvePolicyOptions(effective);
242
489
  const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
243
490
  if (targets.length === 0) {
244
491
  throw new Error('No files matched. Provide files or configure .flectorc files/include.');
@@ -252,16 +499,47 @@ program
252
499
  validateMode(mode);
253
500
  const maskSecrets = Boolean(effective.maskSecrets);
254
501
  const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
502
+ const webhookFormat = resolveWebhookFormat(effective.webhookFormat, effective.webhook);
255
503
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
256
504
 
257
505
  if (effective.snapshot) {
258
506
  mkdirSync(SNAPSHOT_DIR, { recursive: true });
507
+ const idsWithHistory = snapshotIdsWithHistory();
508
+ let written = 0;
259
509
  for (const filepath of targets) {
260
- if (!existsSync(filepath) || !isSupported(filepath)) continue;
510
+ if (!existsSync(filepath)) {
511
+ renderWarn(`Skipping missing file: ${filepath}`);
512
+ continue;
513
+ }
514
+ if (!isSupported(filepath)) {
515
+ renderWarn(`Skipping unsupported file: ${filepath}`);
516
+ continue;
517
+ }
261
518
  const state = parseFile(filepath);
262
519
  const snapshotPath = snapshotPathForFile(filepath);
263
- writeFileSync(snapshotPath, JSON.stringify({ file: filepath, state }, null, 2), 'utf8');
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
+ };
530
+ writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf8');
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));
264
535
  console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
536
+ written += 1;
537
+ }
538
+ if (written === 0 && !effective.allowEmpty) {
539
+ throw new Error(
540
+ 'No snapshots written — all targets were missing or unsupported.' +
541
+ ' Pass --allow-empty to allow an empty snapshot run.',
542
+ );
265
543
  }
266
544
  return;
267
545
  }
@@ -284,6 +562,33 @@ program
284
562
  }
285
563
 
286
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
+
287
592
  for (const filepath of targets) {
288
593
  if (!existsSync(filepath)) {
289
594
  renderWarn(`Skipping missing file: ${filepath}`);
@@ -310,29 +615,26 @@ program
310
615
  source: 'watch',
311
616
  policies,
312
617
  plugins,
618
+ severityRemap,
313
619
  });
314
620
  } catch (err) {
315
621
  renderError(`policy evaluation failed: ${err.message}`);
316
- if (String(effective.onAlertFailure) === 'exit') process.exitCode = 1;
622
+ process.exit(1);
317
623
  }
318
- renderPolicyFindings(policyFindings);
624
+ renderPolicyFindings(maybeMaskFindings(policyFindings, event.events, maskSecrets));
319
625
  if (effective.command || effective.webhook) {
320
626
  const outboundChanges = maybeMaskChanges(event.events, maskSecretsWebhooks);
321
627
  const envelope = createEnvelope({
322
628
  source: 'watch',
323
629
  file: event.filepath,
324
630
  changes: outboundChanges,
325
- policies: policyFindings,
631
+ policies: maybeMaskFindings(
632
+ policyFindings,
633
+ event.events,
634
+ maskSecretsWebhooks,
635
+ ),
326
636
  });
327
- await fireAlerts({
328
- command: effective.command,
329
- webhook: effective.webhook,
330
- webhookHeaders,
331
- webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
332
- webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
333
- deliveryMode: effective.deliveryMode,
334
- onAlertFailure: effective.onAlertFailure,
335
- }, envelope);
637
+ await deliverAlert(envelope);
336
638
  }
337
639
  } else {
338
640
  renderInfo(`[lifecycle] ${event.filepath}: ${event.lifecycle.type} - ${event.lifecycle.message}`);
@@ -342,15 +644,7 @@ program
342
644
  file: event.filepath,
343
645
  lifecycle: event.lifecycle,
344
646
  });
345
- await fireAlerts({
346
- command: effective.command,
347
- webhook: effective.webhook,
348
- webhookHeaders,
349
- webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
350
- webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
351
- deliveryMode: effective.deliveryMode,
352
- onAlertFailure: effective.onAlertFailure,
353
- }, envelope);
647
+ await deliverAlert(envelope);
354
648
  }
355
649
  }
356
650
  }
@@ -363,13 +657,6 @@ program
363
657
  }
364
658
  renderInfo('Press Ctrl+C to stop.\n');
365
659
 
366
- const closeAll = async (exitCode) => {
367
- await Promise.all(watchers.map((w) => w.close()));
368
- if (exitCode === 0) {
369
- console.log(chalk.dim('\nflecto stopped.'));
370
- }
371
- process.exit(exitCode);
372
- };
373
660
  process.on('SIGINT', () => void closeAll(0));
374
661
  process.on('SIGTERM', () => void closeAll(0));
375
662
  } catch (err) {
@@ -378,35 +665,183 @@ program
378
665
  }
379
666
  });
380
667
 
668
+ program
669
+ .command('history [files...]')
670
+ .description('Summarize drift across local snapshots')
671
+ .option('-l, --limit <n>', 'Number of recent snapshots to show', '10')
672
+ .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
673
+ .option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
674
+ .option('--array-id-key <key>', 'Diff arrays by this object identity key (opt-in)')
675
+ .option('--array-ignore-order', 'Treat array order as insignificant', false)
676
+ .action(async (files, opts, command) => {
677
+ try {
678
+ const limit = Number.parseInt(String(opts.limit), 10);
679
+ if (!Number.isInteger(limit) || limit < 1) {
680
+ throw new Error('--limit must be a positive integer');
681
+ }
682
+
683
+ const { config } = loadRcConfig(process.cwd());
684
+ const profile = resolveProfileName(opts.profile);
685
+ const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
686
+ const ignorePaths = parseCsv(effective.ignore);
687
+ const dOpts = diffOptionsFromEffective(effective, ignorePaths);
688
+
689
+ const allSnapshots = readLocalSnapshotHistory();
690
+ let snapshots = allSnapshots;
691
+ if (files.length > 0) {
692
+ const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
693
+ snapshots = snapshots.filter((snapshot) => targets.has(resolve(snapshot.file)));
694
+ }
695
+
696
+ const summaries = summarizeSnapshotHistory(snapshots, limit, dOpts);
697
+ if (summaries.length === 0) {
698
+ if (files.length > 0 && allSnapshots.length > 0) {
699
+ throw new Error(
700
+ 'No local snapshots matched the given files. Omit files to view all saved snapshot history.',
701
+ );
702
+ }
703
+ throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
704
+ }
705
+
706
+ console.log(`Local snapshot history (${summaries.length} snapshots)`);
707
+ for (const snapshot of summaries) {
708
+ const file = relative(process.cwd(), snapshot.file) || snapshot.file;
709
+ const changes = `${snapshot.changeCount} change${snapshot.changeCount === 1 ? '' : 's'}`;
710
+ console.log(`${snapshot.createdAt} ${file} — ${changes}`);
711
+ }
712
+ } catch (err) {
713
+ renderError(err.message);
714
+ process.exit(1);
715
+ }
716
+ });
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
+
381
809
  program
382
810
  .command('ci [files...]')
383
811
  .description('Run semantic diff in CI mode')
384
812
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
385
813
  .option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
386
- .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)
387
816
  .option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
388
817
  .option('--ignore <keys>', 'Comma-separated key paths to ignore')
389
818
  .option('--policies <ids>', 'Comma-separated policy pack ids')
390
819
  .option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
391
- .option('--array-id-key <key>', 'Diff arrays by this object identity key (opt-in)')
820
+ .option('--array-id-key <key>', 'Diff arrays by this object identity key')
821
+ .option('--no-array-id', 'Diff arrays by index instead of object identity')
392
822
  .option('--array-ignore-order', 'Treat array order as insignificant', false)
393
823
  .option('--mask-secrets', 'Mask secret-like values in CI output', false)
394
- .action(async (files, opts) => {
824
+ .option('--allow-empty', 'Allow CI to succeed when no files were diffed', false)
825
+ .action(async (files, opts, command) => {
395
826
  try {
396
827
  const { config } = loadRcConfig(process.cwd());
397
828
  const profile = resolveProfileName(opts.profile);
398
- const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts));
399
- const { policies: packIds, plugins } = resolvePolicyOptions(effective);
829
+ const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
830
+ const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective);
400
831
  const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
401
832
  if (targets.length === 0) {
402
833
  throw new Error('No files matched. Provide files or configure .flectorc files/include.');
403
834
  }
404
835
 
405
836
  const ignorePaths = parseCsv(effective.ignore);
406
- const failOn = new Set(parseCsv(effective.failOn));
837
+ const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
407
838
  const format = String(effective.format ?? 'json');
408
- if (!['json', 'ndjson', 'github-annotations'].includes(format)) {
409
- 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.');
410
845
  }
411
846
  const maskSecrets = Boolean(effective.maskSecrets);
412
847
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
@@ -414,9 +849,17 @@ program
414
849
  /** @type {any[]} */
415
850
  const results = [];
416
851
  let shouldFail = false;
852
+ let diffed = 0;
417
853
 
418
854
  for (const filepath of targets) {
419
- if (!existsSync(filepath) || !isSupported(filepath)) continue;
855
+ if (!existsSync(filepath)) {
856
+ renderWarn(`Skipping missing file: ${filepath}`);
857
+ continue;
858
+ }
859
+ if (!isSupported(filepath)) {
860
+ renderWarn(`Skipping unsupported file: ${filepath}`);
861
+ continue;
862
+ }
420
863
  const after = parseFile(filepath);
421
864
  let before;
422
865
  try {
@@ -435,22 +878,38 @@ program
435
878
  source: 'ci',
436
879
  policies: packIds,
437
880
  plugins,
881
+ severityRemap,
438
882
  });
439
883
  const outboundChanges = maybeMaskChanges(events, maskSecrets);
884
+ const outboundFindings = maybeMaskFindings(policyFindings, events, maskSecrets);
440
885
  const envelope = createEnvelope({
441
886
  source: 'ci',
442
887
  file: filepath,
443
888
  changes: outboundChanges,
444
- policies: policyFindings,
889
+ policies: outboundFindings,
445
890
  });
446
- results.push({ file: filepath, envelope, policies: policyFindings });
891
+ results.push({ file: filepath, envelope, policies: outboundFindings });
892
+ diffed += 1;
447
893
 
448
894
  if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
449
895
  shouldFail = true;
450
896
  }
451
897
  }
452
898
 
453
- printCiOutput(results, format);
899
+ if (diffed === 0 && !effective.allowEmpty) {
900
+ throw new Error(
901
+ 'No files were diffed — all targets were missing or unsupported.' +
902
+ ' Pass --allow-empty to allow an empty CI run.',
903
+ );
904
+ }
905
+
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
+ }
454
913
  process.exit(shouldFail ? 1 : 0);
455
914
  } catch (err) {
456
915
  renderError(err.message);
@@ -458,12 +917,297 @@ program
458
917
  }
459
918
  });
460
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);
1103
+ process.exit(shouldFail ? 1 : 0);
1104
+ } catch (err) {
1105
+ renderError(err.message);
1106
+ process.exit(1);
1107
+ }
1108
+ });
1109
+
1110
+ {
1111
+ const policies = program
1112
+ .command('policies')
1113
+ .description('Work with policy packs and plugins');
1114
+
1115
+ policies
1116
+ .command('test <fixtureDir>')
1117
+ .description('Assert policy findings from a fixture directory')
1118
+ .option('--config <name>', 'Fixture config file name', 'flecto-policy-test.json')
1119
+ .action(async (fixtureDir, opts) => {
1120
+ try {
1121
+ const result = await testPolicyFixture(fixtureDir, { configName: opts.config });
1122
+ console.log(chalk.green(
1123
+ `✓ Policy fixture passed: ${result.fixtureDir} (${result.findings.length} findings)`,
1124
+ ));
1125
+ } catch (err) {
1126
+ renderError(err.message);
1127
+ process.exitCode = 1;
1128
+ }
1129
+ });
1130
+
1131
+ policies
1132
+ .command('list')
1133
+ .description('List built-in and local policy packs')
1134
+ .option('--json', 'Output machine-readable JSON')
1135
+ .action((opts) => {
1136
+ try {
1137
+ const packs = listPolicyPacks(process.cwd());
1138
+ if (opts.json) {
1139
+ console.log(JSON.stringify(packs, null, 2));
1140
+ return;
1141
+ }
1142
+
1143
+ console.log('Resolution order: policies/<id>.json, .yaml, .yml, then built-in packs.');
1144
+ console.log('id\tsource path\trules\toverrides builtin\tpackage');
1145
+ for (const pack of packs) {
1146
+ console.log(
1147
+ `${pack.id}\t${pack.sourcePath}\t${pack.ruleCount}\t${pack.overridesBuiltin ? 'yes' : 'no'}\t${pack.package ?? '-'}`,
1148
+ );
1149
+ }
1150
+ } catch (err) {
1151
+ renderError(err.message);
1152
+ process.exit(1);
1153
+ }
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
+ });
1191
+ }
1192
+
461
1193
  program
462
1194
  .command('init')
463
- .description('Create starter .flectorc configuration')
1195
+ .description('Create starter .flectorc configuration from detected stack signals')
464
1196
  .action(() => {
465
- 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
+ }
466
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(', ')}`);
467
1211
  });
468
1212
 
469
1213
  program
@@ -485,8 +1229,13 @@ program
485
1229
  exclude: config?.exclude ?? [],
486
1230
  });
487
1231
  renderInfo(`resolved files: ${files.length}`);
1232
+ const [major, minor] = process.versions.node.split('.').map(Number);
1233
+ if (major < 20 || (major === 20 && minor < 19)) {
1234
+ throw new Error(`Node.js ${process.versions.node} is unsupported. Use Node.js >= 20.19.0.`);
1235
+ }
1236
+ renderInfo(`node: ${process.versions.node}`);
488
1237
  if (typeof fetch !== 'function') {
489
- throw new Error('Global fetch unavailable. Use Node.js >= 18.');
1238
+ throw new Error('Global fetch unavailable. Use Node.js >= 20.19.0.');
490
1239
  }
491
1240
  renderInfo('fetch: available');
492
1241
  renderInfo(`version: ${PKG.version}`);
@@ -497,7 +1246,7 @@ program
497
1246
  }
498
1247
  });
499
1248
 
500
- program.parse(process.argv);
1249
+ await program.parseAsync(process.argv);
501
1250
 
502
1251
  if (!process.argv.slice(2).length) {
503
1252
  program.help();