flecto 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,71 @@ The format is based on [Keep a Changelog], and this project adheres to
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.1.0] - 2026-09-15
11
+
12
+ ### Added
13
+
14
+ - **A shared, git-tracked snapshot store** ([#141]). Snapshot history lived in
15
+ `.flecto-snapshots/`, keyed by each file's *absolute* path — right for a laptop
16
+ and meaningless anywhere else. An ephemeral runner starts with that directory
17
+ empty on every run, so `history` and `report` were local-only by construction
18
+ and `ci` had to be handed `--snapshot-ref`.
19
+
20
+ `--snapshot-store shared` (or `"snapshotStore": "shared"` in `.flectorc`, which
21
+ is the better place for it) writes `.flecto/snapshots/` instead: keyed by
22
+ repo-relative path, one file per config file with its history inside, keys
23
+ sorted at every level. Commit it and every runner reads the baseline the author
24
+ saved, with no cache, no ref, and no setup step. `watch`, `ci`, `history`, and
25
+ `report` all read whichever store is selected, and every "nothing found"
26
+ message names the store it looked in.
27
+
28
+ **Committing snapshots commits config values into git history permanently**, so
29
+ the shared store masks by default: values that trip Flecto's secret detection —
30
+ by shape *or* by key name, the same names `--mask-secrets` recognizes — are
31
+ stored as `flecto:sha256:<digest>`. The digest is a change detector, not a
32
+ vault — a rotated credential still reports as drift, because a store that
33
+ silently missed one would be worse than no store, and the live side of a diff is
34
+ masked the same way so an untouched secret produces no change.
35
+ `--snapshot-mask none` opts out and says so.
36
+
37
+ Retention (`--snapshot-retention`, 20 per file in the shared store) prunes
38
+ oldest-first, because an append-forever store inside a repository becomes its
39
+ own problem. The `local` store is untouched: same filenames, same JSON, same
40
+ unbounded history, still the default.
41
+
42
+ ### Security
43
+
44
+ - **The merge gate could be turned green from `.flectorc`** ([#121]).
45
+ `--update-baseline` accepts every finding of the current run, and it resolved
46
+ through the ordinary options merge — so a pull request that added four lines of
47
+ `.flectorc` turned a failing `flecto ci --fail-on error` into a passing one,
48
+ overriding a `--fail-on` given on the command line. `updateBaseline` is now
49
+ refused from `.flectorc` (and from a profile) rather than honored: it is an
50
+ action, not a setting. `--update-baseline` on the command line is unchanged.
51
+
52
+ - **Write destinations could be redirected out of the repository** ([#121]).
53
+ `--output` (`flecto report`) and `--baseline` (`flecto ci`) can both be declared
54
+ in `.flectorc`, so a pull request could point them at any file the job could
55
+ reach — through `..`, or through a symlink — and both files carry content that
56
+ pull request partly wrote. A destination declared in `.flectorc` must now
57
+ resolve inside the project (`FLECTO_ALLOW_RC_WRITES=1` opts out), and a
58
+ destination that leaves the project through a symlink is refused whoever named
59
+ it (`FLECTO_ALLOW_SYMLINK_TARGETS=1` opts out) — including a link whose target
60
+ does not exist yet, which `existsSync` reports as absent and which would have
61
+ Flecto *create* a file outside the repository rather than overwrite one. A
62
+ destination named on the command line is operator intent and is unchanged.
63
+
64
+ - **The GitLab token followed redirects** ([#121]). `fetch` strips
65
+ `Authorization` when a redirect crosses origins and strips only that header;
66
+ GitLab authenticates with `PRIVATE-TOKEN`, which was forwarded to the redirect
67
+ target in full — verified against a local server. Provider API requests are now
68
+ issued with `redirect: 'manual'` and refuse a 3xx, naming the origin it pointed
69
+ at. Bitbucket workspace and repository segments are URL-encoded alongside,
70
+ matching GitLab's project id.
71
+
72
+ The API host comes from runner environment rather than pull request content, so
73
+ this needed a hostile or misconfigured API host to reach.
74
+
10
75
  ## [3.0.2] - 2026-09-06
11
76
 
12
77
  ### Security
@@ -972,7 +1037,8 @@ fixed — those runs were never actually gated — but the failure is new.
972
1037
  - Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
973
1038
  continuing with no policies.
974
1039
 
975
- [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.2...HEAD
1040
+ [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v3.1.0...HEAD
1041
+ [3.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.2...v3.1.0
976
1042
  [3.0.2]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.1...v3.0.2
977
1043
  [3.0.1]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.0...v3.0.1
978
1044
  [3.0.0]: https://github.com/myselfsiddharth/Flecto/compare/v2.1.0...v3.0.0
package/README.md CHANGED
@@ -263,7 +263,10 @@ flecto history config/prod.yaml --limit 10
263
263
  ```
264
264
 
265
265
  Snapshots stay on your machine in `.flecto-snapshots/`. Nothing is uploaded and
266
- no account is required. → **[CLI reference](docs/cli-reference.md#flecto-history-files)**
266
+ no account is required. To read the same history on a CI runner, save it to the
267
+ git-tracked store instead — `--snapshot-store shared` writes a committable
268
+ `.flecto/snapshots/`, masking secret-like values into digests as it goes.
269
+ → **[CLI reference](docs/cli-reference.md#flecto-history-files)**
267
270
 
268
271
  ### Share what changed before the incident
269
272
 
package/index.js CHANGED
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { program } from 'commander';
4
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, realpathSync } from 'fs';
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs';
5
5
  import { resolve, relative, dirname, join } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
- import { createHash } from 'crypto';
8
7
  import { execFileSync } from 'child_process';
9
8
  import chalk from 'chalk';
10
9
 
@@ -36,6 +35,12 @@ import { fireAlerts } from './src/alerter.js';
36
35
  import { resolveWebhookFormat, WEBHOOK_FORMAT_CHOICES } from './src/notifiers.js';
37
36
  import { createEnvelope } from './src/envelope.js';
38
37
  import { buildSarif } from './src/sarif.js';
38
+ import {
39
+ maskState,
40
+ resolveSnapshotStore,
41
+ SNAPSHOT_MASK_MODES,
42
+ SNAPSHOT_STORE_IDS,
43
+ } from './src/snapshot-store.js';
39
44
  import {
40
45
  loadBaseline,
41
46
  applyBaseline,
@@ -63,13 +68,13 @@ import {
63
68
  resolveProfileName,
64
69
  resolvePolicyOptions,
65
70
  assertTargetContained,
71
+ assertWriteDestinationContained,
66
72
  } from './src/config.js';
67
73
 
68
74
  const PKG = JSON.parse(
69
75
  readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'),
70
76
  );
71
77
 
72
- const SNAPSHOT_DIR = '.flecto-snapshots';
73
78
  const FAIL_ON_CHOICES = ['changed', 'added', 'removed', 'policy', 'error', 'warn'];
74
79
 
75
80
  /**
@@ -82,85 +87,42 @@ const FAIL_ON_CHOICES = ['changed', 'added', 'removed', 'policy', 'error', 'warn
82
87
  const PLAN_DEFAULT_FAIL_ON = 'error';
83
88
  const PLAN_DEFAULT_POLICIES = 'terraform';
84
89
 
85
- function snapshotIdForPath(absPath) {
86
- const normalized = absPath.replaceAll('\\', '/');
87
- return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
88
- }
89
-
90
- function snapshotPathForFile(absPath) {
91
- const id = snapshotIdForPath(absPath);
92
- return resolve(`${SNAPSHOT_DIR}/${id}.json`);
93
- }
94
-
95
- function snapshotHistoryPathForFile(absPath) {
96
- const id = snapshotIdForPath(absPath);
97
- let timestamp = Date.now();
98
- let path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
99
- while (existsSync(path)) {
100
- timestamp += 1;
101
- path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
102
- }
103
- return path;
104
- }
105
-
106
90
  /**
107
- * Snapshot ids that already have at least one timestamped history entry.
91
+ * Put the live side of a diff in the same form the store recorded the baseline
92
+ * in.
108
93
  *
109
- * Listed once per run and threaded through the snapshot loop: probing the
110
- * directory per file made writing N baselines cost N listings of O(N) entries
111
- * each, which is quadratic in the number of tracked files.
112
- * @returns {Set<string>}
94
+ * A masked store holds `flecto:sha256:…` where a secret was. Diffing that
95
+ * against the plaintext on disk would report every secret in the file as changed
96
+ * on every single run — noise that would train a team to ignore the tool, from a
97
+ * store whose whole purpose is to be trusted. Masking both sides compares digest
98
+ * to digest, so a rotated credential still reports as changed and an untouched
99
+ * one reports nothing.
100
+ * @template T
101
+ * @param {T} state
102
+ * @param {import('./src/snapshot-store.js').SnapshotStore} store
103
+ * @returns {T}
113
104
  */
114
- function snapshotIdsWithHistory() {
115
- /** @type {Set<string>} */
116
- const ids = new Set();
117
- if (!existsSync(SNAPSHOT_DIR)) return ids;
118
- for (const name of readdirSync(SNAPSHOT_DIR)) {
119
- const match = /^([a-f0-9]{16})\.\d+\.json$/.exec(name);
120
- if (match) ids.add(match[1]);
121
- }
122
- return ids;
123
- }
124
-
125
- function preserveLegacySnapshotForHistory(absPath, snapshotPath, idsWithHistory) {
126
- if (!existsSync(snapshotPath) || idsWithHistory.has(snapshotIdForPath(absPath))) return;
127
-
128
- const legacy = JSON.parse(readFileSync(snapshotPath, 'utf8'));
129
- writeFileSync(
130
- snapshotHistoryPathForFile(absPath),
131
- JSON.stringify({
132
- file: legacy.file ?? absPath,
133
- state: legacy.state ?? legacy,
134
- ...(Array.isArray(legacy.documents) ? { documents: legacy.documents } : {}),
135
- createdAt: legacy.createdAt ?? statSync(snapshotPath).mtime.toISOString(),
136
- }, null, 2),
137
- 'utf8',
138
- );
105
+ function alignStateWithStore(state, store) {
106
+ return store.maskMode === 'hash' ? /** @type {T} */ (maskState(state)) : state;
139
107
  }
140
108
 
141
- function readLocalSnapshotHistory() {
142
- if (!existsSync(SNAPSHOT_DIR)) return [];
143
-
144
- const entries = readdirSync(SNAPSHOT_DIR, { withFileTypes: true })
145
- .filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
146
- const historyEntries = entries.filter((entry) => /^[a-f0-9]{16}\.\d+\.json$/.test(entry.name));
147
- const historyIds = new Set(historyEntries.map((entry) => entry.name.slice(0, 16)));
148
- const legacyEntries = entries.filter((entry) =>
149
- /^[a-f0-9]{16}\.json$/.test(entry.name) && !historyIds.has(entry.name.slice(0, 16)));
150
- const snapshotEntries = [...historyEntries, ...legacyEntries];
151
-
152
- return snapshotEntries.map((entry) => {
153
- const path = resolve(SNAPSHOT_DIR, entry.name);
154
- const snapshot = JSON.parse(readFileSync(path, 'utf8'));
155
- const state = restoreSnapshotDocumentKeys(snapshot?.state ?? snapshot, snapshot);
156
- if (typeof snapshot?.file !== 'string') {
157
- throw new Error(`Invalid snapshot file: ${path}`);
158
- }
159
- return {
160
- file: snapshot.file,
161
- state,
162
- createdAt: snapshot.createdAt ?? statSync(path).mtime.toISOString(),
163
- };
109
+ /**
110
+ * Resolve the snapshot store a command should read and write (#141).
111
+ *
112
+ * Every snapshot consumer goes through this, so `--snapshot-store shared` in
113
+ * `.flectorc` means the same thing to `watch`, `ci`, `history`, and `report` —
114
+ * a store the editor of the config and the runner gating it disagree about
115
+ * would be worse than having only the local one.
116
+ * @param {Record<string, unknown>} effective
117
+ * @returns {import('./src/snapshot-store.js').SnapshotStore}
118
+ */
119
+ function snapshotStoreFromEffective(effective) {
120
+ return resolveSnapshotStore({
121
+ store: effective.snapshotStore,
122
+ dir: effective.snapshotDir,
123
+ mask: effective.snapshotMask,
124
+ retention: effective.snapshotRetention,
125
+ cwd: process.cwd(),
164
126
  });
165
127
  }
166
128
 
@@ -402,21 +364,18 @@ function canonicalPath(path) {
402
364
  }
403
365
  }
404
366
 
405
- function readSnapshotStateFromRef(filePath, snapshotRef) {
367
+ function readSnapshotStateFromRef(filePath, snapshotRef, store) {
406
368
  if (!snapshotRef) {
407
- const snapshotPath = snapshotPathForFile(filePath);
408
369
  // Failing closed here is right — a diff with no baseline is not a clean
409
- // diff — but an ENOENT on a hashed filename explains nothing. Snapshot
410
- // history is local to the working directory, so this is what an ephemeral
411
- // CI runner hits on every run (#141).
412
- if (!existsSync(snapshotPath)) {
413
- throw new Error(
414
- `no local snapshot has been saved for this file (${SNAPSHOT_DIR}/ holds none).`
415
- + ' Save one with "flecto watch <file> --snapshot", or pass --snapshot-ref'
416
- + ' <git-ref> to diff against a committed revision instead',
417
- );
370
+ // diff — but an ENOENT on a hashed filename explains nothing. The default
371
+ // store is local to the working directory, so this is what an ephemeral CI
372
+ // runner hits on every run; the message names the store it looked in and
373
+ // the two ways to give it one (#141).
374
+ const record = store.readLatest(filePath);
375
+ if (!record) {
376
+ throw new Error(`no snapshot has been saved for this file (${store.emptyHint})`);
418
377
  }
419
- return readSnapshotStateFromFile(snapshotPath);
378
+ return record.state;
420
379
  }
421
380
  const maybePath = resolve(snapshotRef);
422
381
  if (existsSync(maybePath)) {
@@ -684,6 +643,10 @@ program
684
643
  .option('--mask-secrets-webhooks', 'Also mask secrets in webhook payloads', false)
685
644
  .option('--snapshot', 'Save current state as baseline instead of watching')
686
645
  .option('--diff', 'Diff current file against saved baseline and exit')
646
+ .option('--snapshot-store <id>', `Snapshot store: ${SNAPSHOT_STORE_IDS.join(' | ')} (shared is repo-relative and meant to be committed)`)
647
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
648
+ .option('--snapshot-mask <mode>', `How the store records secret-like values: ${SNAPSHOT_MASK_MODES.join(' | ')} (default: hash for shared, none for local)`)
649
+ .option('--snapshot-retention <n>', 'Snapshots kept per file, 0 keeps every one (default: 20 for shared, unlimited for local)')
687
650
  .option('--allow-empty', 'Allow --snapshot to succeed when nothing was written', false)
688
651
  .action(async (files, opts, command) => {
689
652
  try {
@@ -707,16 +670,18 @@ program
707
670
  const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
708
671
  const webhookFormat = resolveWebhookFormat(effective.webhookFormat, effective.webhook);
709
672
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
673
+ const snapshotStore = snapshotStoreFromEffective(effective);
710
674
 
711
675
  if (effective.snapshot) {
712
- mkdirSync(SNAPSHOT_DIR, { recursive: true });
713
- // Snapshots carry config values, so a .flecto-snapshots/ that is itself a
676
+ mkdirSync(snapshotStore.root, { recursive: true });
677
+ // Snapshots carry config values, so a store directory that is itself a
714
678
  // link out of the project would write them somewhere the repository does
715
679
  // not control. Same rule as a target, checked after mkdir so an existing
716
680
  // link is seen rather than a path that does not exist yet.
717
- assertTargetContained(resolve(SNAPSHOT_DIR), process.cwd());
718
- const idsWithHistory = snapshotIdsWithHistory();
681
+ assertTargetContained(snapshotStore.root, process.cwd());
719
682
  let written = 0;
683
+ let pruned = 0;
684
+ const warnings = new Set();
720
685
  for (const filepath of targets) {
721
686
  if (!existsSync(filepath)) {
722
687
  renderWarn(`Skipping missing file: ${filepath}`);
@@ -727,25 +692,32 @@ program
727
692
  continue;
728
693
  }
729
694
  const state = parseFile(filepath);
730
- const snapshotPath = snapshotPathForFile(filepath);
731
- preserveLegacySnapshotForHistory(filepath, snapshotPath, idsWithHistory);
732
695
  // Only a multi-document file records `documents`, so an ordinary
733
696
  // snapshot is byte-for-byte what it was before this field existed.
734
- const documents = documentKeysOf(state) ?? [];
735
- const snapshot = {
736
- file: filepath,
697
+ const result = snapshotStore.write(filepath, {
737
698
  state,
738
- ...(documents.length > 0 ? { documents: [...documents] } : {}),
739
- createdAt: new Date().toISOString(),
740
- };
741
- writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf8');
742
- writeFileSync(snapshotHistoryPathForFile(filepath), JSON.stringify(snapshot, null, 2), 'utf8');
743
- // Keep the set in step with what this run has written, so a repeated
744
- // target behaves exactly as it did when the check hit the disk.
745
- idsWithHistory.add(snapshotIdForPath(filepath));
746
- console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
699
+ documents: documentKeysOf(state) ?? [],
700
+ });
701
+ if (result.warning) warnings.add(result.warning);
702
+ pruned += result.pruned;
703
+ console.log(chalk.green(`✓ Snapshot saved: ${result.path}`));
747
704
  written += 1;
748
705
  }
706
+ for (const warning of warnings) renderWarn(warning);
707
+ if (pruned > 0) {
708
+ renderNote(
709
+ `Pruned ${pruned} snapshot${pruned === 1 ? '' : 's'} beyond the`
710
+ + ` ${snapshotStore.retention}-entry retention of the ${snapshotStore.id} store.`,
711
+ );
712
+ }
713
+ if (written > 0 && snapshotStore.id === 'shared') {
714
+ renderNote(
715
+ `Shared store: commit ${snapshotStore.label} so every runner reads the same history.`
716
+ + (snapshotStore.maskMode === 'none'
717
+ ? ' Masking is off, so these files carry config values verbatim into git history.'
718
+ : ' Secret-like values are stored as digests, not plaintext.'),
719
+ );
720
+ }
749
721
  if (written === 0 && !effective.allowEmpty) {
750
722
  throw new Error(
751
723
  'No snapshots written — all targets were missing or unsupported.' +
@@ -760,14 +732,14 @@ program
760
732
  let compared = 0;
761
733
  let missing = 0;
762
734
  for (const filepath of targets) {
763
- const snapshotPath = snapshotPathForFile(filepath);
764
- if (!existsSync(snapshotPath)) {
765
- renderWarn(`No snapshot found for "${filepath}"`);
735
+ const record = snapshotStore.readLatest(filepath);
736
+ if (!record) {
737
+ renderWarn(`No snapshot found for "${filepath}" in ${snapshotStore.label}`);
766
738
  missing += 1;
767
739
  continue;
768
740
  }
769
- const before = readSnapshotStateFromFile(snapshotPath);
770
- const after = parseFile(filepath);
741
+ const before = record.state;
742
+ const after = alignStateWithStore(parseFile(filepath), snapshotStore);
771
743
  const events = diffTrees(before, after, dOpts);
772
744
  renderDiff(filepath, events, { maskSecrets });
773
745
  compared += 1;
@@ -779,7 +751,7 @@ program
779
751
  // is the normal state of a fresh CI runner.
780
752
  if (compared === 0) {
781
753
  throw new Error(
782
- 'No snapshot found for any target, so nothing was compared.'
754
+ `No snapshot found for any target in ${snapshotStore.label}, so nothing was compared.`
783
755
  + ' Run "flecto watch <file> --snapshot" first — no history is not no drift.',
784
756
  );
785
757
  }
@@ -897,8 +869,10 @@ program
897
869
 
898
870
  program
899
871
  .command('history [files...]')
900
- .description('Summarize drift across local snapshots')
872
+ .description('Summarize drift across saved snapshots')
901
873
  .option('-l, --limit <n>', 'Number of recent snapshots to show', '10')
874
+ .option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
875
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
902
876
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
903
877
  .option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
904
878
  .option('--array-id-key <key>', 'Diff arrays by this object identity key (opt-in)')
@@ -917,7 +891,8 @@ program
917
891
  const ignorePaths = parseCsv(effective.ignore);
918
892
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
919
893
 
920
- const allSnapshots = readLocalSnapshotHistory();
894
+ const snapshotStore = snapshotStoreFromEffective(effective);
895
+ const allSnapshots = snapshotStore.readHistory();
921
896
  let snapshots = allSnapshots;
922
897
  if (files.length > 0) {
923
898
  const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
@@ -928,13 +903,20 @@ program
928
903
  if (summaries.length === 0) {
929
904
  if (files.length > 0 && allSnapshots.length > 0) {
930
905
  throw new Error(
931
- 'No local snapshots matched the given files. Omit files to view all saved snapshot history.',
906
+ `No snapshots in ${snapshotStore.label} matched the given files.`
907
+ + ' Omit files to view all saved snapshot history.',
932
908
  );
933
909
  }
934
- throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
910
+ throw new Error(
911
+ `No snapshots found in ${snapshotStore.label}.`
912
+ + ` Run "flecto watch <file> --snapshot${snapshotStore.id === 'shared' ? ' --snapshot-store shared' : ''}" first`
913
+ + ' — no history is not no drift.',
914
+ );
935
915
  }
936
916
 
937
- console.log(`Local snapshot history (${summaries.length} snapshots)`);
917
+ console.log(
918
+ `Snapshot history from ${snapshotStore.label} (${summaries.length} snapshots, ${snapshotStore.id} store)`,
919
+ );
938
920
  let baselines = 0;
939
921
  for (const snapshot of summaries) {
940
922
  const file = relative(process.cwd(), snapshot.file) || snapshot.file;
@@ -965,8 +947,10 @@ program
965
947
 
966
948
  program
967
949
  .command('report [files...]')
968
- .description('Render local snapshot history as a self-contained HTML report')
950
+ .description('Render saved snapshot history as a self-contained HTML report')
969
951
  .option('-o, --output <path>', 'Write the report to this path', 'flecto-report.html')
952
+ .option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
953
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
970
954
  .option('-l, --limit <n>', 'Number of recent snapshots to include', '10')
971
955
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
972
956
  .option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
@@ -992,10 +976,15 @@ program
992
976
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
993
977
  const maskSecrets = Boolean(effective.maskSecrets);
994
978
  const outputPath = resolve(String(effective.output ?? 'flecto-report.html'));
979
+ assertWriteDestinationContained(outputPath, {
980
+ option: '--output',
981
+ fromCli: cliOverrides.output !== undefined,
982
+ });
995
983
 
996
984
  // Same snapshot source, filtering, and errors as `flecto history` — this
997
985
  // command only changes how that history is rendered.
998
- const allSnapshots = readLocalSnapshotHistory();
986
+ const snapshotStore = snapshotStoreFromEffective(effective);
987
+ const allSnapshots = snapshotStore.readHistory();
999
988
  let snapshots = allSnapshots;
1000
989
  if (files.length > 0) {
1001
990
  const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
@@ -1006,10 +995,15 @@ program
1006
995
  if (summaries.length === 0) {
1007
996
  if (files.length > 0 && allSnapshots.length > 0) {
1008
997
  throw new Error(
1009
- 'No local snapshots matched the given files. Omit files to report on all saved snapshot history.',
998
+ `No snapshots in ${snapshotStore.label} matched the given files.`
999
+ + ' Omit files to report on all saved snapshot history.',
1010
1000
  );
1011
1001
  }
1012
- throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
1002
+ throw new Error(
1003
+ `No snapshots found in ${snapshotStore.label}.`
1004
+ + ` Run "flecto watch <file> --snapshot${snapshotStore.id === 'shared' ? ' --snapshot-store shared' : ''}" first`
1005
+ + ' — no history is not no drift.',
1006
+ );
1013
1007
  }
1014
1008
 
1015
1009
  const reportSnapshots = [];
@@ -1043,6 +1037,7 @@ program
1043
1037
  version: PKG.version,
1044
1038
  limit,
1045
1039
  maskSecrets,
1040
+ store: snapshotStore.label,
1046
1041
  });
1047
1042
  mkdirSync(dirname(outputPath), { recursive: true });
1048
1043
  writeFileSync(outputPath, html, 'utf8');
@@ -1060,6 +1055,8 @@ program
1060
1055
  .description('Run semantic diff in CI mode')
1061
1056
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
1062
1057
  .option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
1058
+ .option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
1059
+ .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
1063
1060
  .option('--format <type>', 'Output format: json | ndjson | sarif | github-annotations | pr-comment', 'json')
1064
1061
  .option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs a token + merge request context)', false)
1065
1062
  .option('--pr-provider <name>', `Force the comment delivery target: ${PR_PROVIDER_IDS.join(' | ')} (default: detect from CI)`)
@@ -1112,8 +1109,31 @@ program
1112
1109
  }
1113
1110
  const dOpts = diffOptionsFromEffective(effective, ignorePaths);
1114
1111
 
1112
+ const snapshotStore = snapshotStoreFromEffective(effective);
1113
+
1115
1114
  const cwd = process.cwd();
1116
1115
  const baselinePath = effective.baseline ? resolve(cwd, String(effective.baseline)) : null;
1116
+ if (baselinePath) {
1117
+ assertWriteDestinationContained(baselinePath, {
1118
+ option: '--baseline',
1119
+ fromCli: cliOverrides.baseline !== undefined,
1120
+ cwd,
1121
+ });
1122
+ }
1123
+ // `--update-baseline` accepts every finding this run produced, so honoring
1124
+ // it from `.flectorc` would let a pull request turn its own failing gate
1125
+ // green — including one whose `--fail-on` was set explicitly on the command
1126
+ // line. It is an action, not a setting: the CLI is the only place it can
1127
+ // come from, and a declaration in the rc file is refused rather than
1128
+ // ignored, so a repository that meant it finds out.
1129
+ if (effective.updateBaseline && cliOverrides.updateBaseline === undefined) {
1130
+ throw new Error(
1131
+ 'updateBaseline is declared in .flectorc, and it is refused there: it accepts every'
1132
+ + ' current finding, which would turn a failing gate green from a file a pull request'
1133
+ + ' can edit. Pass --update-baseline on the command line when you mean to record a'
1134
+ + ' baseline.',
1135
+ );
1136
+ }
1117
1137
  const updateBaseline = Boolean(effective.updateBaseline);
1118
1138
  if (updateBaseline && !baselinePath) {
1119
1139
  throw new Error('--update-baseline requires --baseline <file> naming the file to write.');
@@ -1134,10 +1154,14 @@ program
1134
1154
  renderWarn(`Skipping unsupported file: ${filepath}`);
1135
1155
  continue;
1136
1156
  }
1137
- const after = parseFile(filepath);
1157
+ // A `--snapshot-ref` baseline is read straight from git, so it is never
1158
+ // masked; only a store-provided baseline needs the live side aligned.
1159
+ const after = effective.snapshotRef
1160
+ ? parseFile(filepath)
1161
+ : alignStateWithStore(parseFile(filepath), snapshotStore);
1138
1162
  let before;
1139
1163
  try {
1140
- before = readSnapshotStateFromRef(filepath, effective.snapshotRef);
1164
+ before = readSnapshotStateFromRef(filepath, effective.snapshotRef, snapshotStore);
1141
1165
  } catch (err) {
1142
1166
  throw new Error(
1143
1167
  `Failed to resolve snapshot baseline for "${filepath}"` +
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "provenance": true
6
6
  },
7
- "version": "3.0.2",
7
+ "version": "3.1.0",
8
8
  "description": "Flecto \u2014 semantic config watcher that reports meaningful changes in plain English",
9
9
  "license": "MIT",
10
10
  "keywords": [