flecto 1.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/LICENSE +21 -0
- package/README.md +342 -0
- package/index.js +403 -0
- package/package.json +53 -0
- package/src/alerter.js +252 -0
- package/src/config.js +107 -0
- package/src/differ.js +277 -0
- package/src/envelope.js +47 -0
- package/src/parser.js +77 -0
- package/src/policy.js +65 -0
- package/src/renderer.js +134 -0
- package/src/watcher.js +137 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
|
|
6
|
+
const RC_CANDIDATES = ['.flectorc', '.flectorc.json', '.flectorc.yaml', '.flectorc.yml'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {{
|
|
10
|
+
* defaults?: Record<string, unknown>,
|
|
11
|
+
* profiles?: Record<string, Record<string, unknown>>,
|
|
12
|
+
* files?: string[],
|
|
13
|
+
* include?: string[],
|
|
14
|
+
* exclude?: string[]
|
|
15
|
+
* }} FlectoRc
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} cwd
|
|
20
|
+
* @returns {{ path: string | null, config: FlectoRc | null }}
|
|
21
|
+
*/
|
|
22
|
+
export function loadRcConfig(cwd = process.cwd()) {
|
|
23
|
+
for (const candidate of RC_CANDIDATES) {
|
|
24
|
+
const fullPath = resolve(cwd, candidate);
|
|
25
|
+
if (!existsSync(fullPath)) continue;
|
|
26
|
+
const raw = readFileSync(fullPath, 'utf8');
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
if (candidate.endsWith('.yaml') || candidate.endsWith('.yml')) {
|
|
30
|
+
parsed = yaml.load(raw);
|
|
31
|
+
} else {
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(raw);
|
|
34
|
+
} catch {
|
|
35
|
+
parsed = yaml.load(raw);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new Error(`Failed to parse ${candidate}: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
return { path: fullPath, config: parsed ?? {} };
|
|
42
|
+
}
|
|
43
|
+
return { path: null, config: null };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve effective options with optional profile and CLI overrides.
|
|
48
|
+
* @param {FlectoRc | null} config
|
|
49
|
+
* @param {string | undefined} profile
|
|
50
|
+
* @param {Record<string, unknown>} cliOverrides
|
|
51
|
+
*/
|
|
52
|
+
export function resolveEffectiveOptions(config, profile, cliOverrides = {}) {
|
|
53
|
+
const defaults = config?.defaults ?? {};
|
|
54
|
+
const profileOptions = profile && config?.profiles?.[profile] ? config.profiles[profile] : {};
|
|
55
|
+
return { ...defaults, ...profileOptions, ...cliOverrides };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Expand file patterns from rc include/files and direct CLI inputs.
|
|
60
|
+
* @param {{ cwd?: string, files?: string[], include?: string[], exclude?: string[] }} input
|
|
61
|
+
* @returns {Promise<string[]>}
|
|
62
|
+
*/
|
|
63
|
+
export async function resolveFiles(input) {
|
|
64
|
+
const cwd = input.cwd ?? process.cwd();
|
|
65
|
+
const files = input.files ?? [];
|
|
66
|
+
const include = input.include ?? [];
|
|
67
|
+
const exclude = input.exclude ?? [];
|
|
68
|
+
const patterns = [...files, ...include].filter(Boolean);
|
|
69
|
+
if (patterns.length === 0) return [];
|
|
70
|
+
const matches = await fg(patterns, {
|
|
71
|
+
cwd,
|
|
72
|
+
absolute: true,
|
|
73
|
+
onlyFiles: true,
|
|
74
|
+
unique: true,
|
|
75
|
+
ignore: exclude,
|
|
76
|
+
dot: true,
|
|
77
|
+
});
|
|
78
|
+
return matches.map((p) => resolve(p));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Scaffold a starter rc file if missing.
|
|
83
|
+
* @param {string} cwd
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
export function initRcFile(cwd = process.cwd()) {
|
|
87
|
+
const path = resolve(cwd, '.flectorc.json');
|
|
88
|
+
if (existsSync(path)) return path;
|
|
89
|
+
const starter = {
|
|
90
|
+
defaults: {
|
|
91
|
+
mode: 'compact',
|
|
92
|
+
interval: 100,
|
|
93
|
+
ignore: ['**.updated_at'],
|
|
94
|
+
deliveryMode: 'best-effort',
|
|
95
|
+
onAlertFailure: 'warn',
|
|
96
|
+
},
|
|
97
|
+
profiles: {
|
|
98
|
+
dev: { mode: 'verbose' },
|
|
99
|
+
ci: { failOn: 'policy,error' },
|
|
100
|
+
},
|
|
101
|
+
files: ['config/**/*.yaml', '.env'],
|
|
102
|
+
exclude: ['**/node_modules/**'],
|
|
103
|
+
};
|
|
104
|
+
writeFileSync(path, JSON.stringify(starter, null, 2), 'utf8');
|
|
105
|
+
return path;
|
|
106
|
+
}
|
|
107
|
+
|
package/src/differ.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ type: 'added' | 'removed' | 'changed', path: string, before?: unknown, after?: unknown, note?: string }} ChangeEvent
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Checks whether a value is a plain object (not array, not null).
|
|
7
|
+
* @param {unknown} v
|
|
8
|
+
* @returns {v is Record<string, unknown>}
|
|
9
|
+
*/
|
|
10
|
+
function isPlainObject(v) {
|
|
11
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default ignore matcher:
|
|
16
|
+
* - exact path match: "meta.timestamp"
|
|
17
|
+
* - subtree/prefix match: "meta" ignores "meta.*" and "meta[0].*"
|
|
18
|
+
* - wildcard segment match: "meta.*.timestamp"
|
|
19
|
+
* - ignore by key name anywhere: "**.updated_at" or "**.timestamp"
|
|
20
|
+
*
|
|
21
|
+
* Array indices in paths use "[N]" segments, e.g. "servers[1].port".
|
|
22
|
+
* @param {string[]} patterns
|
|
23
|
+
* @returns {(path: string) => boolean}
|
|
24
|
+
*/
|
|
25
|
+
function makeIgnoreMatcher(patterns) {
|
|
26
|
+
if (!patterns || patterns.length === 0) return () => false;
|
|
27
|
+
|
|
28
|
+
const exact = new Set();
|
|
29
|
+
/** @type {{ parts: string[] }[]} */
|
|
30
|
+
const globs = [];
|
|
31
|
+
/** @type {Set<string>} */
|
|
32
|
+
const keyAnywhere = new Set();
|
|
33
|
+
|
|
34
|
+
for (const raw of patterns) {
|
|
35
|
+
const p = String(raw ?? '').trim();
|
|
36
|
+
if (!p) continue;
|
|
37
|
+
|
|
38
|
+
if (p.startsWith('**.')) {
|
|
39
|
+
const key = p.slice(3).trim();
|
|
40
|
+
if (key) keyAnywhere.add(key);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (p.includes('*')) {
|
|
45
|
+
globs.push({ parts: splitPathParts(p) });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
exact.add(p);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (path) => {
|
|
53
|
+
if (exact.has(path)) return true;
|
|
54
|
+
|
|
55
|
+
// Prefix/subtree ignore: "meta" ignores "meta.x" and "meta[0].x"
|
|
56
|
+
for (const p of exact) {
|
|
57
|
+
if (!p) continue;
|
|
58
|
+
if (path.startsWith(p) && (path.length === p.length || path[p.length] === '.' || path[p.length] === '[')) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (keyAnywhere.size > 0) {
|
|
64
|
+
for (const key of keyAnywhere) {
|
|
65
|
+
if (path === key) return true;
|
|
66
|
+
if (path.includes(`.${key}`)) return true;
|
|
67
|
+
if (path.includes(`].${key}`)) return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (globs.length > 0) {
|
|
72
|
+
const parts = splitPathParts(path);
|
|
73
|
+
for (const g of globs) {
|
|
74
|
+
if (matchParts(g.parts, parts)) return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Split a path into comparable parts. Turns "a.b[1].c" into ["a","b","[1]","c"].
|
|
84
|
+
* @param {string} path
|
|
85
|
+
* @returns {string[]}
|
|
86
|
+
*/
|
|
87
|
+
function splitPathParts(path) {
|
|
88
|
+
if (!path) return [];
|
|
89
|
+
/** @type {string[]} */
|
|
90
|
+
const parts = [];
|
|
91
|
+
let buf = '';
|
|
92
|
+
for (let i = 0; i < path.length; i++) {
|
|
93
|
+
const ch = path[i];
|
|
94
|
+
if (ch === '.') {
|
|
95
|
+
if (buf) parts.push(buf);
|
|
96
|
+
buf = '';
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (ch === '[') {
|
|
100
|
+
if (buf) parts.push(buf);
|
|
101
|
+
buf = '';
|
|
102
|
+
const end = path.indexOf(']', i);
|
|
103
|
+
if (end === -1) {
|
|
104
|
+
// malformed; treat as literal remainder
|
|
105
|
+
buf = path.slice(i);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
const bracketToken = path.slice(i, end + 1); // include brackets
|
|
109
|
+
// Allow wildcard index syntax: [*]
|
|
110
|
+
parts.push(bracketToken === '[*]' ? '*' : bracketToken);
|
|
111
|
+
i = end;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
buf += ch;
|
|
115
|
+
}
|
|
116
|
+
if (buf) parts.push(buf);
|
|
117
|
+
return parts;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Match glob parts against actual parts. "*" matches any single part (key or index).
|
|
122
|
+
* @param {string[]} patternParts
|
|
123
|
+
* @param {string[]} pathParts
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
function matchParts(patternParts, pathParts) {
|
|
127
|
+
if (patternParts.length !== pathParts.length) return false;
|
|
128
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
129
|
+
const p = patternParts[i];
|
|
130
|
+
if (p === '*') continue;
|
|
131
|
+
if (p !== pathParts[i]) return false;
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Recursively diff two values at a given key path.
|
|
138
|
+
* @param {unknown} before
|
|
139
|
+
* @param {unknown} after
|
|
140
|
+
* @param {string} path
|
|
141
|
+
* @param {ChangeEvent[]} events accumulator
|
|
142
|
+
*/
|
|
143
|
+
function diffValues(before, after, path, events) {
|
|
144
|
+
const beforeIsObj = isPlainObject(before);
|
|
145
|
+
const afterIsObj = isPlainObject(after);
|
|
146
|
+
const beforeIsArr = Array.isArray(before);
|
|
147
|
+
const afterIsArr = Array.isArray(after);
|
|
148
|
+
|
|
149
|
+
// Both plain objects → recurse into keys
|
|
150
|
+
if (beforeIsObj && afterIsObj) {
|
|
151
|
+
diffObjects(before, after, path, events);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Both arrays → diff by index
|
|
156
|
+
if (beforeIsArr && afterIsArr) {
|
|
157
|
+
diffArrays(before, after, path, events);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Both nullish/undefined
|
|
162
|
+
if ((before == null) && (after == null)) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Structural type change (e.g. object → scalar, array → object)
|
|
167
|
+
if (
|
|
168
|
+
(beforeIsObj || beforeIsArr) !== (afterIsObj || afterIsArr) ||
|
|
169
|
+
(beforeIsObj !== afterIsObj) ||
|
|
170
|
+
(beforeIsArr !== afterIsArr)
|
|
171
|
+
) {
|
|
172
|
+
const beforeType = beforeIsObj ? 'object' : beforeIsArr ? 'array' : typeof before;
|
|
173
|
+
const afterType = afterIsObj ? 'object' : afterIsArr ? 'array' : typeof after;
|
|
174
|
+
events.push({
|
|
175
|
+
type: 'changed',
|
|
176
|
+
path,
|
|
177
|
+
before,
|
|
178
|
+
after,
|
|
179
|
+
note: `type changed from ${beforeType} to ${afterType}`,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Scalar comparison — note type changes
|
|
185
|
+
if (before !== after) {
|
|
186
|
+
const beforeType = typeof before;
|
|
187
|
+
const afterType = typeof after;
|
|
188
|
+
const event = { type: 'changed', path, before, after };
|
|
189
|
+
if (beforeType !== afterType) {
|
|
190
|
+
event.note = `type changed from ${beforeType} to ${afterType}`;
|
|
191
|
+
}
|
|
192
|
+
events.push(event);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Diff two plain objects. Key ordering is intentionally ignored.
|
|
198
|
+
* @param {Record<string, unknown>} before
|
|
199
|
+
* @param {Record<string, unknown>} after
|
|
200
|
+
* @param {string} basePath
|
|
201
|
+
* @param {ChangeEvent[]} events
|
|
202
|
+
*/
|
|
203
|
+
function diffObjects(before, after, basePath, events) {
|
|
204
|
+
const beforeKeys = new Set(Object.keys(before));
|
|
205
|
+
const afterKeys = new Set(Object.keys(after));
|
|
206
|
+
|
|
207
|
+
// Keys only in "after" → added
|
|
208
|
+
for (const key of afterKeys) {
|
|
209
|
+
if (!beforeKeys.has(key)) {
|
|
210
|
+
const childPath = basePath ? `${basePath}.${key}` : key;
|
|
211
|
+
events.push({ type: 'added', path: childPath, after: after[key] });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Keys only in "before" → removed
|
|
216
|
+
for (const key of beforeKeys) {
|
|
217
|
+
if (!afterKeys.has(key)) {
|
|
218
|
+
const childPath = basePath ? `${basePath}.${key}` : key;
|
|
219
|
+
events.push({ type: 'removed', path: childPath, before: before[key] });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Keys in both → recurse
|
|
224
|
+
for (const key of beforeKeys) {
|
|
225
|
+
if (afterKeys.has(key)) {
|
|
226
|
+
const childPath = basePath ? `${basePath}.${key}` : key;
|
|
227
|
+
diffValues(before[key], after[key], childPath, events);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Diff two arrays by index.
|
|
234
|
+
* @param {unknown[]} before
|
|
235
|
+
* @param {unknown[]} after
|
|
236
|
+
* @param {string} basePath
|
|
237
|
+
* @param {ChangeEvent[]} events
|
|
238
|
+
*/
|
|
239
|
+
function diffArrays(before, after, basePath, events) {
|
|
240
|
+
const maxLen = Math.max(before.length, after.length);
|
|
241
|
+
for (let i = 0; i < maxLen; i++) {
|
|
242
|
+
const childPath = `${basePath}[${i}]`;
|
|
243
|
+
if (i >= before.length) {
|
|
244
|
+
events.push({ type: 'added', path: childPath, after: after[i] });
|
|
245
|
+
} else if (i >= after.length) {
|
|
246
|
+
events.push({ type: 'removed', path: childPath, before: before[i] });
|
|
247
|
+
} else {
|
|
248
|
+
diffValues(before[i], after[i], childPath, events);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Compute the semantic difference between two parsed config trees.
|
|
255
|
+
* Accepts any JSON-like values at the root (object/array/scalar/null).
|
|
256
|
+
* @param {unknown} before
|
|
257
|
+
* @param {unknown} after
|
|
258
|
+
* @param {{ ignorePaths?: string[] }} [options]
|
|
259
|
+
* @returns {ChangeEvent[]}
|
|
260
|
+
*/
|
|
261
|
+
export function diffTrees(before, after, options = {}) {
|
|
262
|
+
/** @type {ChangeEvent[]} */
|
|
263
|
+
const events = [];
|
|
264
|
+
const ignore = makeIgnoreMatcher(options.ignorePaths ?? []);
|
|
265
|
+
|
|
266
|
+
// Root handling: avoid assuming object roots.
|
|
267
|
+
if (isPlainObject(before) && isPlainObject(after)) {
|
|
268
|
+
diffObjects(before, after, '', events);
|
|
269
|
+
} else if (Array.isArray(before) && Array.isArray(after)) {
|
|
270
|
+
diffArrays(before, after, '', events);
|
|
271
|
+
} else {
|
|
272
|
+
// Compare as a single root value. Use "<root>" so we can still ignore it if desired.
|
|
273
|
+
diffValues(before, after, '<root>', events);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return events.filter(e => !ignore(e.path));
|
|
277
|
+
}
|
package/src/envelope.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
|
|
3
|
+
export const EVENT_SCHEMA_VERSION = '1.1';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {'watch' | 'ci' | 'diff'} EventSource
|
|
7
|
+
* @typedef {'changes' | 'lifecycle'} EnvelopeEventType
|
|
8
|
+
*
|
|
9
|
+
* @typedef {{
|
|
10
|
+
* schema_version: string,
|
|
11
|
+
* event_id: string,
|
|
12
|
+
* batch_id: string,
|
|
13
|
+
* event_type: EnvelopeEventType,
|
|
14
|
+
* source: EventSource,
|
|
15
|
+
* emitted_at: string,
|
|
16
|
+
* file: string,
|
|
17
|
+
* changes: import('./differ.js').ChangeEvent[],
|
|
18
|
+
* lifecycle?: { type: string, message: string }
|
|
19
|
+
* }} SentinelEnvelope
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a stable event envelope for automation sinks.
|
|
24
|
+
* @param {{
|
|
25
|
+
* file: string,
|
|
26
|
+
* source: EventSource,
|
|
27
|
+
* changes?: import('./differ.js').ChangeEvent[],
|
|
28
|
+
* lifecycle?: { type: string, message: string },
|
|
29
|
+
* batchId?: string
|
|
30
|
+
* }} input
|
|
31
|
+
* @returns {SentinelEnvelope}
|
|
32
|
+
*/
|
|
33
|
+
export function createEnvelope(input) {
|
|
34
|
+
const batchId = input.batchId ?? randomUUID();
|
|
35
|
+
return {
|
|
36
|
+
schema_version: EVENT_SCHEMA_VERSION,
|
|
37
|
+
event_id: randomUUID(),
|
|
38
|
+
batch_id: batchId,
|
|
39
|
+
event_type: input.lifecycle ? 'lifecycle' : 'changes',
|
|
40
|
+
source: input.source,
|
|
41
|
+
emitted_at: new Date().toISOString(),
|
|
42
|
+
file: input.file,
|
|
43
|
+
changes: input.changes ?? [],
|
|
44
|
+
lifecycle: input.lifecycle,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
package/src/parser.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { extname } from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import TOML from '@iarna/toml';
|
|
5
|
+
import dotenv from 'dotenv';
|
|
6
|
+
|
|
7
|
+
const SUPPORTED = ['.json', '.yaml', '.yml', '.toml', '.env'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Auto-detect the format of a file and parse it into a plain JS object.
|
|
11
|
+
* @param {string} filepath
|
|
12
|
+
* @param {string} raw
|
|
13
|
+
* @returns {unknown}
|
|
14
|
+
* @throws {Error} on unsupported format or parse failure
|
|
15
|
+
*/
|
|
16
|
+
export function parseContent(filepath, raw) {
|
|
17
|
+
const ext = extname(filepath).toLowerCase();
|
|
18
|
+
|
|
19
|
+
if (!SUPPORTED.includes(ext)) {
|
|
20
|
+
const supported = SUPPORTED.join(', ');
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Unsupported file format "${ext}" for "${filepath}".\n` +
|
|
23
|
+
`Supported extensions: ${supported}`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
if (ext === '.json') {
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (ext === '.yaml' || ext === '.yml') {
|
|
32
|
+
const result = yaml.load(raw);
|
|
33
|
+
// yaml.load can return null for empty files
|
|
34
|
+
return result == null ? {} : result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (ext === '.toml') {
|
|
38
|
+
return TOML.parse(raw);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (ext === '.env') {
|
|
42
|
+
const parsed = dotenv.parse(raw);
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// Try to extract line info from error messages
|
|
47
|
+
const lineMatch = err.message?.match(/line (\d+)/i);
|
|
48
|
+
const lineInfo = lineMatch ? ` (line ${lineMatch[1]})` : '';
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Parse error in "${filepath}"${lineInfo}: ${err.message}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Auto-detect the format of a file and parse it into a plain JS value.
|
|
57
|
+
* @param {string} filepath
|
|
58
|
+
* @returns {unknown}
|
|
59
|
+
*/
|
|
60
|
+
export function parseFile(filepath) {
|
|
61
|
+
let raw;
|
|
62
|
+
try {
|
|
63
|
+
raw = readFileSync(filepath, 'utf8');
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new Error(`Cannot read file "${filepath}": ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
return parseContent(filepath, raw);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Returns true if the file extension is supported.
|
|
72
|
+
* @param {string} filepath
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export function isSupported(filepath) {
|
|
76
|
+
return SUPPORTED.includes(extname(filepath).toLowerCase());
|
|
77
|
+
}
|
package/src/policy.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {'info' | 'warn' | 'error'} PolicySeverity
|
|
3
|
+
* @typedef {{ id: string, severity: PolicySeverity, path: string, message: string }} PolicyFinding
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SECRET_KEY_RE = /(secret|token|password|api[_-]?key|private[_-]?key)/i;
|
|
7
|
+
const DANGEROUS_TOGGLE_RE = /(debug|allow_insecure|disable_tls|skip_tls_verify)/i;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Evaluate built-in policy checks against semantic changes.
|
|
11
|
+
* @param {import('./differ.js').ChangeEvent[]} changes
|
|
12
|
+
* @returns {PolicyFinding[]}
|
|
13
|
+
*/
|
|
14
|
+
export function evaluatePolicies(changes) {
|
|
15
|
+
/** @type {PolicyFinding[]} */
|
|
16
|
+
const findings = [];
|
|
17
|
+
|
|
18
|
+
for (const change of changes) {
|
|
19
|
+
const path = change.path ?? '';
|
|
20
|
+
const pathLower = path.toLowerCase();
|
|
21
|
+
|
|
22
|
+
if (SECRET_KEY_RE.test(pathLower) && change.type === 'changed') {
|
|
23
|
+
findings.push({
|
|
24
|
+
id: 'secret-key-changed',
|
|
25
|
+
severity: 'error',
|
|
26
|
+
path,
|
|
27
|
+
message: 'Sensitive-looking key changed. Confirm secret rotation and access controls.',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (DANGEROUS_TOGGLE_RE.test(pathLower) && change.type === 'changed' && change.after === true) {
|
|
32
|
+
findings.push({
|
|
33
|
+
id: 'dangerous-toggle-enabled',
|
|
34
|
+
severity: 'error',
|
|
35
|
+
path,
|
|
36
|
+
message: 'Potentially dangerous toggle enabled.',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (pathLower.endsWith('pool_size') && typeof change.before === 'number' && typeof change.after === 'number') {
|
|
41
|
+
if (change.before > 0 && change.after >= change.before * 2) {
|
|
42
|
+
findings.push({
|
|
43
|
+
id: 'pool-size-jump',
|
|
44
|
+
severity: 'warn',
|
|
45
|
+
path,
|
|
46
|
+
message: `Pool size increased from ${change.before} to ${change.after} (>=2x).`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return findings;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {PolicyFinding[]} findings
|
|
57
|
+
* @returns {PolicySeverity | null}
|
|
58
|
+
*/
|
|
59
|
+
export function highestSeverity(findings) {
|
|
60
|
+
if (!findings || findings.length === 0) return null;
|
|
61
|
+
if (findings.some((f) => f.severity === 'error')) return 'error';
|
|
62
|
+
if (findings.some((f) => f.severity === 'warn')) return 'warn';
|
|
63
|
+
return 'info';
|
|
64
|
+
}
|
|
65
|
+
|
package/src/renderer.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format a scalar value for display. Strings get quoted; others are JSON-stringified.
|
|
5
|
+
* @param {unknown} v
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
function fmt(v) {
|
|
9
|
+
if (v === undefined) return '';
|
|
10
|
+
if (typeof v === 'string') return JSON.stringify(v);
|
|
11
|
+
if (typeof v === 'object' && v !== null) return JSON.stringify(v);
|
|
12
|
+
return String(v);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Return a HH:MM:SS timestamp string.
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function timestamp() {
|
|
20
|
+
return new Date().toTimeString().slice(0, 8);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Render a single change event as a colored string.
|
|
25
|
+
* @param {import('./differ.js').ChangeEvent} event
|
|
26
|
+
* @param {'compact' | 'verbose'} mode
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function renderEvent(event, mode) {
|
|
30
|
+
const { type, path, before, after, note } = event;
|
|
31
|
+
|
|
32
|
+
if (type === 'added') {
|
|
33
|
+
const line = ` ${chalk.green('+')} ${chalk.green(path)}: ${chalk.green(fmt(after))}`;
|
|
34
|
+
return mode === 'verbose'
|
|
35
|
+
? `${line}\n ${chalk.dim('(key added)')}`
|
|
36
|
+
: line;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (type === 'removed') {
|
|
40
|
+
const line = ` ${chalk.red('-')} ${chalk.red(path)}: ${chalk.red(fmt(before))}`;
|
|
41
|
+
return mode === 'verbose'
|
|
42
|
+
? `${line}\n ${chalk.dim('(key removed)')}`
|
|
43
|
+
: line;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// changed
|
|
47
|
+
const noteStr = note ? chalk.dim(` [${note}]`) : '';
|
|
48
|
+
if (mode === 'verbose') {
|
|
49
|
+
return [
|
|
50
|
+
` ${chalk.yellow('~')} ${chalk.yellow(path)}${noteStr}`,
|
|
51
|
+
` ${chalk.dim('before:')} ${chalk.red(fmt(before))}`,
|
|
52
|
+
` ${chalk.dim('after: ')} ${chalk.green(fmt(after))}`,
|
|
53
|
+
].join('\n');
|
|
54
|
+
}
|
|
55
|
+
return ` ${chalk.yellow('~')} ${chalk.yellow(path)}: ${chalk.red(fmt(before))} ${chalk.dim('→')} ${chalk.green(fmt(after))}${noteStr}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render a batch of change events to stdout.
|
|
60
|
+
* @param {string} filepath
|
|
61
|
+
* @param {import('./differ.js').ChangeEvent[]} events
|
|
62
|
+
* @param {'compact' | 'verbose'} mode
|
|
63
|
+
*/
|
|
64
|
+
export function renderChanges(filepath, events, mode = 'compact') {
|
|
65
|
+
const ts = chalk.dim(`[${timestamp()}]`);
|
|
66
|
+
const file = chalk.cyan(filepath);
|
|
67
|
+
const count = `${events.length} change${events.length !== 1 ? 's' : ''}`;
|
|
68
|
+
|
|
69
|
+
console.log(`${ts} ${file} — ${count}`);
|
|
70
|
+
for (const event of events) {
|
|
71
|
+
console.log(renderEvent(event, mode));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (mode === 'verbose') {
|
|
75
|
+
console.log('');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Print a diff result (for --diff mode) to stdout.
|
|
81
|
+
* @param {string} filepath
|
|
82
|
+
* @param {import('./differ.js').ChangeEvent[]} events
|
|
83
|
+
*/
|
|
84
|
+
export function renderDiff(filepath, events) {
|
|
85
|
+
if (events.length === 0) {
|
|
86
|
+
console.log(chalk.green(`✓ ${filepath} matches snapshot — no changes`));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(chalk.cyan(`${filepath}`) + ` — ${events.length} change${events.length !== 1 ? 's' : ''} from snapshot:`);
|
|
91
|
+
for (const event of events) {
|
|
92
|
+
console.log(renderEvent(event, 'compact'));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Print an error message in red.
|
|
98
|
+
* @param {string} msg
|
|
99
|
+
*/
|
|
100
|
+
export function renderError(msg) {
|
|
101
|
+
console.error(chalk.red(`[error] ${msg}`));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Print a warning in yellow.
|
|
106
|
+
* @param {string} msg
|
|
107
|
+
*/
|
|
108
|
+
export function renderWarn(msg) {
|
|
109
|
+
console.warn(chalk.yellow(`[warn] ${msg}`));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Print an info message in dim text.
|
|
114
|
+
* @param {string} msg
|
|
115
|
+
*/
|
|
116
|
+
export function renderInfo(msg) {
|
|
117
|
+
console.log(chalk.dim(msg));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Print policy findings.
|
|
122
|
+
* @param {import('./policy.js').PolicyFinding[]} findings
|
|
123
|
+
*/
|
|
124
|
+
export function renderPolicyFindings(findings) {
|
|
125
|
+
if (!findings || findings.length === 0) return;
|
|
126
|
+
for (const f of findings) {
|
|
127
|
+
const prefix = f.severity === 'error'
|
|
128
|
+
? chalk.red('! policy(error)')
|
|
129
|
+
: f.severity === 'warn'
|
|
130
|
+
? chalk.yellow('! policy(warn)')
|
|
131
|
+
: chalk.blue('! policy(info)');
|
|
132
|
+
console.log(` ${prefix} ${chalk.cyan(f.path)}: ${f.message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|