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/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
+ }
@@ -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
+
@@ -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
+ }