flecto 1.0.2 → 2.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/src/differ.js CHANGED
@@ -139,8 +139,9 @@ function matchParts(patternParts, pathParts) {
139
139
  * @param {unknown} after
140
140
  * @param {string} path
141
141
  * @param {ChangeEvent[]} events accumulator
142
+ * @param {{ arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
142
143
  */
143
- function diffValues(before, after, path, events) {
144
+ function diffValues(before, after, path, events, options = {}) {
144
145
  const beforeIsObj = isPlainObject(before);
145
146
  const afterIsObj = isPlainObject(after);
146
147
  const beforeIsArr = Array.isArray(before);
@@ -148,13 +149,13 @@ function diffValues(before, after, path, events) {
148
149
 
149
150
  // Both plain objects → recurse into keys
150
151
  if (beforeIsObj && afterIsObj) {
151
- diffObjects(before, after, path, events);
152
+ diffObjects(before, after, path, events, options);
152
153
  return;
153
154
  }
154
155
 
155
- // Both arrays → diff by index
156
+ // Both arrays → diff by index or identity key
156
157
  if (beforeIsArr && afterIsArr) {
157
- diffArrays(before, after, path, events);
158
+ diffArrays(before, after, path, events, options);
158
159
  return;
159
160
  }
160
161
 
@@ -199,8 +200,9 @@ function diffValues(before, after, path, events) {
199
200
  * @param {Record<string, unknown>} after
200
201
  * @param {string} basePath
201
202
  * @param {ChangeEvent[]} events
203
+ * @param {{ arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
202
204
  */
203
- function diffObjects(before, after, basePath, events) {
205
+ function diffObjects(before, after, basePath, events, options = {}) {
204
206
  const beforeKeys = new Set(Object.keys(before));
205
207
  const afterKeys = new Set(Object.keys(after));
206
208
 
@@ -224,19 +226,100 @@ function diffObjects(before, after, basePath, events) {
224
226
  for (const key of beforeKeys) {
225
227
  if (afterKeys.has(key)) {
226
228
  const childPath = basePath ? `${basePath}.${key}` : key;
227
- diffValues(before[key], after[key], childPath, events);
229
+ diffValues(before[key], after[key], childPath, events, options);
228
230
  }
229
231
  }
230
232
  }
231
233
 
232
234
  /**
233
- * Diff two arrays by index.
235
+ * @param {unknown} item
236
+ * @param {string} idKey
237
+ * @returns {string | null}
238
+ */
239
+ function identityKey(item, idKey) {
240
+ if (!isPlainObject(item)) return null;
241
+ if (!Object.prototype.hasOwnProperty.call(item, idKey)) return null;
242
+ const v = item[idKey];
243
+ if (v == null || typeof v === 'object') return null;
244
+ return String(v);
245
+ }
246
+
247
+ /**
248
+ * Diff arrays by identity key when configured; otherwise by index.
234
249
  * @param {unknown[]} before
235
250
  * @param {unknown[]} after
236
251
  * @param {string} basePath
237
252
  * @param {ChangeEvent[]} events
253
+ * @param {{ arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
238
254
  */
239
- function diffArrays(before, after, basePath, events) {
255
+ function diffArrays(before, after, basePath, events, options = {}) {
256
+ const idKey = options.arrayIdKey ? String(options.arrayIdKey) : null;
257
+
258
+ if (idKey) {
259
+ /** @type {Map<string, { value: unknown, index: number }>} */
260
+ const beforeMap = new Map();
261
+ /** @type {Map<string, { value: unknown, index: number }>} */
262
+ const afterMap = new Map();
263
+ let canUseIdentity = true;
264
+
265
+ for (let i = 0; i < before.length; i++) {
266
+ const key = identityKey(before[i], idKey);
267
+ if (key == null || beforeMap.has(key)) {
268
+ canUseIdentity = false;
269
+ break;
270
+ }
271
+ beforeMap.set(key, { value: before[i], index: i });
272
+ }
273
+ if (canUseIdentity) {
274
+ for (let i = 0; i < after.length; i++) {
275
+ const key = identityKey(after[i], idKey);
276
+ if (key == null || afterMap.has(key)) {
277
+ canUseIdentity = false;
278
+ break;
279
+ }
280
+ afterMap.set(key, { value: after[i], index: i });
281
+ }
282
+ }
283
+
284
+ if (canUseIdentity) {
285
+ for (const [key, afterItem] of afterMap) {
286
+ const childPath = `${basePath}[${JSON.stringify(key)}]`;
287
+ if (!beforeMap.has(key)) {
288
+ events.push({ type: 'added', path: childPath, after: afterItem.value });
289
+ } else {
290
+ diffValues(beforeMap.get(key).value, afterItem.value, childPath, events, options);
291
+ }
292
+ }
293
+ for (const [key, beforeItem] of beforeMap) {
294
+ if (!afterMap.has(key)) {
295
+ const childPath = `${basePath}[${JSON.stringify(key)}]`;
296
+ events.push({ type: 'removed', path: childPath, before: beforeItem.value });
297
+ }
298
+ }
299
+ return;
300
+ }
301
+ }
302
+
303
+ if (options.arrayIgnoreOrder) {
304
+ // Order-insensitive without id key: multiset compare via JSON signatures
305
+ const beforeSigs = before.map((v) => JSON.stringify(v));
306
+ const afterSigs = after.map((v) => JSON.stringify(v));
307
+ /** @type {Map<string, number>} */
308
+ const counts = new Map();
309
+ for (const s of beforeSigs) counts.set(s, (counts.get(s) ?? 0) + 1);
310
+ for (const s of afterSigs) {
311
+ const n = counts.get(s) ?? 0;
312
+ if (n > 0) counts.set(s, n - 1);
313
+ else events.push({ type: 'added', path: `${basePath}[*]`, after: JSON.parse(s) });
314
+ }
315
+ for (const [s, n] of counts) {
316
+ for (let i = 0; i < n; i++) {
317
+ events.push({ type: 'removed', path: `${basePath}[*]`, before: JSON.parse(s) });
318
+ }
319
+ }
320
+ return;
321
+ }
322
+
240
323
  const maxLen = Math.max(before.length, after.length);
241
324
  for (let i = 0; i < maxLen; i++) {
242
325
  const childPath = `${basePath}[${i}]`;
@@ -245,7 +328,7 @@ function diffArrays(before, after, basePath, events) {
245
328
  } else if (i >= after.length) {
246
329
  events.push({ type: 'removed', path: childPath, before: before[i] });
247
330
  } else {
248
- diffValues(before[i], after[i], childPath, events);
331
+ diffValues(before[i], after[i], childPath, events, options);
249
332
  }
250
333
  }
251
334
  }
@@ -255,22 +338,26 @@ function diffArrays(before, after, basePath, events) {
255
338
  * Accepts any JSON-like values at the root (object/array/scalar/null).
256
339
  * @param {unknown} before
257
340
  * @param {unknown} after
258
- * @param {{ ignorePaths?: string[] }} [options]
341
+ * @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
259
342
  * @returns {ChangeEvent[]}
260
343
  */
261
344
  export function diffTrees(before, after, options = {}) {
262
345
  /** @type {ChangeEvent[]} */
263
346
  const events = [];
264
347
  const ignore = makeIgnoreMatcher(options.ignorePaths ?? []);
348
+ const diffOpts = {
349
+ arrayIdKey: options.arrayIdKey ?? null,
350
+ arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
351
+ };
265
352
 
266
353
  // Root handling: avoid assuming object roots.
267
354
  if (isPlainObject(before) && isPlainObject(after)) {
268
- diffObjects(before, after, '', events);
355
+ diffObjects(before, after, '', events, diffOpts);
269
356
  } else if (Array.isArray(before) && Array.isArray(after)) {
270
- diffArrays(before, after, '', events);
357
+ diffArrays(before, after, '', events, diffOpts);
271
358
  } else {
272
359
  // Compare as a single root value. Use "<root>" so we can still ignore it if desired.
273
- diffValues(before, after, '<root>', events);
360
+ diffValues(before, after, '<root>', events, diffOpts);
274
361
  }
275
362
 
276
363
  return events.filter(e => !ignore(e.path));
package/src/envelope.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'crypto';
2
2
 
3
- export const EVENT_SCHEMA_VERSION = '1.1';
3
+ export const EVENT_SCHEMA_VERSION = '2.0';
4
4
 
5
5
  /**
6
6
  * @typedef {'watch' | 'ci' | 'diff'} EventSource
@@ -15,8 +15,9 @@ export const EVENT_SCHEMA_VERSION = '1.1';
15
15
  * emitted_at: string,
16
16
  * file: string,
17
17
  * changes: import('./differ.js').ChangeEvent[],
18
+ * policies?: import('./policy.js').PolicyFinding[],
18
19
  * lifecycle?: { type: string, message: string }
19
- * }} SentinelEnvelope
20
+ * }} FlectoEnvelope
20
21
  */
21
22
 
22
23
  /**
@@ -25,10 +26,11 @@ export const EVENT_SCHEMA_VERSION = '1.1';
25
26
  * file: string,
26
27
  * source: EventSource,
27
28
  * changes?: import('./differ.js').ChangeEvent[],
29
+ * policies?: import('./policy.js').PolicyFinding[],
28
30
  * lifecycle?: { type: string, message: string },
29
31
  * batchId?: string
30
32
  * }} input
31
- * @returns {SentinelEnvelope}
33
+ * @returns {FlectoEnvelope}
32
34
  */
33
35
  export function createEnvelope(input) {
34
36
  const batchId = input.batchId ?? randomUUID();
@@ -41,7 +43,7 @@ export function createEnvelope(input) {
41
43
  emitted_at: new Date().toISOString(),
42
44
  file: input.file,
43
45
  changes: input.changes ?? [],
46
+ policies: input.policies ?? [],
44
47
  lifecycle: input.lifecycle,
45
48
  };
46
49
  }
47
-
@@ -0,0 +1,37 @@
1
+ {
2
+ "id": "default",
3
+ "rules": [
4
+ {
5
+ "id": "secret-key-changed",
6
+ "severity": "error",
7
+ "when": ["added", "changed"],
8
+ "match": {
9
+ "path": "(secret|token|password|api[_-]?key|private[_-]?key)",
10
+ "pathFlags": "i"
11
+ },
12
+ "message": "Sensitive-looking key added or changed. Confirm secret storage, rotation, and access controls."
13
+ },
14
+ {
15
+ "id": "dangerous-toggle-enabled",
16
+ "severity": "error",
17
+ "when": ["changed"],
18
+ "match": {
19
+ "path": "(debug|allow_insecure|disable_tls|skip_tls_verify)",
20
+ "pathFlags": "i"
21
+ },
22
+ "afterEquals": true,
23
+ "message": "Potentially dangerous toggle enabled."
24
+ },
25
+ {
26
+ "id": "pool-size-jump",
27
+ "severity": "warn",
28
+ "when": ["changed"],
29
+ "match": {
30
+ "path": "pool_size$",
31
+ "pathFlags": "i"
32
+ },
33
+ "numericJump": { "minMultiple": 2 },
34
+ "messageTemplate": "Pool size increased from {before} to {after} (>=2x)."
35
+ }
36
+ ]
37
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "id": "strict-prod",
3
+ "rules": [
4
+ {
5
+ "id": "secret-key-changed",
6
+ "severity": "error",
7
+ "when": ["added", "changed", "removed"],
8
+ "match": {
9
+ "path": "(secret|token|password|api[_-]?key|private[_-]?key|credential)",
10
+ "pathFlags": "i"
11
+ },
12
+ "message": "Sensitive-looking key changed in production profile. Confirm rotation and access controls."
13
+ },
14
+ {
15
+ "id": "dangerous-toggle-enabled",
16
+ "severity": "error",
17
+ "when": ["added", "changed"],
18
+ "match": {
19
+ "path": "(debug|allow_insecure|disable_tls|skip_tls_verify|permit_all)",
20
+ "pathFlags": "i"
21
+ },
22
+ "afterEquals": true,
23
+ "message": "Dangerous toggle enabled in production profile."
24
+ },
25
+ {
26
+ "id": "pool-size-jump",
27
+ "severity": "error",
28
+ "when": ["changed"],
29
+ "match": {
30
+ "path": "pool_size$",
31
+ "pathFlags": "i"
32
+ },
33
+ "numericJump": { "minMultiple": 2 },
34
+ "messageTemplate": "Pool size increased from {before} to {after} (>=2x) in production."
35
+ }
36
+ ]
37
+ }
package/src/parser.js CHANGED
@@ -1,10 +1,72 @@
1
1
  import { readFileSync } from 'fs';
2
- import { extname } from 'path';
2
+ import { basename, extname } from 'path';
3
3
  import yaml from 'js-yaml';
4
4
  import TOML from '@iarna/toml';
5
5
  import dotenv from 'dotenv';
6
6
 
7
- const SUPPORTED = ['.json', '.yaml', '.yml', '.toml', '.env'];
7
+ const SUPPORTED_EXT = ['.json', '.yaml', '.yml', '.toml', '.env', '.ini'];
8
+
9
+ /**
10
+ * True for dotenv-like names: `.env`, `.env.*`, `*.env`
11
+ * @param {string} filepath
12
+ */
13
+ export function isEnvFilename(filepath) {
14
+ const base = basename(filepath);
15
+ return base === '.env' || base.startsWith('.env.') || base.endsWith('.env');
16
+ }
17
+
18
+ /**
19
+ * True for INI files.
20
+ * @param {string} filepath
21
+ */
22
+ export function isIniFilename(filepath) {
23
+ return extname(filepath).toLowerCase() === '.ini';
24
+ }
25
+
26
+ /**
27
+ * Minimal INI parser: [section] + key=value.
28
+ * Root keys are top-level; sectioned keys nest under the section name.
29
+ * @param {string} raw
30
+ * @returns {Record<string, unknown>}
31
+ */
32
+ export function parseIni(raw) {
33
+ /** @type {Record<string, unknown>} */
34
+ const out = {};
35
+ let section = null;
36
+
37
+ for (const line of String(raw).split(/\r?\n/)) {
38
+ const trimmed = line.trim();
39
+ if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('#')) continue;
40
+ const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
41
+ if (sectionMatch) {
42
+ section = sectionMatch[1].trim();
43
+ if (!isPlainObject(out[section])) out[section] = {};
44
+ continue;
45
+ }
46
+ const eq = trimmed.indexOf('=');
47
+ if (eq === -1) continue;
48
+ const key = trimmed.slice(0, eq).trim();
49
+ let value = trimmed.slice(eq + 1).trim();
50
+ if (
51
+ (value.startsWith('"') && value.endsWith('"')) ||
52
+ (value.startsWith("'") && value.endsWith("'"))
53
+ ) {
54
+ value = value.slice(1, -1);
55
+ }
56
+ if (section == null) {
57
+ out[key] = value;
58
+ } else {
59
+ /** @type {Record<string, string>} */
60
+ const bucket = /** @type {any} */ (out[section]);
61
+ bucket[key] = value;
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function isPlainObject(v) {
68
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
69
+ }
8
70
 
9
71
  /**
10
72
  * Auto-detect the format of a file and parse it into a plain JS object.
@@ -15,35 +77,38 @@ const SUPPORTED = ['.json', '.yaml', '.yml', '.toml', '.env'];
15
77
  */
16
78
  export function parseContent(filepath, raw) {
17
79
  const ext = extname(filepath).toLowerCase();
80
+ const envLike = isEnvFilename(filepath);
81
+ const iniLike = isIniFilename(filepath);
18
82
 
19
- if (!SUPPORTED.includes(ext)) {
20
- const supported = SUPPORTED.join(', ');
83
+ if (!envLike && !iniLike && !SUPPORTED_EXT.includes(ext)) {
84
+ const supported = [...SUPPORTED_EXT, '.env.*', '*.env'].join(', ');
21
85
  throw new Error(
22
- `Unsupported file format "${ext}" for "${filepath}".\n` +
86
+ `Unsupported file format "${ext || '(none)'}" for "${filepath}".\n` +
23
87
  `Supported extensions: ${supported}`
24
88
  );
25
89
  }
26
90
  try {
91
+ if (envLike || ext === '.env') {
92
+ return dotenv.parse(raw);
93
+ }
94
+
95
+ if (iniLike) {
96
+ return parseIni(raw);
97
+ }
98
+
27
99
  if (ext === '.json') {
28
100
  return JSON.parse(raw);
29
101
  }
30
102
 
31
103
  if (ext === '.yaml' || ext === '.yml') {
32
104
  const result = yaml.load(raw);
33
- // yaml.load can return null for empty files
34
105
  return result == null ? {} : result;
35
106
  }
36
107
 
37
108
  if (ext === '.toml') {
38
109
  return TOML.parse(raw);
39
110
  }
40
-
41
- if (ext === '.env') {
42
- const parsed = dotenv.parse(raw);
43
- return parsed;
44
- }
45
111
  } catch (err) {
46
- // Try to extract line info from error messages
47
112
  const lineMatch = err.message?.match(/line (\d+)/i);
48
113
  const lineInfo = lineMatch ? ` (line ${lineMatch[1]})` : '';
49
114
  throw new Error(
@@ -68,10 +133,11 @@ export function parseFile(filepath) {
68
133
  }
69
134
 
70
135
  /**
71
- * Returns true if the file extension is supported.
136
+ * Returns true if the file format is supported.
72
137
  * @param {string} filepath
73
138
  * @returns {boolean}
74
139
  */
75
140
  export function isSupported(filepath) {
76
- return SUPPORTED.includes(extname(filepath).toLowerCase());
141
+ if (isEnvFilename(filepath) || isIniFilename(filepath)) return true;
142
+ return SUPPORTED_EXT.includes(extname(filepath).toLowerCase());
77
143
  }