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/README.md +52 -2
- package/index.js +121 -25
- package/package.json +2 -1
- package/schemas/flecto-envelope-2.0.json +65 -0
- package/src/alerter.js +7 -7
- package/src/config.js +38 -2
- package/src/differ.js +100 -13
- package/src/envelope.js +6 -4
- package/src/packs/default.json +37 -0
- package/src/packs/strict-prod.json +37 -0
- package/src/parser.js +80 -14
- package/src/policy.js +244 -35
- package/src/renderer.js +59 -13
- package/src/watcher.js +11 -4
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
|
-
*
|
|
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 = '
|
|
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
|
-
* }}
|
|
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 {
|
|
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
|
|
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 (!
|
|
20
|
-
const supported =
|
|
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
|
|
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
|
-
|
|
141
|
+
if (isEnvFilename(filepath) || isIniFilename(filepath)) return true;
|
|
142
|
+
return SUPPORTED_EXT.includes(extname(filepath).toLowerCase());
|
|
77
143
|
}
|