flecto 4.1.1 → 4.2.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,97 @@ The format is based on [Keep a Changelog], and this project adheres to
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.2.0] - 2026-10-05
11
+
12
+ The first npm release since 4.1.1. The `v4.1.2` tag moved the documented Action
13
+ pins but its npm publish failed (the version was never bumped), so the fixes
14
+ below reach npm for the first time here.
15
+
16
+ ### Added
17
+
18
+ - **`--new-files added`: a pull request that adds a config file no longer has to
19
+ fail the gate.** `flecto ci --snapshot-ref` stopped with "Failed to resolve
20
+ snapshot baseline" whenever a target was not in the base commit, so any PR
21
+ that added a values file or a manifest matched by the gate's glob went red,
22
+ whatever `--fail-on` said. With `--new-files added` (Action input
23
+ `new-files: added`), such a file is reported as new, every key `added`, and
24
+ policies still run on it.
25
+
26
+ The default is unchanged and still fails closed, and the error now names the
27
+ flag. A renamed file looks exactly like a new one, and would turn every
28
+ `changed` into an `added`, which the Action's `policy,error` does not gate,
29
+ so this is opt-in and refused from `.flectorc`, as `snapshotRef` is.
30
+
31
+ Found by replaying Flecto over a real repository's infra history, where one
32
+ commit in twelve added a values file.
33
+
34
+ ### Changed
35
+
36
+ - **`KEY=VALUE` lists diff by KEY, so one inserted variable is one addition**
37
+ ([#226]). Compose writes `environment`, `labels` and `build.args` as a map or
38
+ as a list of `KEY=VALUE` strings, and means the same by both. The list form
39
+ diffed by position: a real pull request that inserted ten `OIDC_*` variables
40
+ read as 31 `changed` rows, each pairing two unrelated variables. It now reads
41
+ as 9 additions under `environment["OIDC_ISSUER_URL"]` and so on, and the
42
+ language server places diagnostics on the right line.
43
+
44
+ Paths for these lists change from `environment[3]` to `environment["KEY"]`.
45
+ `--no-array-id` restores index paths. A list counts only when every item is a
46
+ `KEY=VALUE` or bare `KEY` with unique keys and at least one `=`, so `command:
47
+ [python, app.py]` and `args: [--a=1]` keep their order-sensitive diff.
48
+
49
+ [#226]: https://github.com/myselfsiddharth/Flecto/issues/226
50
+
51
+ ### Fixed
52
+
53
+ - **A connection string whose password is a reference is no longer reported as
54
+ a leaked credential** ([#225]). `postgres://app:$(DB_PASSWORD)@db/app`, the
55
+ Kubernetes form of env-var expansion, and `{{ .Values.x }}` Helm expressions
56
+ were flagged `url-credentials` (error), although `${DB_PASSWORD}` already
57
+ was not. A literal password in the same position is still caught. Found on
58
+ a real repository's ConfigMap during outreach.
59
+
60
+ - **The symlinked-baseline check never fired below the repository root.** It
61
+ asked `git ls-tree` for a root-relative path from inside the file's own
62
+ directory, so for `config/app.yaml` it looked up `config/config/app.yaml`,
63
+ found nothing, and let the baseline through as the link's target path. Only
64
+ files at the root were protected.
65
+
66
+ - **Pointing Flecto at a Helm chart says so, instead of reporting broken YAML**
67
+ ([#210]). `flecto ci "helm/**/*.yaml"` is the first thing a Helm user tries,
68
+ and it failed with a syntax error pointing inside a chart template that is
69
+ perfectly valid — sending them to debug their chart rather than their command.
70
+
71
+ A YAML file that **fails to parse** and carries Go template delimiters now
72
+ reports what it is and the two ways to read it: render the chart, or point at
73
+ `values.yaml`. The check runs only after a parse failure, so valid YAML
74
+ holding `{{ ... }}` in a string — a Prometheus alert rule, say — is untouched.
75
+
76
+ Found by running Flecto against real repositories instead of our own fixtures.
77
+
78
+ - **`flecto ci --format human` now explains itself** ([#211], fixed by
79
+ [@DYNOSuprovo](https://github.com/DYNOSuprovo) in [#212]). `human` is the
80
+ default for `plan` and `compare` and what `watch` prints, so reaching for it on
81
+ `ci` is the natural mistake — especially when running `ci` locally to see what
82
+ the gate will say. The old error listed the valid values without saying that
83
+ `human` was deliberately excluded, so it read as a typo or an inconsistency. It
84
+ now names the reason and both ways out: `pr-comment` to read a run, `json` to
85
+ parse it. An actual typo still gets the list.
86
+
87
+ - **A command killed by a signal is a failed delivery, not a success**
88
+ ([#185], fixed by [@nova-loop](https://github.com/nova-loop) in [#214]).
89
+ `watch --command` checked only the exit code, which is `null` when a signal
90
+ ends the process, so an OOM-killed or timed-out hook counted as delivered:
91
+ `--on-alert-failure` never fired and at-least-once deliveries were marked done.
92
+ The warning now names the signal.
93
+
94
+ [#210]: https://github.com/myselfsiddharth/Flecto/issues/210
95
+ [#185]: https://github.com/myselfsiddharth/Flecto/issues/185
96
+ [#211]: https://github.com/myselfsiddharth/Flecto/issues/211
97
+ [#212]: https://github.com/myselfsiddharth/Flecto/pull/212
98
+ [#214]: https://github.com/myselfsiddharth/Flecto/pull/214
99
+ [#225]: https://github.com/myselfsiddharth/Flecto/issues/225
100
+
10
101
  ## [4.1.1] - 2026-09-29
11
102
 
12
103
  ### Fixed
@@ -20,8 +111,6 @@ The format is based on [Keep a Changelog], and this project adheres to
20
111
  The rest of the pitch — that Flecto never runs `terraform`, `helm`, or `sops`,
21
112
  and never decrypts — lives in the README, which has room for it.
22
113
 
23
- ### Fixed
24
-
25
114
  - **Documented Action pins moved from `@v4.0.0` to `@v4.1.0`.** Tags are
26
115
  immutable, and the fix that stopped the bundled Actions installing the pre-4.0
27
116
  CLI shipped *in* 4.1.0 — so while the examples said `@v4.0.0` they pointed at a
@@ -1322,7 +1411,8 @@ fixed — those runs were never actually gated — but the failure is new.
1322
1411
  - Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
1323
1412
  continuing with no policies.
1324
1413
 
1325
- [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.1...HEAD
1414
+ [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.2.0...HEAD
1415
+ [4.2.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.1...v4.2.0
1326
1416
  [4.1.1]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.0...v4.1.1
1327
1417
  [4.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.0.0...v4.1.0
1328
1418
  [4.0.0]: https://github.com/myselfsiddharth/Flecto/compare/v3.1.0...v4.0.0
package/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
  </p>
7
7
 
8
8
  <p align="center">
9
+ <a href="https://github.com/marketplace/actions/flecto-pr-risk"><img alt="GitHub Marketplace" src="https://img.shields.io/badge/marketplace-Flecto%20PR%20Risk-34d399?style=flat-square&logo=github&labelColor=0b1220"/></a>
9
10
  <a href="https://www.npmjs.com/package/flecto"><img alt="npm" src="https://img.shields.io/npm/v/flecto?style=flat-square&color=34d399&labelColor=0b1220"/></a>
10
11
  <a href="https://github.com/myselfsiddharth/Flecto/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/myselfsiddharth/Flecto/ci.yml?branch=main&style=flat-square&label=CI&labelColor=0b1220"/></a>
11
12
  <a href="LICENSE"><img alt="MIT" src="https://img.shields.io/badge/license-MIT-8fa3bf?style=flat-square&labelColor=0b1220"/></a>
@@ -74,6 +75,10 @@ aws_db_instance.main.#action
74
75
 
75
76
  ## Add it in 60 seconds
76
77
 
78
+ Flecto PR Risk is on the
79
+ [GitHub Marketplace](https://github.com/marketplace/actions/flecto-pr-risk), so
80
+ `myselfsiddharth/Flecto@v4.1.2` is the whole reference.
81
+
77
82
  **Terraform** — point it at the plan JSON:
78
83
 
79
84
  ```yaml
@@ -86,7 +91,7 @@ steps:
86
91
  - run: |
87
92
  terraform plan -out=tf.plan
88
93
  terraform show -json tf.plan > plan.json
89
- - uses: myselfsiddharth/Flecto/.github/actions/flecto-pr-risk@v4.1.0
94
+ - uses: myselfsiddharth/Flecto@v4.1.2
90
95
  with:
91
96
  terraform-plan: plan.json
92
97
  fail-on: error
@@ -104,7 +109,7 @@ steps:
104
109
  with:
105
110
  fetch-depth: 0
106
111
  - run: helm template ./chart > rendered.yaml
107
- - uses: myselfsiddharth/Flecto/.github/actions/flecto-pr-risk@v4.1.0
112
+ - uses: myselfsiddharth/Flecto@v4.1.2
108
113
  with:
109
114
  targets: rendered.yaml
110
115
  policies: kubernetes
package/index.js CHANGED
@@ -385,18 +385,51 @@ function gitRepoRelativePath(filePath) {
385
385
  * @returns {boolean}
386
386
  */
387
387
  function baselineEntryIsSymlink(commit, rel, dir) {
388
+ return baselineEntryMode(commit, rel, dir) === '120000';
389
+ }
390
+
391
+ /**
392
+ * The git mode of the entry at this path in this commit: '' when the commit
393
+ * has no such path, null when git could not answer.
394
+ * @param {string} commit
395
+ * @param {string} rel
396
+ * @param {string} dir
397
+ * @returns {string | null}
398
+ */
399
+ function baselineEntryMode(commit, rel, dir) {
388
400
  try {
389
- const entry = execFileSync(
401
+ // `rel` is relative to the repository root, and without --full-tree
402
+ // ls-tree resolves it against `dir`: for any file below the root it looked
403
+ // up `sub/sub/file`, found nothing, and the symlink check never fired.
404
+ return execFileSync(
390
405
  'git',
391
- ['-C', dir, 'ls-tree', '--format=%(objectmode)', '--end-of-options', commit, '--', rel],
406
+ ['-C', dir, 'ls-tree', '--full-tree', '--format=%(objectmode)', '--end-of-options', commit, '--', rel],
392
407
  { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
393
408
  ).trim();
394
- return entry === '120000';
395
409
  } catch {
396
- return false;
410
+ return null;
397
411
  }
398
412
  }
399
413
 
414
+ /**
415
+ * Returned in place of a baseline state when `--new-files added` is set and the
416
+ * baseline commit does not contain the file. A distinct value rather than an
417
+ * empty document, so the caller cannot mistake "this file is new" for "this
418
+ * file was empty" (#186), and has to say which one it is reporting.
419
+ */
420
+ const NEW_FILE = Symbol('new file');
421
+
422
+ /**
423
+ * An empty baseline of the same shape as the head document, so every key
424
+ * diffs as `added`. Not `parseContent(path, '')`: JSON has no empty document.
425
+ * @param {unknown} after
426
+ */
427
+ function emptyBaselineLike(after) {
428
+ if (Array.isArray(after)) return [];
429
+ if (after !== null && typeof after === 'object') return {};
430
+ return null;
431
+ }
432
+
400
433
  /**
401
434
  * Resolve symlinks where possible, falling back to the input when the path does
402
435
  * not exist on disk.
@@ -518,7 +551,7 @@ function isExplicitPath(value) {
518
551
  || value.startsWith('..\\');
519
552
  }
520
553
 
521
- function readSnapshotStateFromRef(filePath, snapshotRef, store, snapshotFile) {
554
+ function readSnapshotStateFromRef(filePath, snapshotRef, store, snapshotFile, { newFiles = 'fail' } = {}) {
522
555
  if (snapshotRef === '') {
523
556
  // The same unset-CI-variable case readSnapshotFile refuses. Falling back to
524
557
  // the store here would compare against something the operator did not
@@ -576,6 +609,19 @@ function readSnapshotStateFromRef(filePath, snapshotRef, store, snapshotFile) {
576
609
  }
577
610
 
578
611
  const rel = gitRepoRelativePath(filePath);
612
+ // A file the baseline commit does not have is a file this change adds. The
613
+ // default is still to fail closed: a rename reads as a new file too, and
614
+ // turns every `changed` into an `added` that the Action's default --fail-on
615
+ // does not gate. `--new-files added` is the explicit, CLI-only opt-in. The
616
+ // commit is the operator's, so the change under review cannot fake absence.
617
+ if (baselineEntryMode(commit, rel, repoDir) === '') {
618
+ if (newFiles === 'added') return NEW_FILE;
619
+ throw new Error(
620
+ `"${rel}" is not in ${ref}, so there is no baseline to diff it against.`
621
+ + ' If this change adds the file, pass --new-files added to report every key as added'
622
+ + ' (policies still run); the default fails closed because a rename looks the same.',
623
+ );
624
+ }
579
625
  if (baselineEntryIsSymlink(commit, rel, repoDir)) {
580
626
  throw new Error(
581
627
  `"${rel}" is a symbolic link in ${ref}, so the baseline there is a path, not a configuration.`
@@ -1309,6 +1355,7 @@ program
1309
1355
  .option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
1310
1356
  .option('--snapshot-ref <ref>', 'Baseline git revision (a snapshot path also works if it is path-shaped)')
1311
1357
  .option('--snapshot-file <path>', 'Baseline snapshot file, never consulted as a git revision')
1358
+ .option('--new-files <mode>', 'A file not in the --snapshot-ref commit: fail (default) | added (every key reported as added)')
1312
1359
  .option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
1313
1360
  .option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
1314
1361
  .option('--format <type>', 'Output format: json | ndjson | sarif | github-annotations | pr-comment', 'json')
@@ -1347,9 +1394,23 @@ program
1347
1394
 
1348
1395
  const ignorePaths = parseCsv(effective.ignore);
1349
1396
  const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
1397
+ const newFiles = String(effective.newFiles ?? 'fail');
1398
+ if (newFiles !== 'fail' && newFiles !== 'added') {
1399
+ throw new Error(`--new-files must be fail or added (got ${newFiles})`);
1400
+ }
1350
1401
  const format = String(effective.format ?? 'json');
1351
- if (!['json', 'ndjson', 'sarif', 'github-annotations', 'pr-comment'].includes(format)) {
1352
- throw new Error('--format must be json, ndjson, sarif, github-annotations, or pr-comment');
1402
+ if (format === 'human') {
1403
+ throw new Error(
1404
+ '--format human is not available for `ci`, whose output is meant to be consumed by a machine. Use pr-comment to read it yourself, or json to parse it. (human is the default for `plan` and `compare`.)',
1405
+ );
1406
+ }
1407
+ // Listed once: the check and the message drift apart otherwise, and the
1408
+ // message is the only place a user finds out what is allowed. The wording
1409
+ // is unchanged -- this is a dedup, not a rewording.
1410
+ const CI_FORMATS = ['json', 'ndjson', 'sarif', 'github-annotations', 'pr-comment'];
1411
+ if (!CI_FORMATS.includes(format)) {
1412
+ const listed = `${CI_FORMATS.slice(0, -1).join(', ')}, or ${CI_FORMATS.at(-1)}`;
1413
+ throw new Error(`--format must be ${listed}`);
1353
1414
  }
1354
1415
  const prCommentPost = Boolean(effective.prCommentPost);
1355
1416
  if (effective.prProvider && !PR_PROVIDER_IDS.includes(String(effective.prProvider))) {
@@ -1421,13 +1482,22 @@ program
1421
1482
  : alignStateWithStore(parseFile(filepath), snapshotStore);
1422
1483
  let before;
1423
1484
  try {
1424
- before = readSnapshotStateFromRef(filepath, effective.snapshotRef, snapshotStore, effective.snapshotFile);
1485
+ before = readSnapshotStateFromRef(
1486
+ filepath, effective.snapshotRef, snapshotStore, effective.snapshotFile, { newFiles },
1487
+ );
1425
1488
  } catch (err) {
1426
1489
  throw new Error(
1427
1490
  `Failed to resolve snapshot baseline for "${filepath}"` +
1428
1491
  `${effective.snapshotRef ? ` (ref: ${effective.snapshotRef})` : ''}: ${err.message}`
1429
1492
  );
1430
1493
  }
1494
+ if (before === NEW_FILE) {
1495
+ renderWarn(
1496
+ `${relative(cwd, filepath).replaceAll('\\', '/')} is not in ${effective.snapshotRef}: reporting it as a new file,`
1497
+ + ' every key added (--new-files added).',
1498
+ );
1499
+ before = emptyBaselineLike(after);
1500
+ }
1431
1501
  const events = diffTrees(before, after, dOpts);
1432
1502
  const rawFindings = await evaluatePolicies(events, {
1433
1503
  cwd,
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "provenance": true
6
6
  },
7
- "version": "4.1.1",
7
+ "version": "4.2.0",
8
8
  "description": "Reads your Terraform plan and Kubernetes changes and posts a plain-English risk summary on every pull request, blocking the dangerous ones",
9
9
  "license": "MIT",
10
10
  "keywords": [
package/src/alerter.js CHANGED
@@ -204,7 +204,12 @@ export function runCommand(command, envelope) {
204
204
  renderWarn(`Command failed to start: ${err.message}`);
205
205
  settle(false);
206
206
  });
207
- child.on('close', (code) => {
207
+ child.on('close', (code, signal) => {
208
+ if (signal) {
209
+ renderWarn(`Command failed (signal ${signal}): ${command}`);
210
+ settle(false);
211
+ return;
212
+ }
208
213
  if (code && code !== 0) {
209
214
  renderWarn(`Command failed (exit ${code}): ${command}`);
210
215
  settle(false);
package/src/config.js CHANGED
@@ -519,7 +519,11 @@ export function assertSnapshotRefFromCli(effective, cliOverrides) {
519
519
  if (rcBaselineAllowed()) return;
520
520
  // `snapshotFile` picks the baseline just as directly, so it is gated with it
521
521
  // rather than left as the way around it.
522
- for (const option of ['snapshotRef', 'snapshotFile']) {
522
+ // `newFiles` decides what a file missing from the baseline is compared
523
+ // against, and a rename is a missing file: from .flectorc it would let a pull
524
+ // request move prod.yaml and have every `changed` reported as `added`.
525
+ const flags = { snapshotRef: 'snapshot-ref', snapshotFile: 'snapshot-file', newFiles: 'new-files' };
526
+ for (const [option, flag] of Object.entries(flags)) {
523
527
  if (effective[option] === undefined || cliOverrides[option] !== undefined) continue;
524
528
  throw new Error(
525
529
  `Refusing "${option}" declared in .flectorc: it chooses the baseline every change is`
@@ -527,7 +531,7 @@ export function assertSnapshotRefFromCli(effective, cliOverrides) {
527
531
  + ' pointing it at "HEAD", or at a file it committed, compares every file against itself'
528
532
  + ' and exits 0.\n'
529
533
  + `Declared: ${JSON.stringify(effective[option])}\n`
530
- + `Pass --${option === 'snapshotRef' ? 'snapshot-ref' : 'snapshot-file'} on the command line`
534
+ + `Pass --${flag} on the command line`
531
535
  + ' instead, or set FLECTO_ALLOW_RC_BASELINE=1 if this config is trusted.',
532
536
  );
533
537
  }
package/src/differ.js CHANGED
@@ -361,6 +361,48 @@ function identityMap(items, idKey) {
361
361
  return map;
362
362
  }
363
363
 
364
+ // `KEY=VALUE`, or a bare `KEY` (Compose's pass-through from the host).
365
+ export const ASSIGNMENT_RE = /^([A-Za-z_][A-Za-z0-9_.-]*)(?:=|$)/;
366
+
367
+ /**
368
+ * Key a list of `KEY=VALUE` strings by KEY, or return null when it is not one.
369
+ *
370
+ * Compose writes `environment`, `labels` and `build.args` either as a map or as
371
+ * a list of assignments, and means the same thing by both. Diffed by position,
372
+ * one inserted variable turned every later line into a `changed` pairing two
373
+ * unrelated variables (#226). At least one item must carry an `=`, so a plain
374
+ * word list such as `command: [python, app.py]` keeps its order-sensitive diff.
375
+ * @param {unknown[]} items
376
+ * @returns {Map<string, { value: unknown, index: number }> | null}
377
+ */
378
+ function assignmentMap(items) {
379
+ /** @type {Map<string, { value: unknown, index: number }>} */
380
+ const map = new Map();
381
+ for (let i = 0; i < items.length; i++) {
382
+ const item = items[i];
383
+ if (typeof item !== 'string') return null;
384
+ const match = ASSIGNMENT_RE.exec(item);
385
+ if (!match || map.has(match[1])) return null;
386
+ map.set(match[1], { value: item, index: i });
387
+ }
388
+ return map;
389
+ }
390
+
391
+ /**
392
+ * Both sides are assignment lists and at least one item assigns a value.
393
+ * @param {unknown[]} before
394
+ * @param {unknown[]} after
395
+ * @returns {{ beforeMap: Map<string, { value: unknown, index: number }>, afterMap: Map<string, { value: unknown, index: number }> } | null}
396
+ */
397
+ function assignmentMaps(before, after) {
398
+ const beforeMap = assignmentMap(before);
399
+ const afterMap = beforeMap && assignmentMap(after);
400
+ if (!beforeMap || !afterMap) return null;
401
+ const assigns = (item) => typeof item === 'string' && item.includes('=');
402
+ if (!before.some(assigns) && !after.some(assigns)) return null;
403
+ return { beforeMap, afterMap };
404
+ }
405
+
364
406
  /**
365
407
  * Select a configured identity key, or auto-detect id then name.
366
408
  * @param {unknown[]} before
@@ -425,10 +467,11 @@ function arraySignature(value) {
425
467
  */
426
468
  function diffArrays(before, after, basePath, events, options = {}, ancestors = newAncestors()) {
427
469
  const idKey = resolveArrayIdKey(before, after, options);
470
+ const assignments = !idKey && options.arrayIdentity !== false ? assignmentMaps(before, after) : null;
428
471
 
429
- if (idKey) {
430
- const beforeMap = identityMap(before, idKey);
431
- const afterMap = identityMap(after, idKey);
472
+ if (idKey || assignments) {
473
+ const { beforeMap, afterMap } = assignments
474
+ ?? { beforeMap: identityMap(before, idKey), afterMap: identityMap(after, idKey) };
432
475
 
433
476
  if (beforeMap && afterMap) {
434
477
  for (const [key, afterItem] of afterMap) {
package/src/parser.js CHANGED
@@ -435,6 +435,30 @@ export function yamlDocumentKeys(docs) {
435
435
  * @returns {unknown}
436
436
  * @throws {Error} on unsupported format or parse failure
437
437
  */
438
+ /**
439
+ * Whether a file that failed to parse as YAML looks like a Helm chart template.
440
+ *
441
+ * Only ever consulted **after** a parse failure, which is what keeps it from
442
+ * firing on valid YAML that happens to contain braces -- a Prometheus rule
443
+ * holding `{{ $labels.instance }}` in a string parses fine and never reaches
444
+ * here. Go template delimiters in a file that did not parse are close to
445
+ * conclusive, and `{{-`, `nindent`, `include "` and a `templates/` path are the
446
+ * shapes that only a chart has.
447
+ *
448
+ * @param {string} raw
449
+ * @param {string} filepath
450
+ * @returns {boolean}
451
+ */
452
+ function looksLikeHelmTemplate(raw, filepath) {
453
+ if (!/\{\{/u.test(raw) || !/\}\}/u.test(raw)) return false;
454
+ const path = filepath.replace(/\\/gu, '/');
455
+ return /\/templates\//u.test(path)
456
+ || /\{\{-/u.test(raw)
457
+ || /\|\s*nindent\b/u.test(raw)
458
+ || /\{\{[^}]*\binclude\s+"/u.test(raw)
459
+ || /\{\{[^}]*\.Values\./u.test(raw);
460
+ }
461
+
438
462
  export function parseContent(filepath, raw) {
439
463
  const ext = extname(filepath).toLowerCase();
440
464
  const envLike = isEnvFilename(filepath);
@@ -469,6 +493,19 @@ export function parseContent(filepath, raw) {
469
493
  parsed = TOML.parse(raw);
470
494
  }
471
495
  } catch (err) {
496
+ // A chart template is not YAML, and saying so beats a column number. Without
497
+ // this, `flecto ci "helm/**/*.yaml"` -- the first thing a Helm user tries --
498
+ // reports a syntax error inside a file that is perfectly valid, and sends
499
+ // them to debug their chart instead of their command.
500
+ if ((ext === '.yaml' || ext === '.yml') && looksLikeHelmTemplate(raw, filepath)) {
501
+ throw new Error(
502
+ `"${filepath}" looks like a Helm template, not YAML.\n` +
503
+ 'Flecto reads rendered manifests, so render the chart first:\n' +
504
+ ' helm template ./chart > rendered.yaml && flecto ci rendered.yaml\n' +
505
+ 'Or point Flecto at the values file, which is plain YAML:\n' +
506
+ ' flecto ci chart/values.yaml'
507
+ );
508
+ }
472
509
  const lineMatch = err.message?.match(/line (\d+)/i);
473
510
  const lineInfo = lineMatch ? ` (line ${lineMatch[1]})` : '';
474
511
  throw new Error(
package/src/positions.js CHANGED
@@ -3,6 +3,7 @@ import yaml from 'js-yaml';
3
3
 
4
4
  import { isArmoredAgeFile } from './encrypted.js';
5
5
  import { isEnvFilename, isIniFilename, stripJsonComments, yamlDocumentKeys } from './parser.js';
6
+ import { ASSIGNMENT_RE } from './differ.js';
6
7
 
7
8
  /**
8
9
  * Where a config path lives in the source text (#142).
@@ -247,6 +248,12 @@ function itemByIdentity(items, quoted, arrayIdKey) {
247
248
  if (String(id) === target) matches.add(item);
248
249
  }
249
250
  }
251
+ // A `KEY=VALUE` list (Compose `environment`, `labels`) is keyed by KEY.
252
+ if (matches.size === 0 && !arrayIdKey) {
253
+ for (const item of items) {
254
+ if (typeof item.value === 'string' && ASSIGNMENT_RE.exec(item.value)?.[1] === target) matches.add(item);
255
+ }
256
+ }
250
257
  return matches.size === 1 ? [...matches][0] : null;
251
258
  }
252
259
 
package/src/secrets.js CHANGED
@@ -78,8 +78,13 @@ const URL_CREDENTIALS_RE = /[a-z][a-z0-9+.-]{0,32}:\/\/[^\s/:@]+:([^\s/@]+)@/gi;
78
78
  * Values that only *reference* a secret. Redacting these adds noise and, worse,
79
79
  * would make the policy rule fire on configs that correctly keep secrets out of
80
80
  * the file.
81
+ *
82
+ * `$(NAME)` is Kubernetes' env-var expansion in a container's env and args, and
83
+ * `{{ ... }}` a Helm or Jinja template expression: in a connection string such
84
+ * as `postgres://app:$(DB_PASSWORD)@db/app` both are the reference, not the
85
+ * credential.
81
86
  */
82
- const PLACEHOLDER_RE = /^(?:\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z0-9_]+%|<[^>]*>|\*+)$/;
87
+ const PLACEHOLDER_RE = /^(?:\$\{[^}]*\}|\$\([A-Za-z_][A-Za-z0-9_]*\)|\{\{[^}]*\}\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z0-9_]+%|<[^>]*>|\*+)$/;
83
88
 
84
89
  /**
85
90
  * High-entropy fallback gates. Every one of these must pass: