flecto 2.1.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/package.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "access": "public",
5
5
  "provenance": true
6
6
  },
7
- "version": "2.1.0",
8
- "description": "Flecto semantic config watcher that reports meaningful changes in plain English",
7
+ "version": "3.0.0",
8
+ "description": "Flecto \u2014 semantic config watcher that reports meaningful changes in plain English",
9
9
  "license": "MIT",
10
10
  "keywords": [
11
11
  "flecto",
@@ -43,6 +43,7 @@
43
43
  "scripts": {
44
44
  "test": "node --test test/*.test.js",
45
45
  "test:watch": "node --test --watch test/*.test.js",
46
+ "bench": "node bench/run.js",
46
47
  "pack:check": "npm pack --dry-run"
47
48
  },
48
49
  "dependencies": {
@@ -7,6 +7,7 @@
7
7
  "required": ["rules"],
8
8
  "properties": {
9
9
  "id": { "type": "string", "minLength": 1 },
10
+ "expandSubtrees": { "type": "boolean" },
10
11
  "rules": {
11
12
  "type": "array",
12
13
  "items": { "$ref": "#/$defs/rule" }
@@ -41,6 +42,8 @@
41
42
  "afterIn": { "type": "array" },
42
43
  "beforeTruthy": { "const": true },
43
44
  "afterTruthy": { "const": true },
45
+ "beforeLooksSecret": { "const": true },
46
+ "afterLooksSecret": { "const": true },
44
47
  "afterMatches": { "type": "string" },
45
48
  "numericJump": {
46
49
  "type": "object",
@@ -89,6 +92,8 @@
89
92
  "afterIn": { "type": "array" },
90
93
  "beforeTruthy": { "const": true },
91
94
  "afterTruthy": { "const": true },
95
+ "beforeLooksSecret": { "const": true },
96
+ "afterLooksSecret": { "const": true },
92
97
  "afterMatches": { "type": "string" },
93
98
  "numericJump": { "$ref": "#/$defs/numericJump" },
94
99
  "numericDelta": { "$ref": "#/$defs/numericDelta" }
package/src/alerter.js CHANGED
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
2
2
  import { mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
3
3
  import { resolve } from 'path';
4
4
  import { renderWarn } from './renderer.js';
5
+ import { formatWebhookPayload } from './notifiers.js';
5
6
 
6
7
  const ALERT_TMP_DIR = '.flecto-tmp';
7
8
  const ALERT_QUEUE_DIR = '.flecto-queue';
@@ -135,11 +136,19 @@ async function flushPersistentQueue(deliver) {
135
136
  /**
136
137
  * @param {string} url
137
138
  * @param {import('./envelope.js').FlectoEnvelope} envelope
138
- * @param {{ headers?: Record<string, string>, timeoutMs?: number, retries?: number }} [options]
139
+ * @param {{
140
+ * headers?: Record<string, string>,
141
+ * timeoutMs?: number,
142
+ * retries?: number,
143
+ * format?: import('./notifiers.js').WebhookFormat
144
+ * }} [options]
139
145
  * @returns {Promise<boolean>}
140
146
  */
141
147
  export async function postWebhook(url, envelope, options = {}) {
142
- const body = JSON.stringify(envelope);
148
+ // `flecto` (the default) returns the envelope untouched, so the body is
149
+ // byte-identical to what has always been posted. Chat formats reshape only
150
+ // the body: headers, retries, and delivery modes are unchanged.
151
+ const body = JSON.stringify(formatWebhookPayload(envelope, options.format ?? 'flecto'));
143
152
  const timeoutMs = options.timeoutMs ?? 5_000;
144
153
  const retries = options.retries ?? 2;
145
154
  const headers = {
@@ -186,7 +195,13 @@ export async function postWebhook(url, envelope, options = {}) {
186
195
  }
187
196
 
188
197
  /**
189
- * @param {{ webhook?: string, webhookHeaders?: Record<string, string>, webhookTimeoutMs?: number, webhookRetries?: number }} options
198
+ * @param {{
199
+ * webhook?: string,
200
+ * webhookHeaders?: Record<string, string>,
201
+ * webhookTimeoutMs?: number,
202
+ * webhookRetries?: number,
203
+ * webhookFormat?: import('./notifiers.js').WebhookFormat
204
+ * }} options
190
205
  * @param {import('./envelope.js').FlectoEnvelope} envelope
191
206
  */
192
207
  async function deliverWebhook(options, envelope) {
@@ -195,6 +210,7 @@ async function deliverWebhook(options, envelope) {
195
210
  headers: options.webhookHeaders,
196
211
  timeoutMs: options.webhookTimeoutMs,
197
212
  retries: options.webhookRetries,
213
+ format: options.webhookFormat,
198
214
  });
199
215
  }
200
216
 
@@ -217,6 +233,7 @@ function applyFailurePolicy(options, ok) {
217
233
  * webhookHeaders?: Record<string, string>,
218
234
  * webhookTimeoutMs?: number,
219
235
  * webhookRetries?: number,
236
+ * webhookFormat?: import('./notifiers.js').WebhookFormat,
220
237
  * deliveryMode?: 'best-effort' | 'at-least-once',
221
238
  * onAlertFailure?: 'warn' | 'exit' | 'retry'
222
239
  * }} options
package/src/config.js CHANGED
@@ -1,9 +1,14 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'fs';
1
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
2
2
  import { resolve } from 'path';
3
3
  import fg from 'fast-glob';
4
4
  import yaml from 'js-yaml';
5
+ import { isEnvFilename } from './parser.js';
5
6
 
6
7
  const RC_CANDIDATES = ['.flectorc', '.flectorc.json', '.flectorc.yaml', '.flectorc.yml'];
8
+ const COMPOSE_FILENAMES = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
9
+ const CONFIG_DIR_PATTERN = 'config/**/*.{yaml,yml,json,toml,ini}';
10
+ const ENV_FILE_PATTERNS = ['.env', '.env.*', '*.env'];
11
+ const GENERIC_FILE_PATTERNS = [CONFIG_DIR_PATTERN, ...ENV_FILE_PATTERNS];
7
12
 
8
13
  /**
9
14
  * @typedef {{
@@ -13,6 +18,15 @@ const RC_CANDIDATES = ['.flectorc', '.flectorc.json', '.flectorc.yaml', '.flecto
13
18
  * include?: string[],
14
19
  * exclude?: string[]
15
20
  * }} FlectoRc
21
+ *
22
+ * @typedef {{
23
+ * id: string,
24
+ * evidence: string[],
25
+ * pack: string | null,
26
+ * summary: string
27
+ * }} StackSignal
28
+ *
29
+ * @typedef {{ signals: StackSignal[], packs: string[], files: string[] }} StackDetection
16
30
  */
17
31
 
18
32
  /**
@@ -126,13 +140,104 @@ export async function resolveFiles(input) {
126
140
  }
127
141
 
128
142
  /**
129
- * Scaffold a starter rc file if missing.
143
+ * Detect stack signals in a directory and map them to policy packs and file
144
+ * patterns. Only built-in pack ids and patterns Flecto can actually parse are
145
+ * ever returned, so a config built from this stays loadable by every command.
146
+ * Terraform files are reported as context only: `.tf` is not a supported parse
147
+ * format, and the `terraform` pack reads plan JSON rather than `.tf` sources,
148
+ * so neither belongs in a config built from a directory listing.
149
+ * @param {string} cwd
150
+ * @returns {StackDetection}
151
+ */
152
+ export function detectStack(cwd = process.cwd()) {
153
+ /** @type {StackSignal[]} */
154
+ const signals = [];
155
+ const packs = ['default'];
156
+ /** @type {string[]} */
157
+ const files = [];
158
+
159
+ let entries = [];
160
+ try {
161
+ entries = readdirSync(cwd, { withFileTypes: true });
162
+ } catch {
163
+ return { signals, packs, files };
164
+ }
165
+ const fileNames = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
166
+ const dirNames = new Set(entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name));
167
+ const fileNameSet = new Set(fileNames);
168
+
169
+ const composeFiles = COMPOSE_FILENAMES.filter((name) => fileNameSet.has(name));
170
+ if (composeFiles.length > 0) {
171
+ packs.push('compose');
172
+ files.push(...composeFiles);
173
+ signals.push({
174
+ id: 'compose',
175
+ evidence: composeFiles,
176
+ pack: 'compose',
177
+ summary: `Detected ${composeFiles.join(', ')} → enabled the \`compose\` policy pack and watched ${composeFiles.length === 1 ? 'it' : 'them'}`,
178
+ });
179
+ }
180
+
181
+ if (fileNameSet.has('package.json')) {
182
+ packs.push('node-runtime');
183
+ files.push('package.json');
184
+ signals.push({
185
+ id: 'node',
186
+ evidence: ['package.json'],
187
+ pack: 'node-runtime',
188
+ summary: 'Detected package.json → enabled the `node-runtime` policy pack and watched it',
189
+ });
190
+ }
191
+
192
+ const terraformFiles = fileNames.filter((name) => name.toLowerCase().endsWith('.tf')).sort();
193
+ if (terraformFiles.length > 0) {
194
+ signals.push({
195
+ id: 'terraform',
196
+ evidence: terraformFiles,
197
+ pack: null,
198
+ summary: `Detected Terraform files (${terraformFiles.join(', ')}) → .tf is not a parseable format, so nothing was enabled; run "flecto plan" on "terraform show -json" output to use the terraform pack`,
199
+ });
200
+ }
201
+
202
+ if (dirNames.has('config')) {
203
+ files.push(CONFIG_DIR_PATTERN);
204
+ signals.push({
205
+ id: 'config-dir',
206
+ evidence: ['config/'],
207
+ pack: null,
208
+ summary: `Detected config/ → watched ${CONFIG_DIR_PATTERN}`,
209
+ });
210
+ }
211
+
212
+ const envFiles = fileNames.filter((name) => isEnvFilename(name)).sort();
213
+ if (envFiles.length > 0) {
214
+ files.push(...ENV_FILE_PATTERNS);
215
+ signals.push({
216
+ id: 'dotenv',
217
+ evidence: envFiles,
218
+ pack: null,
219
+ summary: `Detected ${envFiles.join(', ')} → watched ${ENV_FILE_PATTERNS.join(', ')}`,
220
+ });
221
+ }
222
+
223
+ return { signals, packs, files: [...new Set(files)] };
224
+ }
225
+
226
+ /**
227
+ * Scaffold a starter rc file if missing, pre-selecting policy packs and file
228
+ * patterns from the stack signals found in `cwd`. Never overwrites an existing
229
+ * config: any of the four `.flectorc` candidates is reported back untouched.
130
230
  * @param {string} cwd
131
- * @returns {string}
231
+ * @returns {{ path: string, created: boolean, detection: StackDetection }}
132
232
  */
133
233
  export function initRcFile(cwd = process.cwd()) {
234
+ const detection = detectStack(cwd);
235
+ const existingPath = RC_CANDIDATES
236
+ .map((candidate) => resolve(cwd, candidate))
237
+ .find((candidate) => existsSync(candidate));
238
+ if (existingPath) return { path: existingPath, created: false, detection };
239
+
134
240
  const path = resolve(cwd, '.flectorc.json');
135
- if (existsSync(path)) return path;
136
241
  const starter = {
137
242
  defaults: {
138
243
  mode: 'compact',
@@ -140,7 +245,7 @@ export function initRcFile(cwd = process.cwd()) {
140
245
  ignore: ['**.updated_at'],
141
246
  deliveryMode: 'best-effort',
142
247
  onAlertFailure: 'warn',
143
- policies: ['default'],
248
+ policies: detection.packs,
144
249
  plugins: [],
145
250
  arrayIdKey: null,
146
251
  arrayId: true,
@@ -151,14 +256,14 @@ export function initRcFile(cwd = process.cwd()) {
151
256
  dev: { mode: 'verbose' },
152
257
  ci: { failOn: 'policy,error' },
153
258
  prod: {
154
- policies: ['default', 'strict-prod'],
259
+ policies: [...detection.packs, 'strict-prod'],
155
260
  severityRemap: { 'pool-size-jump': 'error' },
156
261
  maskSecrets: true,
157
262
  },
158
263
  },
159
- files: ['config/**/*.{yaml,yml,json,toml,ini}', '.env', '.env.*', '*.env'],
264
+ files: detection.files.length > 0 ? detection.files : [...GENERIC_FILE_PATTERNS],
160
265
  exclude: ['**/node_modules/**'],
161
266
  };
162
267
  writeFileSync(path, JSON.stringify(starter, null, 2), 'utf8');
163
- return path;
268
+ return { path, created: true, detection };
164
269
  }
package/src/differ.js CHANGED
@@ -1,14 +1,61 @@
1
+ import { annotateEncryptedChanges } from './encrypted.js';
2
+ import { diffDocumentKeys, stripDocumentPrefix } from './documents.js';
3
+
1
4
  /**
2
5
  * @typedef {{ type: 'added' | 'removed' | 'changed', path: string, before?: unknown, after?: unknown, note?: string }} ChangeEvent
3
6
  */
4
7
 
8
+ /**
9
+ * Where an event's path carries a synthetic multi-document prefix, the same
10
+ * path without it. Recorded non-enumerably so the event stays byte-identical
11
+ * everywhere it is serialized — JSON output, webhook payloads, snapshots.
12
+ */
13
+ const SECRET_MATCH_PATH = Symbol.for('flecto.secretMatchPath');
14
+
15
+ /**
16
+ * The path to match secret-looking *key names* against.
17
+ *
18
+ * For an ordinary file this is just `event.path`. For a multi-document file it
19
+ * is the path with the document identity removed, because that identity is a
20
+ * resource name — user data — and a Deployment called `token-service` must not
21
+ * make every value inside it read as a secret. See documents.js.
22
+ * @param {ChangeEvent} event
23
+ * @returns {string}
24
+ */
25
+ export function secretMatchPath(event) {
26
+ const stripped = /** @type {Record<string | symbol, unknown>} */ (event)?.[SECRET_MATCH_PATH];
27
+ return typeof stripped === 'string' ? stripped : (event?.path ?? '');
28
+ }
29
+
30
+ /**
31
+ * @param {ChangeEvent[]} events
32
+ * @param {readonly string[]} documentKeys
33
+ * @returns {ChangeEvent[]}
34
+ */
35
+ function tagSecretMatchPaths(events, documentKeys) {
36
+ if (documentKeys.length === 0) return events;
37
+ for (const event of events) {
38
+ const stripped = stripDocumentPrefix(event.path, documentKeys);
39
+ if (stripped === event.path) continue;
40
+ Object.defineProperty(event, SECRET_MATCH_PATH, {
41
+ value: stripped,
42
+ enumerable: false,
43
+ writable: false,
44
+ configurable: true,
45
+ });
46
+ }
47
+ return events;
48
+ }
49
+
5
50
  /**
6
51
  * Checks whether a value is a plain object (not array, not null).
7
52
  * @param {unknown} v
8
53
  * @returns {v is Record<string, unknown>}
9
54
  */
10
55
  function isPlainObject(v) {
11
- return v !== null && typeof v === 'object' && !Array.isArray(v);
56
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
57
+ const prototype = Object.getPrototypeOf(v);
58
+ return prototype === Object.prototype || prototype === null;
12
59
  }
13
60
 
14
61
  /**
@@ -410,5 +457,15 @@ export function diffTrees(before, after, options = {}) {
410
457
  diffValues(before, after, '<root>', events, diffOpts);
411
458
  }
412
459
 
413
- return events.filter(e => !ignore(e.path));
460
+ // Encrypted files carry signals a key-by-key walk cannot express: the file
461
+ // gaining or losing encryption, and a MAC that moved on its own. This is a
462
+ // no-op — the same array, untouched — when neither side is encrypted.
463
+ // Ignore patterns are applied afterwards so `--ignore` silences the derived
464
+ // paths exactly like any other.
465
+ const annotated = annotateEncryptedChanges(before, after, events).filter(e => !ignore(e.path));
466
+
467
+ // Multi-document paths carry a resource name in front. Record what secret-name
468
+ // matching should look at, once, here — the renderers see events and nothing
469
+ // else, and a resource name must never decide whether a value gets masked.
470
+ return tagSecretMatchPaths(annotated, diffDocumentKeys(before, after));
414
471
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Carrying the "this tree is a multi-document wrapper" signal.
3
+ *
4
+ * A `---`-separated YAML file parses to a synthetic object keyed by document
5
+ * identity (see `parseYamlStream`), so every path inside a document gains a
6
+ * `Kind/namespace/name` prefix. Several subsystems need to know which leading
7
+ * path segments are those synthetic keys rather than real configuration keys —
8
+ * most importantly secret-name matching, which must never look at a resource
9
+ * name, because a resource *named* `token-service` is not a secret.
10
+ *
11
+ * The parser is the only thing that can answer this without guessing: it
12
+ * invented the keys. Rather than re-deriving them downstream from the shape of
13
+ * the keys — a heuristic that misfires the first time somebody writes a config
14
+ * whose top-level keys genuinely look like `Kind/ns/name` — the parser records
15
+ * them here and everything else reads them back.
16
+ *
17
+ * The record is a non-enumerable symbol property, so it is invisible to
18
+ * `Object.keys`, `JSON.stringify`, spread, and `assert.deepEqual`: a marked tree
19
+ * is byte-for-byte the same snapshot, diff, and payload it was before. The flip
20
+ * side is that it does not survive a JSON round trip, so a tree read back out of
21
+ * a snapshot file carries the keys only if the snapshot recorded them.
22
+ * `documentKeysOf` distinguishes the two: `[]` means "known not to be a
23
+ * wrapper", `null` means "provenance unknown".
24
+ */
25
+
26
+ /** Where the synthetic document keys are recorded on a parsed tree. */
27
+ const DOCUMENT_KEYS = Symbol.for('flecto.documentKeys');
28
+
29
+ /** @type {readonly string[]} */
30
+ const NONE = Object.freeze([]);
31
+
32
+ /**
33
+ * Record the synthetic document keys the parser invented for a tree.
34
+ *
35
+ * Returns the tree itself — marking is a side effect on an object Flecto just
36
+ * created, never a copy. Non-object roots (a scalar YAML document, an opaque
37
+ * age blob) cannot carry the mark and are returned unchanged.
38
+ * @template T
39
+ * @param {T} tree
40
+ * @param {readonly string[] | null | undefined} keys
41
+ * @returns {T}
42
+ */
43
+ export function withDocumentKeys(tree, keys) {
44
+ if (tree === null || typeof tree !== 'object') return tree;
45
+ Object.defineProperty(tree, DOCUMENT_KEYS, {
46
+ value: keys == null ? null : Object.freeze([...keys]),
47
+ enumerable: false,
48
+ writable: false,
49
+ configurable: true,
50
+ });
51
+ return tree;
52
+ }
53
+
54
+ /**
55
+ * The synthetic document keys recorded on a tree.
56
+ * @param {unknown} tree
57
+ * @returns {readonly string[] | null} `[]` when the tree is known to be a single
58
+ * document, `null` when nothing recorded its provenance
59
+ */
60
+ export function documentKeysOf(tree) {
61
+ if (tree === null || typeof tree !== 'object') return null;
62
+ const keys = /** @type {Record<string | symbol, unknown>} */ (tree)[DOCUMENT_KEYS];
63
+ return Array.isArray(keys) ? /** @type {readonly string[]} */ (keys) : null;
64
+ }
65
+
66
+ /**
67
+ * The document keys covering either side of a diff, longest first.
68
+ *
69
+ * A document present on only one side still prefixes the paths of its own
70
+ * additions or removals, so the union is what a path can start with. Longest
71
+ * first so that if one identity is a prefix of another — `app` and `app.web` —
72
+ * a path is attributed to the more specific one.
73
+ * @param {unknown} before
74
+ * @param {unknown} after
75
+ * @returns {readonly string[]}
76
+ */
77
+ export function diffDocumentKeys(before, after) {
78
+ const merged = [...(documentKeysOf(before) ?? NONE), ...(documentKeysOf(after) ?? NONE)];
79
+ if (merged.length === 0) return NONE;
80
+ return [...new Set(merged)].sort((a, b) => b.length - a.length);
81
+ }
82
+
83
+ /**
84
+ * Drop a leading document-identity segment from a diff path.
85
+ *
86
+ * Matching is by whole segment against the keys the parser actually invented,
87
+ * not by pattern, so `Deployment/prod/token-service.spec.replicas` becomes
88
+ * `spec.replicas` while an ordinary key that merely begins with the same text
89
+ * is left alone. Returns the path unchanged when no key applies, which is the
90
+ * single-document case and therefore the common one.
91
+ * @param {string} path
92
+ * @param {readonly string[] | null | undefined} keys tried in order; see
93
+ * {@link diffDocumentKeys} for why callers pass them longest first
94
+ * @returns {string}
95
+ */
96
+ export function stripDocumentPrefix(path, keys) {
97
+ if (!keys || keys.length === 0 || !path) return path;
98
+ for (const key of keys) {
99
+ if (!key || !path.startsWith(key)) continue;
100
+ if (path.length === key.length) return '';
101
+ const next = path[key.length];
102
+ if (next === '.') return path.slice(key.length + 1);
103
+ if (next === '[') return path.slice(key.length);
104
+ }
105
+ return path;
106
+ }