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/package.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "access": "public",
5
5
  "provenance": true
6
6
  },
7
- "version": "2.0.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",
@@ -25,7 +25,7 @@
25
25
  "url": "git+https://github.com/myselfsiddharth/Flecto.git"
26
26
  },
27
27
  "engines": {
28
- "node": ">=18"
28
+ "node": ">=20.19.0"
29
29
  },
30
30
  "type": "module",
31
31
  "main": "index.js",
@@ -37,20 +37,22 @@
37
37
  "src/**/*",
38
38
  "schemas/**/*",
39
39
  "README.md",
40
- "LICENSE"
40
+ "LICENSE",
41
+ "CHANGELOG.md"
41
42
  ],
42
43
  "scripts": {
43
44
  "test": "node --test test/*.test.js",
44
45
  "test:watch": "node --test --watch test/*.test.js",
46
+ "bench": "node bench/run.js",
45
47
  "pack:check": "npm pack --dry-run"
46
48
  },
47
49
  "dependencies": {
48
50
  "@iarna/toml": "^2.2.5",
49
51
  "chalk": "^5.3.0",
50
- "chokidar": "^3.6.0",
52
+ "chokidar": "^5.0.0",
51
53
  "commander": "^12.1.0",
52
- "dotenv": "^16.4.5",
54
+ "dotenv": "^17.4.2",
53
55
  "fast-glob": "^3.3.3",
54
- "js-yaml": "^4.1.0"
56
+ "js-yaml": "^4.3.0"
55
57
  }
56
58
  }
@@ -0,0 +1,129 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/myselfsiddharth/Flecto/schemas/flecto-policy-pack-2.0.json",
4
+ "title": "FlectoPolicyPack",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["rules"],
8
+ "properties": {
9
+ "id": { "type": "string", "minLength": 1 },
10
+ "expandSubtrees": { "type": "boolean" },
11
+ "rules": {
12
+ "type": "array",
13
+ "items": { "$ref": "#/$defs/rule" }
14
+ }
15
+ },
16
+ "$defs": {
17
+ "rule": {
18
+ "type": "object",
19
+ "additionalProperties": false,
20
+ "required": ["id", "severity"],
21
+ "properties": {
22
+ "id": { "type": "string", "minLength": 1 },
23
+ "severity": { "enum": ["info", "warn", "error"] },
24
+ "when": {
25
+ "type": "array",
26
+ "minItems": 1,
27
+ "items": { "enum": ["added", "removed", "changed"] }
28
+ },
29
+ "match": {
30
+ "type": "object",
31
+ "additionalProperties": false,
32
+ "properties": {
33
+ "path": { "type": "string" },
34
+ "pathFlags": { "type": "string" },
35
+ "pathEquals": { "type": "string" },
36
+ "pathPrefix": { "type": "string" }
37
+ }
38
+ },
39
+ "beforeEquals": true,
40
+ "afterEquals": true,
41
+ "beforeIn": { "type": "array" },
42
+ "afterIn": { "type": "array" },
43
+ "beforeTruthy": { "const": true },
44
+ "afterTruthy": { "const": true },
45
+ "beforeLooksSecret": { "const": true },
46
+ "afterLooksSecret": { "const": true },
47
+ "afterMatches": { "type": "string" },
48
+ "numericJump": {
49
+ "type": "object",
50
+ "additionalProperties": false,
51
+ "required": ["minMultiple"],
52
+ "properties": {
53
+ "minMultiple": {
54
+ "type": "number",
55
+ "exclusiveMinimum": 0
56
+ }
57
+ }
58
+ },
59
+ "numericDelta": {
60
+ "type": "object",
61
+ "additionalProperties": false,
62
+ "required": ["min"],
63
+ "properties": {
64
+ "min": {
65
+ "type": "number",
66
+ "minimum": 0
67
+ }
68
+ }
69
+ },
70
+ "allOf": {
71
+ "type": "array",
72
+ "minItems": 1,
73
+ "items": { "$ref": "#/$defs/clause" }
74
+ },
75
+ "anyOf": {
76
+ "type": "array",
77
+ "minItems": 1,
78
+ "items": { "$ref": "#/$defs/clause" }
79
+ },
80
+ "message": { "type": "string" },
81
+ "messageTemplate": { "type": "string" }
82
+ }
83
+ },
84
+ "clause": {
85
+ "type": "object",
86
+ "additionalProperties": false,
87
+ "properties": {
88
+ "match": { "$ref": "#/$defs/match" },
89
+ "beforeEquals": true,
90
+ "afterEquals": true,
91
+ "beforeIn": { "type": "array" },
92
+ "afterIn": { "type": "array" },
93
+ "beforeTruthy": { "const": true },
94
+ "afterTruthy": { "const": true },
95
+ "beforeLooksSecret": { "const": true },
96
+ "afterLooksSecret": { "const": true },
97
+ "afterMatches": { "type": "string" },
98
+ "numericJump": { "$ref": "#/$defs/numericJump" },
99
+ "numericDelta": { "$ref": "#/$defs/numericDelta" }
100
+ }
101
+ },
102
+ "match": {
103
+ "type": "object",
104
+ "additionalProperties": false,
105
+ "properties": {
106
+ "path": { "type": "string" },
107
+ "pathFlags": { "type": "string" },
108
+ "pathEquals": { "type": "string" },
109
+ "pathPrefix": { "type": "string" }
110
+ }
111
+ },
112
+ "numericJump": {
113
+ "type": "object",
114
+ "additionalProperties": false,
115
+ "required": ["minMultiple"],
116
+ "properties": {
117
+ "minMultiple": { "type": "number", "exclusiveMinimum": 0 }
118
+ }
119
+ },
120
+ "numericDelta": {
121
+ "type": "object",
122
+ "additionalProperties": false,
123
+ "required": ["min"],
124
+ "properties": {
125
+ "min": { "type": "number", "minimum": 0 }
126
+ }
127
+ }
128
+ }
129
+ }
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';
@@ -11,12 +12,13 @@ const MAX_ENV_CHANGES_CHARS = 16_000;
11
12
  let alertQueue = Promise.resolve();
