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 +67 -1
- package/README.md +4 -1
- package/index.js +148 -124
- package/package.json +1 -1
- package/src/config.js +161 -1
- package/src/differ.js +76 -12
- package/src/pr-comment.js +42 -0
- package/src/pr-providers.js +8 -1
- package/src/renderer.js +3 -15
- package/src/report.js +4 -2
- package/src/secrets.js +27 -0
- package/src/snapshot-store.js +668 -0
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
|
|
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.
|
|
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,
|
|
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
|
-
*
|
|
91
|
+
* Put the live side of a diff in the same form the store recorded the baseline
|
|
92
|
+
* in.
|
|
108
93
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
|
115
|
-
/** @type {
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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.
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
|
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(
|
|
713
|
-
// Snapshots carry config values, so 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(
|
|
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
|
|
735
|
-
const snapshot = {
|
|
736
|
-
file: filepath,
|
|
697
|
+
const result = snapshotStore.write(filepath, {
|
|
737
698
|
state,
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
|
764
|
-
if (!
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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}"` +
|