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/CHANGELOG.md ADDED
@@ -0,0 +1,533 @@
1
+ # Changelog
2
+
3
+ All notable changes to Flecto will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog], and this project adheres to
6
+ [Semantic Versioning].
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [3.0.0] - 2026-08-06
11
+
12
+ ### Migration notes
13
+
14
+ Flecto 3.0.0 is additive in surface — no command, flag, or envelope field was
15
+ removed, exit codes are unchanged, and `schema_version` is still `2.0`. Two
16
+ behavior changes can turn a green 2.1.0 pipeline red, so read these first.
17
+
18
+ **1. The `default` policy pack catches more.** Value-pattern secret detection
19
+ and the SOPS decryption rules were added to `default`, so a credential-shaped
20
+ value under an innocuous key name — or a secret committed in the clear — is now
21
+ an `error`. A pipeline using `--fail-on policy` can fail on config that passed
22
+ in 2.1.0:
23
+
24
+ ```
25
+ flecto ci config.yaml --fail-on policy,error
26
+ # 2.1.0 -> exit 0 3.0.0 -> exit 1
27
+ ```
28
+
29
+ That is the intended behavior, but it is worth a dry run before upgrading CI.
30
+ To keep the 2.1.0 rule set while you triage, name the packs explicitly and
31
+ silence the new rules with `severityRemap`:
32
+
33
+ ```json
34
+ { "profiles": { "ci": { "severityRemap": {
35
+ "secret-value-detected": "off",
36
+ "sops-file-decrypted": "off",
37
+ "sops-value-decrypted": "off"
38
+ } } } }
39
+ ```
40
+
41
+ **2. Unknown `--fail-on` triggers are now an error.** A typo previously matched
42
+ nothing and the run exited `0`, so the gate was silently absent:
43
+
44
+ ```
45
+ flecto ci config.yaml --fail-on "polciy,eror"
46
+ # 2.1.0 -> exit 0, ignored 3.0.0 -> exit 1, "unknown triggers: polciy, eror"
47
+ ```
48
+
49
+ Any pipeline carrying a typo will go red on upgrade. That is the bug being
50
+ fixed — those runs were never actually gated — but the failure is new.
51
+
52
+ **Also worth knowing, unlikely to break a build:**
53
+
54
+ - **Multi-document YAML now parses** instead of failing the file. Paths inside
55
+ such a file are prefixed with the document identity (`Deployment/prod/api.…`),
56
+ so `--ignore` entries and custom pack path regexes written against
57
+ single-document paths will not match. Single-document files are unchanged.
58
+ - **Encrypted files no longer emit ciphertext to machine consumers.** 2.1.0 put
59
+ `ENC[AES256_GCM,data:…]` in the diff; 3.0.0 emits sentinels. Anything parsing
60
+ the JSON/NDJSON envelope for SOPS files needs updating.
61
+ - **`flecto init` no longer overwrites an existing config.** 2.1.0 silently
62
+ regenerated `.flectorc.json`, destroying edits. Both still exit `0`, so a
63
+ script relying on regeneration now gets a no-op.
64
+ - **`--mask-secrets` masks more than before** — value-shaped detection and
65
+ nested values, not only top-level sensitive key names.
66
+
67
+ ### Added
68
+
69
+ - A test that every runtime dependency's `engines.node` is satisfiable by the
70
+ Node version Flecto itself declares, plus a check that the CI matrix actually
71
+ exercises that floor. This class of bug has now happened twice ([#22], and
72
+ chalk 6 requiring Node >=22 in [#104]) and CI could not catch it: `engines` is
73
+ advisory, so the Node 20 job passes while npm warns users with `EBADENGINE`.
74
+ The check reads each manifest off disk rather than through `require()`,
75
+ because a package whose `exports` map hides `./package.json` — chalk 6 is
76
+ exactly that — would otherwise be skipped silently. `chalk` majors are held in
77
+ Dependabot alongside `commander` and `js-yaml`. ([#104])
78
+ - Pre-merge review of rendered Kubernetes manifests, and a `kubernetes` policy
79
+ pack to gate it. ArgoCD, Flux, and `helm diff` compare a cluster to the
80
+ repository; this compares the manifests a pull request *would* produce against
81
+ the ones the merge target produces, before `helm upgrade` runs. The workflow
82
+ needs no new command and no new dependency: render both sides to plain
83
+ multi-document YAML with whatever you already use — `helm template`,
84
+ `kustomize build`, `kubectl kustomize`, `jsonnet`, `cdk8s` — and diff them with
85
+ `flecto compare base.yaml head.yaml --policies kubernetes`. Repositories that
86
+ commit their rendered output can use `flecto ci manifests/prod.yaml
87
+ --snapshot-ref origin/main` instead and render once. **Flecto never invokes
88
+ `helm` or `kustomize`**; neither is a dependency and neither has to exist on
89
+ the runner, which is what keeps the renderer your choice. The pack carries ten
90
+ rules for changes that are risky at review time: `privileged`, host
91
+ namespaces, weakened `runAsNonRoot`, `allowPrivilegeEscalation`, `SYS_ADMIN` /
92
+ `NET_ADMIN` / `ALL` capabilities, images that resolve to `:latest`,
93
+ `imagePullPolicy` moving to `Always`, replica jumps, removed resource limits,
94
+ and Services becoming `LoadBalancer` or `NodePort`. Thresholds are tuned so
95
+ routine work stays quiet — a replica jump needs both a 3× multiple and an
96
+ increase of at least 3, so `1 → 2` does not fire. Policy packs also gained an
97
+ optional pack-level `expandSubtrees`, which expands added and removed subtrees
98
+ into the leaf changes they imply before rules run; without it a brand-new
99
+ `Service` document is a single change carrying the whole manifest, and a rule
100
+ anchored at `spec.type` never sees inside it. It is opt-in per pack and off by
101
+ default, so every existing pack behaves exactly as before. ([#76])
102
+ - SOPS- and age-aware diffing, structural and **without ever decrypting**.
103
+ Encrypted files were previously the ones Flecto helped with least: skipped, or
104
+ read as ordinary YAML with ciphertext blobs filling the diff. They are now
105
+ detected from their **contents** — a SOPS metadata block (a `sops` map with a
106
+ version plus a MAC, a modification stamp, or a key group; also the flat
107
+ `sops_*` form used for dotenv and INI), or a recognized ciphertext container
108
+ (`ENC[AES256_GCM,…]`, an armored age blob, an armored PGP message). Filenames
109
+ are only a hint: teams commit fully encrypted `values.prod.yaml`, and
110
+ `.sops.yaml` is a *plaintext* creation-rules config. A config that merely pins
111
+ `sops.version` is not mistaken for an encrypted file. `.age` files, and any
112
+ file that is one armored blob, are now supported as a single opaque value.
113
+ Every ciphertext-bearing value is replaced **in the parser** with an opaque
114
+ `<encrypted:SCHEME:DIGEST>` sentinel, so no diff, snapshot, webhook payload,
115
+ PR comment, or HTML report can carry ciphertext — there is no code path that
116
+ produces any, with or without `--mask-secrets`. Human output collapses it
117
+ further, to `~ db.password: <encrypted value changed>`. What you get instead
118
+ is the structure: keys added and removed, which encrypted values moved, the
119
+ `sops` metadata block, and — the useful part — the recipient list. Public
120
+ identifiers stay visible (age recipient, PGP fingerprint, KMS ARN) while the
121
+ data key sealed to each is redacted, and the key groups are re-keyed by
122
+ recipient identity so a recipient inserted at the front reads as one addition
123
+ rather than "every recipient changed". Two synthetic paths carry what a
124
+ key-by-key walk cannot express: `<encryption>` when a file gains or loses
125
+ encryption, and `<encryption.mac>` when the MAC moves while every value it
126
+ covers stays put. Both respect `--ignore` like any other path. A value that
127
+ stopped being encrypted is reported as changed with the new value withheld —
128
+ the event is that it was exposed, and a CI log should not widen that. A new
129
+ built-in `sops` policy pack covers recipient added (`error`), recipient
130
+ removed (`warn`), a lone MAC change (`warn`), a file that became encrypted
131
+ (`info`), and `.sops.yaml` creation-rule recipient changes (`warn`); the
132
+ `default` pack gains the two that catch a secret committed in the clear,
133
+ `sops-file-decrypted` and `sops-value-decrypted`, both `error`. Flecto never
134
+ shells out to `sops`, `age`, or `gpg`, never reads a key file, agent socket,
135
+ or KMS credential, and has no flag that turns decryption on. Unencrypted files
136
+ are untouched: a tree with nothing to redact comes back from the encryption
137
+ pass as the same object, and a diff between two of them returns the very array
138
+ it always did. ([#77])
139
+ - A second bundled composite Action, `flecto-pr-risk`, that packages the pull
140
+ request risk comment as a one-line adoption: `uses:` it after
141
+ `actions/checkout` and the defaults do the rest (`format: pr-comment`,
142
+ posting on, `fail-on: policy,error`, secret masking on, the workflow token).
143
+ It resolves the baseline from the pull request instead of `HEAD~1`, which is
144
+ the wrong commit on a PR — `github.event.pull_request.base.sha`, refined to
145
+ the merge base with `HEAD` when the checkout carries enough history. A
146
+ missing base commit is fetched if it can be; when it still cannot be resolved
147
+ the job fails with a message naming `fetch-depth: 0`, rather than reporting
148
+ "no changes" and letting a risky edit through. Posting degrades instead of
149
+ breaking: a fork's read-only token, a missing `pull-requests: write`, or an
150
+ empty `github-token` produce a workflow warning and a report in the log,
151
+ never a failed check — the exit code stays with the diff and policy result.
152
+ `flecto-version` pins the CLI without forking the Action. The existing
153
+ `flecto-ci` Action is untouched, inputs and defaults included, and is now
154
+ covered by tests that parse both committed `action.yml` files. ([#74])
155
+ - `flecto report [files...]`: a static HTML drift report rendered from the local
156
+ snapshot history `flecto history` already reads, written to
157
+ `--output` (default `flecto-report.html`). The page carries a per-file
158
+ timeline — each snapshot with its UTC timestamp, the snapshot it is measured
159
+ against, its semantic changes, and the policy findings those changes produced
160
+ — plus a summary and every finding grouped by severity. `--limit`,
161
+ `--profile`, `--ignore`, `--policies`, `--plugins`, and the array-identity
162
+ flags resolve through the same effective-options path as every other command,
163
+ so a report matches what `flecto history` and `flecto watch --diff` report.
164
+ The file is **fully self-contained**: inline CSS, one small inline script for
165
+ filtering and collapsing, and nothing else — no fonts, no images, no CDN
166
+ scripts, no analytics, and no network access when it is opened. It follows the
167
+ viewer's light or dark theme, is responsive, and prints. Every config value,
168
+ path, and message is HTML-escaped, so a value containing markup renders as
169
+ text rather than as part of the page. `--mask-secrets` (flag or profile)
170
+ applies the same key-name and value-pattern redaction used elsewhere, and also
171
+ redacts policy messages that interpolate values — a report is a shareable
172
+ artifact, so a leak there is worse than one in a terminal. With no snapshots
173
+ it prints the same guidance `flecto history` does and writes no file. ([#75])
174
+ - A convention for distributing policy packs, plus `flecto policies add <name>`
175
+ to install one. A community pack is an npm package named `flecto-pack-<id>`
176
+ (or `@scope/flecto-pack-<id>`) with a `flecto-pack.json`, `flecto-pack.yaml`,
177
+ or `flecto-pack.yml` at its root — no build step, no entry point, no code. A
178
+ package that builds its pack elsewhere can point at it with a `"flecto"` field
179
+ in its package.json (`{ "pack": "dist/pack.json" }`, or the bare path).
180
+ `flecto policies add` takes either the pack id or the full package name,
181
+ resolves the already-installed package from `node_modules`, validates it with
182
+ the same validator that runs at evaluation time, and writes it to
183
+ `policies/<id>.json` so the existing resolution order picks it up unchanged. A
184
+ malformed third-party pack is rejected at add time rather than failing later
185
+ during evaluation, and an existing local pack is never overwritten without
186
+ `--force`. Nothing from the package is imported or executed: only the
187
+ declarative pack file is read, JavaScript shipped in a pack package is ignored
188
+ (and reported), and a `"flecto"` field pointing at a `.js` file is rejected.
189
+ Plugins, which do run code, are deliberately out of scope for this command.
190
+ `flecto policies list` now reports the originating npm package for packs
191
+ installed this way, tracked in `policies/.flecto-packs.json`; hand-written
192
+ local packs list exactly as before. ([#71])
193
+ - Value-pattern secret detection. Secrets are now found by what the value looks
194
+ like, not only by the key name: known token formats (AWS `AKIA…`/`ASIA…`,
195
+ GitHub `ghp_…`/`gho_…`/`ghu_…`/`ghs_…`/`ghr_…`, Slack `xox[abprs]-…`, Google
196
+ `AIza…`, Stripe `sk_live_…`/`rk_live_…`, JWTs, PEM private-key blocks, and
197
+ credentials embedded in a `scheme://user:password@host` URL) plus a
198
+ conservative high-entropy fallback for opaque strings. The same detection
199
+ drives `--mask-secrets` redaction and the new `secret-value-detected` rule in
200
+ the built-in `default` and `strict-prod` packs, so a credential under a boring
201
+ key such as `db.connstr` is both flagged and masked. Packs can use it directly
202
+ through the new `afterLooksSecret` / `beforeLooksSecret` predicates. Key-name
203
+ detection is unchanged. ([#66])
204
+ - Native Slack, Discord, and Microsoft Teams alert payloads:
205
+ `flecto watch --webhook-format <flecto|slack|discord|teams|auto>` (or
206
+ `webhookFormat` in `.flectorc`). The existing webhook path is reused as-is —
207
+ headers, `--webhook-timeout`, `--webhook-retries`, `--delivery-mode`, and
208
+ `--on-alert-failure` all behave identically; only the request body changes, so
209
+ no receiver of your own is needed. Slack gets Block Kit `blocks` with an
210
+ mrkdwn `text` fallback, Discord an embed colored by the highest policy
211
+ severity, Teams a MessageCard. Long change sets truncate to `… +N more`
212
+ within each service's documented limits (Slack 3000 chars per section, Discord
213
+ 4096 per embed description, Teams 28 KB per message). `auto` detects the
214
+ format from the webhook host (`hooks.slack.com`, `discord.com/api/webhooks`,
215
+ `*.office.com`) and is opt-in: the default remains `flecto`, which posts the
216
+ raw envelope byte-for-byte as before. `--mask-secrets-webhooks` applies to
217
+ chat payloads too. ([#68])
218
+ - `flecto ci --format pr-comment`: a markdown risk summary for pull requests —
219
+ change counts, policy findings grouped by severity with file and path, and the
220
+ per-file change list, collapsed into a `<details>` block past ten changes.
221
+ The body opens with a hidden `<!-- flecto:pr-comment -->` marker, so posting
222
+ updates the one comment Flecto already left instead of adding a new one per
223
+ push; an unchanged report skips the write entirely. Rendering to stdout is the
224
+ default and never touches the network. Posting requires **both** the explicit
225
+ `--pr-comment-post` opt-in and a complete GitHub pull request context
226
+ (`GITHUB_TOKEN`, `GITHUB_REPOSITORY`, and a PR number from `GITHUB_REF` or
227
+ `GITHUB_EVENT_PATH`); `GH_TOKEN` is ignored so a local `gh auth login` cannot
228
+ turn a laptop run into a comment. Delivery problems warn on stderr and leave
229
+ the exit code to the diff and policy result, and the token is never printed.
230
+ The bundled `flecto-ci` Action exposes this as the opt-in `pr-comment-post`
231
+ and `github-token` inputs. ([#67])
232
+ - Multi-document YAML support (`---`-separated), the usual shape of a Kubernetes
233
+ manifest. Previously such a file failed to parse. Each document is diffed
234
+ under its own key: `kind/name` for Kubernetes-shaped documents (namespaced
235
+ resources include the namespace), then a top-level `id` or `name`, falling
236
+ back to the document index when no stable identity is available — so a
237
+ document inserted at the top of a file no longer renumbers every other path.
238
+ Empty documents (a leading or trailing `---`, or a template that rendered
239
+ nothing) are dropped. Single-document files are unchanged: they still parse to
240
+ the document itself, with identical diff paths. ([#69])
241
+ - Stack-aware `flecto init`: the generated `.flectorc.json` now pre-selects
242
+ policy packs and file patterns from signals in the working directory —
243
+ `docker-compose.yml` / `compose.yaml` enables the `compose` pack and watches
244
+ the compose file, `package.json` enables `node-runtime`, and `config/` plus
245
+ `.env` files shape the `files` patterns. Terraform files are reported as
246
+ context only, since no `terraform` pack ships yet and `.tf` is not a parseable
247
+ format. `init` prints what it detected and why, and falls back to the previous
248
+ generic starter config when nothing is found. ([#72])
249
+ - `flecto compare <fileA> <fileB>`: run the differ and policy engine across two
250
+ different files, for environment skew ("works in staging, fails in prod")
251
+ rather than drift in one file over time. `fileA` is the baseline, so `+` is
252
+ present only in `fileB` and `-` only in `fileA`. The two files need not share
253
+ a format — `config/prod.yaml` against `config/prod.json` works, since every
254
+ supported format parses to a plain tree. Respects `--profile`, `--ignore`,
255
+ `--policies`, `--plugins`, `--array-id-key`, `--no-array-id`,
256
+ `--array-ignore-order`, and `--mask-secrets` exactly as `ci` does, and adds
257
+ `--fail-on` with the same triggers (defaulting to
258
+ `changed,added,removed,policy,error`, since environments that should match
259
+ ought to match on added and removed keys too). Output defaults to the
260
+ human-readable renderer; `--format json|ndjson|github-annotations` emits the
261
+ same envelopes and result shape as `ci`, plus a `baseline` field naming
262
+ `fileA`. Exit code is `0` when the files match under the active fail triggers,
263
+ `1` otherwise. ([#70])
264
+ - A reproducible large-repo benchmark harness (`npm run bench`) and the findings
265
+ it produced in [docs/performance.md](docs/performance.md). The harness
266
+ generates a synthetic repo at 50/250/1000 config files — including deeply
267
+ nested trees and files with 5,000-entry arrays — snapshots it, mutates it, and
268
+ then measures `flecto ci` end to end while attributing time across glob
269
+ discovery, snapshot load, parse, diff, and policy evaluation. It uses
270
+ `node:perf_hooks` only, adds no dependency, never runs during `npm test`, and
271
+ is excluded from the published package. Developer tooling: nothing in `src/`
272
+ or the CLI depends on it. ([#78])
273
+ - `flecto plan <planFiles...>`: diff Terraform plan JSON (`terraform show
274
+ -json`) with the same differ, envelope, and policy engine every other command
275
+ uses. Flecto never runs the `terraform` binary — it only reads the JSON you
276
+ hand it. Paths are keyed by the resource address
277
+ (`aws_security_group.web.ingress[0].cidr_blocks[0]`), and every resource also
278
+ gets a synthetic `#action` attribute so resource-level rules can match one
279
+ event instead of one per attribute: `create` reports as `added`, `delete` as
280
+ `removed`, `update` as `changed`, and — deliberately — a `replace`
281
+ (destroy-and-recreate, in either action ordering) also reports as `removed`
282
+ carrying the value `"replace"`, so `--fail-on removed` catches every replace
283
+ with no policy pack loaded, and the note names the attribute that forced it
284
+ (`(forced by: engine_version)`). Values Terraform cannot resolve until apply
285
+ (`after_unknown`) render as `(known after apply)`, never `null`, except on a
286
+ pure create, where an all-computed attribute is dropped rather than listed.
287
+ Values Terraform marks sensitive are replaced with `(sensitive value)`
288
+ unconditionally — before the policy engine, the envelope, or any formatter
289
+ sees them — independent of `--mask-secrets`; that flag adds Flecto's own
290
+ value-shaped detection on top, for credentials Terraform did not mark.
291
+ `--format human|json|ndjson|github-annotations|pr-comment`, `--ignore`,
292
+ `--policies` (default `terraform`), `--plugins`, and `--fail-on` (default
293
+ `error`, not `changed` — a plan is supposed to contain changes) all work as
294
+ they do elsewhere. Ships with a new `terraform` policy pack, loaded by
295
+ default: a resource replaced or a stateful resource destroyed, security-group
296
+ ingress opened to `0.0.0.0/0` / `::/0`, an IAM policy granting a wildcard
297
+ `Action`/`Resource`, an S3 public-access-block disabled or a public ACL, an
298
+ instance-size change, a capacity setting jumping 2x or more, any
299
+ Terraform-sensitive value changing, and a credential-shaped value Terraform
300
+ did not mark sensitive. See [docs/terraform.md](docs/terraform.md). ([#73])
301
+
302
+ ### Changed
303
+
304
+ - `flecto init` no longer claims to have initialized a config when one already
305
+ exists. It now checks every `.flectorc` candidate — not just
306
+ `.flectorc.json` — and warns that the existing file was left unchanged instead
307
+ of writing a second config that `loadRcConfig` would shadow. ([#72])
308
+ - Policy packs are now cached across a run instead of being re-resolved,
309
+ re-parsed, and re-validated on every file (`ci`) or every change event
310
+ (`watch`). The cache key is the working directory, the resolved pack path,
311
+ and that file's mtime, so a `policies/<id>.json` edited mid-`watch` is picked
312
+ up on the very next change event rather than served stale — watch mode's
313
+ fail-closed behavior on a bad pack edit is unchanged, and per-profile
314
+ `severityRemap` still applies after the cache, so one profile's remap can
315
+ never leak into another's findings. `matchClause()` also compiles each
316
+ rule's `match.path` and `afterMatches` regular expressions once at pack-load
317
+ time instead of once per change event. Measured with `npm run bench`: the
318
+ policy phase of the in-process pipeline at 1,000 files drops by roughly 60%
319
+ (median across two 15-run sessions: ~38 ms to ~15 ms); end-to-end `flecto ci`
320
+ wall time improves more modestly and closer to the harness's documented ±10%
321
+ run-to-run noise. See [docs/performance.md](docs/performance.md).
322
+ ([#92], [#93]) ([#108])
323
+
324
+ ### Fixed
325
+
326
+ - **SOPS protections no longer disengage on multi-document YAML.** Multi-document
327
+ files ([#69]) wrap each document in a synthetic identity-keyed object, so a
328
+ `sops` metadata block sits one level below the root — and `encryptionState` /
329
+ `normalizeEncrypted` looked only at the root. Every SOPS protection silently
330
+ switched off on exactly the file shape Kubernetes secrets ship in: the
331
+ plaintext of a value that had just been decrypted was printed verbatim (with
332
+ `--mask-secrets` on as well as off), no `sops` or `default` pack rule could
333
+ fire, and a recipient inserted at the front of a document's key list read as
334
+ "every recipient changed". A pull request that added an attacker's decryption
335
+ key *and* committed a secret in the clear passed `--fail-on policy,error`
336
+ while printing the secret into the CI log. Encryption state is now determined
337
+ per document, `sops` pack rules match a document-prefixed path, and recipient
338
+ groups inside a document are re-keyed by identity exactly as they are at the
339
+ root. Ciphertext itself never leaked — `redactCiphertext` always walked the
340
+ whole tree — and single-document behaviour is byte-for-byte unchanged.
341
+ ([#109])
342
+ - **`--mask-secrets` no longer masks every value in a document whose resource
343
+ name looks secret-shaped.** Secret-name matching ran against the whole diff
344
+ path, and a multi-document path begins with the document's identity, so any
345
+ resource whose kind or name contained `secret`, `token`, `password`,
346
+ `api_key`, `private_key`, or `credential` — every `kind: Secret`, and any
347
+ Deployment called something like `token-service` — had all of its values
348
+ replaced by `***`, numbers and booleans included. A reviewer could not see
349
+ that `replicas` went 2 → 9 or that `privileged` went false → true, and policy
350
+ messages interpolating those values degraded to nonsense. The parser now
351
+ records the keys it invented for a multi-document file and the renderers match
352
+ secret names against the path *below* that prefix: a resource name is user
353
+ data and never participates. Genuinely sensitive keys inside a document —
354
+ `data.password`, `stringData.token` — are masked exactly as before. Snapshots
355
+ of multi-document files record their document keys so `flecto report` and
356
+ `flecto history` mask correctly too; snapshots of ordinary files are
357
+ unchanged. ([#110])
358
+ - Policy finding messages no longer bypass secret masking. `evaluatePolicies`
359
+ runs on unmasked events, so a pack rule whose `messageTemplate` interpolates
360
+ `{before}` / `{after}` could print a credential that `--mask-secrets` had
361
+ redacted from `changes` — across the terminal, webhooks, CI JSON, GitHub
362
+ annotations, and the PR comment. Interpolated values are now masked with the
363
+ same path-aware logic as change events. ([#88])
364
+ - **Unknown `--fail-on` triggers are rejected instead of silently ignored.** A
365
+ typo such as `--fail-on polciy,eror` previously matched nothing, so the run
366
+ exited `0` with a real diff present and the CI gate was effectively absent.
367
+ Unknown triggers now fail with the list of valid ones. ([#97])
368
+ - YAML and TOML scalars are normalized to a JSON-safe tree before they reach
369
+ snapshots, the differ, or renderers — dates, BigInts, non-finite numbers, and
370
+ objects carrying a `toJSON()`. Previously these could diff or serialize
371
+ inconsistently depending on which parser produced them. Existing snapshots
372
+ are unaffected: `JSON.stringify` already wrote these as strings, so this makes
373
+ the in-memory tree match what was always on disk. ([#94])
374
+ - Top-level `include` patterns are merged with `files` instead of being dropped
375
+ whenever `files` was also present in `.flectorc`. ([#95])
376
+ - `--on-alert-failure exit` now terminates watch mode. It reported the failure
377
+ but left the watcher running, so a build depending on it to stop never did.
378
+ ([#96])
379
+ - `flecto watch` no longer misses changes when a file's valid JSON root is
380
+ `null`. The baseline was treated as absent rather than as the value `null`,
381
+ so the first real change after it went unreported. ([#98])
382
+ - `flecto watch --snapshot` no longer degrades quadratically with the number of
383
+ tracked files. Deciding whether a file already had snapshot history listed the
384
+ whole `.flecto-snapshots/` directory once per file — and compiled a regular
385
+ expression once per directory entry — so re-snapshotting a repo cost N
386
+ listings of O(N) entries. The directory is now listed once per run. Measured
387
+ on 1,000 tracked files, re-snapshotting went from 2,229 ms to 546 ms (~4x);
388
+ the first snapshot of a repo, which never took this path, is unchanged.
389
+ ([#78])
390
+ - `flecto ci --snapshot-ref <git-ref>` now resolves the baseline correctly when
391
+ run from a subdirectory of the repository. `git show <rev>:<path>` resolves
392
+ `<path>` from the repository root, so the previous cwd-relative path failed
393
+ outside the repo root — a common setup in monorepos. Paths are also
394
+ canonicalized before comparison, fixing baseline resolution under symlinked
395
+ directories such as macOS `/tmp` and `/var/folders`. ([#79])
396
+ - `--mask-secrets` now redacts nested secret values in terminal output
397
+ (`watch` and `watch --diff`), matching the masking already applied to
398
+ webhook/CI payloads. Previously a change on a benign-looking path such as
399
+ `database` printed its `password` / `api_key` children in the clear.
400
+ ([#24])
401
+ - A YAML file with a self-referential anchor (`a: &x\n b: *x`) no longer
402
+ fails to parse. js-yaml resolves such an alias to the same object it
403
+ anchors, producing a genuinely cyclic tree; scalar normalization walked it
404
+ and overflowed the call stack (`Maximum call stack size exceeded`) before
405
+ the file could load at all — a bare `Parse error`, not a crash. Cyclic
406
+ back-references now normalize to a fixed `"<circular>"` sentinel, so the
407
+ rest of the file parses, snapshots, and diffs normally, two files with the
408
+ same cycle shape compare equal, and merge keys (`<<: *base`), which resolve
409
+ to an ordinary acyclic tree, are unaffected. ([#103]) ([#107])
410
+
411
+ ## [2.1.0] - 2026-07-24
412
+
413
+ ### Added
414
+
415
+ - Default-on array identity matching with auto-detect of unique `id`, then
416
+ `name`. Escape hatch: `--no-array-id` or `"arrayId": false` in `.flectorc`.
417
+ Custom keys still work via `--array-id-key`. ([#6])
418
+ - `flecto history` for local snapshot drift baselines (`--limit`). ([#7])
419
+ - Richer declarative policy predicates: `beforeEquals`, `beforeIn` / `afterIn`,
420
+ `beforeTruthy` / `afterTruthy`, `afterMatches`, `numericDelta`,
421
+ `match.pathEquals` / `match.pathPrefix`, and `allOf` / `anyOf`. ([#34])
422
+ - Built-in `compose` and `node-runtime` policy packs. ([#8])
423
+ - JSON Schema + load-time validation for policy packs
424
+ (`schemas/flecto-policy-pack-2.0.json`). ([#36])
425
+ - `flecto policies list` (+ `--json`) for pack discovery. ([#37])
426
+ - `flecto policies test <fixtureDir>` fixture harness for packs/plugins. ([#38])
427
+ - Per-profile `severityRemap` to raise, lower, or silence pack rules without
428
+ forking. ([#39])
429
+ - Reusable GitHub Action wrapper for `flecto ci`
430
+ (`.github/actions/flecto-ci`). ([#9])
431
+ - Policy pack + plugin authoring guides, cookbook, and examples. ([#32], [#35])
432
+ - `CHANGELOG.md` with v2.1 migration notes. ([#33])
433
+
434
+ ### Changed
435
+
436
+ - Node.js requirement raised to **>=20.19.0** (matches chokidar 5). CI matrix
437
+ is 20/22/24; publish uses Node 22. ([#22], [#27])
438
+ - `flecto ci` and `flecto watch --snapshot` fail closed when every target is
439
+ missing or unsupported. Pass `--allow-empty` to permit an empty run.
440
+ ([#20], [#29], [#40])
441
+ - Only options explicitly set on the CLI override `.flectorc` profiles
442
+ (Commander defaults no longer wipe profile settings). ([#19], [#31])
443
+ - Watch mode fails closed on policy pack/plugin load or evaluation errors,
444
+ independent of `--on-alert-failure`. ([#25])
445
+ - Secret masking recursively redacts nested secret values when enabled. ([#24])
446
+ - Dangerous-toggle rules treat stringy truthy values (`true` / `1` / `yes`) as
447
+ enabled, so `.env` / INI configs are covered. ([#23])
448
+
449
+ ### Fixed
450
+
451
+ - `arrayIgnoreOrder` no longer false-positives on object key order or throws on
452
+ non-JSON values such as `undefined`. ([#21])
453
+ - `fireAlerts` preserves its `{ ok }` result and surfaces queue errors; watch
454
+ consumes rejected alert handlers safely. ([#26])
455
+ - GitHub annotation output escapes `%`, newlines, commas, and colons per
456
+ workflow-command rules. ([#28])
457
+ - Removed leftover `.sentinel-snapshots/` gitignore entry. ([#30])
458
+
459
+ ### Migration notes
460
+
461
+ - **Array identity is on by default.** Diff paths may change from index-based
462
+ (`services[0].…`) to identity-based (`services["api"].…`). Review snapshots,
463
+ CI baselines, and any automation that consumes diff paths before upgrading.
464
+ - To keep 2.0-style index-based array diffs: `--no-array-id` or
465
+ `"arrayId": false` in `.flectorc`.
466
+ - **Node 18 is no longer supported.** Use Node.js 20.19.0 or newer.
467
+ - Recursive masking only affects output when secret masking is enabled, but
468
+ nested secret values previously visible in terminal/webhook payloads are now
469
+ redacted.
470
+ - `.flectorc` profile settings (for example `mode`, `failOn`, `format`) now
471
+ apply when you omit the corresponding CLI flags.
472
+ - Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
473
+ continuing with no policies.
474
+
475
+ [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.0...HEAD
476
+ [3.0.0]: https://github.com/myselfsiddharth/Flecto/compare/v2.1.0...v3.0.0
477
+ [2.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v2.0.0...v2.1.0
478
+ [#6]: https://github.com/myselfsiddharth/Flecto/issues/6
479
+ [#7]: https://github.com/myselfsiddharth/Flecto/issues/7
480
+ [#8]: https://github.com/myselfsiddharth/Flecto/issues/8
481
+ [#9]: https://github.com/myselfsiddharth/Flecto/issues/9
482
+ [#19]: https://github.com/myselfsiddharth/Flecto/issues/19
483
+ [#20]: https://github.com/myselfsiddharth/Flecto/issues/20
484
+ [#21]: https://github.com/myselfsiddharth/Flecto/issues/21
485
+ [#22]: https://github.com/myselfsiddharth/Flecto/issues/22
486
+ [#23]: https://github.com/myselfsiddharth/Flecto/issues/23
487
+ [#24]: https://github.com/myselfsiddharth/Flecto/issues/24
488
+ [#25]: https://github.com/myselfsiddharth/Flecto/issues/25
489
+ [#26]: https://github.com/myselfsiddharth/Flecto/issues/26
490
+ [#27]: https://github.com/myselfsiddharth/Flecto/issues/27
491
+ [#28]: https://github.com/myselfsiddharth/Flecto/issues/28
492
+ [#29]: https://github.com/myselfsiddharth/Flecto/issues/29
493
+ [#30]: https://github.com/myselfsiddharth/Flecto/issues/30
494
+ [#31]: https://github.com/myselfsiddharth/Flecto/issues/31
495
+ [#32]: https://github.com/myselfsiddharth/Flecto/issues/32
496
+ [#33]: https://github.com/myselfsiddharth/Flecto/issues/33
497
+ [#34]: https://github.com/myselfsiddharth/Flecto/issues/34
498
+ [#35]: https://github.com/myselfsiddharth/Flecto/issues/35
499
+ [#36]: https://github.com/myselfsiddharth/Flecto/issues/36
500
+ [#37]: https://github.com/myselfsiddharth/Flecto/issues/37
501
+ [#38]: https://github.com/myselfsiddharth/Flecto/issues/38
502
+ [#39]: https://github.com/myselfsiddharth/Flecto/issues/39
503
+ [#40]: https://github.com/myselfsiddharth/Flecto/pull/40
504
+ [#66]: https://github.com/myselfsiddharth/Flecto/issues/66
505
+ [#67]: https://github.com/myselfsiddharth/Flecto/issues/67
506
+ [#68]: https://github.com/myselfsiddharth/Flecto/issues/68
507
+ [#69]: https://github.com/myselfsiddharth/Flecto/issues/69
508
+ [#70]: https://github.com/myselfsiddharth/Flecto/issues/70
509
+ [#71]: https://github.com/myselfsiddharth/Flecto/issues/71
510
+ [#72]: https://github.com/myselfsiddharth/Flecto/issues/72
511
+ [#73]: https://github.com/myselfsiddharth/Flecto/issues/73
512
+ [#74]: https://github.com/myselfsiddharth/Flecto/issues/74
513
+ [#75]: https://github.com/myselfsiddharth/Flecto/issues/75
514
+ [#76]: https://github.com/myselfsiddharth/Flecto/issues/76
515
+ [#77]: https://github.com/myselfsiddharth/Flecto/issues/77
516
+ [#78]: https://github.com/myselfsiddharth/Flecto/issues/78
517
+ [#79]: https://github.com/myselfsiddharth/Flecto/issues/79
518
+ [#88]: https://github.com/myselfsiddharth/Flecto/issues/88
519
+ [#92]: https://github.com/myselfsiddharth/Flecto/issues/92
520
+ [#93]: https://github.com/myselfsiddharth/Flecto/issues/93
521
+ [#94]: https://github.com/myselfsiddharth/Flecto/issues/94
522
+ [#95]: https://github.com/myselfsiddharth/Flecto/issues/95
523
+ [#96]: https://github.com/myselfsiddharth/Flecto/issues/96
524
+ [#97]: https://github.com/myselfsiddharth/Flecto/issues/97
525
+ [#98]: https://github.com/myselfsiddharth/Flecto/issues/98
526
+ [#103]: https://github.com/myselfsiddharth/Flecto/issues/103
527
+ [#104]: https://github.com/myselfsiddharth/Flecto/issues/104
528
+ [#107]: https://github.com/myselfsiddharth/Flecto/pull/107
529
+ [#108]: https://github.com/myselfsiddharth/Flecto/pull/108
530
+ [#109]: https://github.com/myselfsiddharth/Flecto/issues/109
531
+ [#110]: https://github.com/myselfsiddharth/Flecto/issues/110
532
+ [Keep a Changelog]: https://keepachangelog.com/en/1.1.0/
533
+ [Semantic Versioning]: https://semver.org/spec/v2.0.0.html