12
13
 
13
14
  function enqueue(fn) {
14
- alertQueue = alertQueue
15
- .then(async () => { await fn(); })
15
+ const result = alertQueue
16
+ .then(() => fn());
17
+ alertQueue = result
16
18
  .catch((err) => {
17
19
  renderWarn(`Alert pipeline error: ${err?.message ?? String(err)}`);
18
20
  });
19
- return alertQueue;
21
+ return result;
20
22
  }
21
23
 
22
24
  /**
@@ -134,11 +136,19 @@ async function flushPersistentQueue(deliver) {
134
136
  /**
135
137
  * @param {string} url
136
138
  * @param {import('./envelope.js').FlectoEnvelope} envelope
137
- * @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]
138
145
  * @returns {Promise<boolean>}
139
146
  */
140
147
  export async function postWebhook(url, envelope, options = {}) {
141
- 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'));
142
152
  const timeoutMs = options.timeoutMs ?? 5_000;
143
153
  const retries = options.retries ?? 2;
144
154
  const headers = {
@@ -185,7 +195,13 @@ export async function postWebhook(url, envelope, options = {}) {
185
195
  }
186
196
 
187
197
  /**
188
- * @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
189
205
  * @param {import('./envelope.js').FlectoEnvelope} envelope
190
206
  */
191
207
  async function deliverWebhook(options, envelope) {
@@ -194,6 +210,7 @@ async function deliverWebhook(options, envelope) {
194
210
  headers: options.webhookHeaders,
195
211
  timeoutMs: options.webhookTimeoutMs,
196
212
  retries: options.webhookRetries,
213
+ format: options.webhookFormat,
197
214
  });
198
215
  }
199
216
 
@@ -216,6 +233,7 @@ function applyFailurePolicy(options, ok) {
216
233
  * webhookHeaders?: Record<string, string>,
217
234
  * webhookTimeoutMs?: number,
218
235
  * webhookRetries?: number,
236
+ * webhookFormat?: import('./notifiers.js').WebhookFormat,
219
237
  * deliveryMode?: 'best-effort' | 'at-least-once',
220
238
  * onAlertFailure?: 'warn' | 'exit' | 'retry'
221
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
  /**
@@ -73,6 +87,7 @@ export function resolveEffectiveOptions(config, profile, cliOverrides = {}) {
73
87
  export function resolvePolicyOptions(effective) {
74
88
  const policiesRaw = effective.policies;
75
89
  const pluginsRaw = effective.plugins;
90
+ const severityRemapRaw = effective.severityRemap;
76
91
  const policies = Array.isArray(policiesRaw)
77
92
  ? policiesRaw.map(String)
78
93
  : typeof policiesRaw === 'string'
@@ -83,7 +98,22 @@ export function resolvePolicyOptions(effective) {
83
98
  : typeof pluginsRaw === 'string'
84
99
  ? String(pluginsRaw).split(',').map((s) => s.trim()).filter(Boolean)
85
100
  : [];
86
- return { policies, plugins };
101
+ if (
102
+ severityRemapRaw !== undefined
103
+ && (severityRemapRaw === null || Array.isArray(severityRemapRaw) || typeof severityRemapRaw !== 'object')
104
+ ) {
105
+ throw new Error('severityRemap must be an object mapping rule ids to info, warn, error, or off');
106
+ }
107
+ const severityRemap = {};
108
+ for (const [ruleId, severity] of Object.entries(severityRemapRaw ?? {})) {
109
+ if (!['info', 'warn', 'error', 'off'].includes(severity)) {
110
+ throw new Error(
111
+ `severityRemap for "${ruleId}" must be one of: info, warn, error, off`,
112
+ );
113
+ }
114
+ severityRemap[ruleId] = severity;
115
+ }
116
+ return { policies, plugins, severityRemap };
87
117
  }
88
118
 
89
119
  /**
@@ -110,13 +140,104 @@ export async function resolveFiles(input) {
110
140
  }
111
141
 
112
142
  /**
113
- * 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.
114
149
  * @param {string} cwd
115
- * @returns {string}
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.
230
+ * @param {string} cwd
231
+ * @returns {{ path: string, created: boolean, detection: StackDetection }}
116
232
  */
117
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
+
118
240
  const path = resolve(cwd, '.flectorc.json');
119
- if (existsSync(path)) return path;
120
241
  const starter = {
121
242
  defaults: {
122
243
  mode: 'compact',
@@ -124,20 +245,25 @@ export function initRcFile(cwd = process.cwd()) {
124
245
  ignore: ['**.updated_at'],
125
246
  deliveryMode: 'best-effort',
126
247
  onAlertFailure: 'warn',
127
- policies: ['default'],
248
+ policies: detection.packs,
128
249
  plugins: [],
129
250
  arrayIdKey: null,
251
+ arrayId: true,
130
252
  arrayIgnoreOrder: false,
131
253
  maskSecrets: false,
132
254
  },
133
255
  profiles: {
134
256
  dev: { mode: 'verbose' },
135
257
  ci: { failOn: 'policy,error' },
136
- prod: { policies: ['default', 'strict-prod'], maskSecrets: true },
258
+ prod: {
259
+ policies: [...detection.packs, 'strict-prod'],
260
+ severityRemap: { 'pool-size-jump': 'error' },
261
+ maskSecrets: true,
262
+ },
137
263
  },
138
- files: ['config/**/*.{yaml,yml,json,toml,ini}', '.env', '.env.*', '*.env'],
264
+ files: detection.files.length > 0 ? detection.files : [...GENERIC_FILE_PATTERNS],
139
265
  exclude: ['**/node_modules/**'],
140
266
  };
141
267
  writeFileSync(path, JSON.stringify(starter, null, 2), 'utf8');
142
- return path;
268
+ return { path, created: true, detection };
143
269
  }