flecto 1.0.2 → 2.1.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/renderer.js CHANGED
@@ -1,12 +1,18 @@
1
1
  import chalk from 'chalk';
2
2
 
3
+ const SECRET_PATH_RE = /(secret|token|password|api[_-]?key|private[_-]?key|credential)/i;
4
+
3
5
  /**
4
6
  * Format a scalar value for display. Strings get quoted; others are JSON-stringified.
5
7
  * @param {unknown} v
8
+ * @param {{ maskSecrets?: boolean, path?: string }} [opts]
6
9
  * @returns {string}
7
10
  */
8
- function fmt(v) {
11
+ function fmt(v, opts = {}) {
9
12
  if (v === undefined) return '';
13
+ if (opts.maskSecrets && opts.path && SECRET_PATH_RE.test(opts.path)) {
14
+ return chalk.dim('"***"');
15
+ }
10
16
  if (typeof v === 'string') return JSON.stringify(v);
11
17
  if (typeof v === 'object' && v !== null) return JSON.stringify(v);
12
18
  return String(v);
@@ -24,35 +30,36 @@ function timestamp() {
24
30
  * Render a single change event as a colored string.
25
31
  * @param {import('./differ.js').ChangeEvent} event
26
32
  * @param {'compact' | 'verbose'} mode
33
+ * @param {{ maskSecrets?: boolean }} [opts]
27
34
  * @returns {string}
28
35
  */
29
- function renderEvent(event, mode) {
36
+ function renderEvent(event, mode, opts = {}) {
30
37
  const { type, path, before, after, note } = event;
38
+ const maskOpts = { maskSecrets: Boolean(opts.maskSecrets), path };
31
39
 
32
40
  if (type === 'added') {
33
- const line = ` ${chalk.green('+')} ${chalk.green(path)}: ${chalk.green(fmt(after))}`;
41
+ const line = ` ${chalk.green('+')} ${chalk.green(path)}: ${chalk.green(fmt(after, maskOpts))}`;
34
42
  return mode === 'verbose'
35
43
  ? `${line}\n ${chalk.dim('(key added)')}`
36
44
  : line;
37
45
  }
38
46
 
39
47
  if (type === 'removed') {
40
- const line = ` ${chalk.red('-')} ${chalk.red(path)}: ${chalk.red(fmt(before))}`;
48
+ const line = ` ${chalk.red('-')} ${chalk.red(path)}: ${chalk.red(fmt(before, maskOpts))}`;
41
49
  return mode === 'verbose'
42
50
  ? `${line}\n ${chalk.dim('(key removed)')}`
43
51
  : line;
44
52
  }
45
53
 
46
- // changed
47
54
  const noteStr = note ? chalk.dim(` [${note}]`) : '';
48
55
  if (mode === 'verbose') {
49
56
  return [
50
57
  ` ${chalk.yellow('~')} ${chalk.yellow(path)}${noteStr}`,
51
- ` ${chalk.dim('before:')} ${chalk.red(fmt(before))}`,
52
- ` ${chalk.dim('after: ')} ${chalk.green(fmt(after))}`,
58
+ ` ${chalk.dim('before:')} ${chalk.red(fmt(before, maskOpts))}`,
59
+ ` ${chalk.dim('after: ')} ${chalk.green(fmt(after, maskOpts))}`,
53
60
  ].join('\n');
54
61
  }
55
- return ` ${chalk.yellow('~')} ${chalk.yellow(path)}: ${chalk.red(fmt(before))} ${chalk.dim('→')} ${chalk.green(fmt(after))}${noteStr}`;
62
+ return ` ${chalk.yellow('~')} ${chalk.yellow(path)}: ${chalk.red(fmt(before, maskOpts))} ${chalk.dim('→')} ${chalk.green(fmt(after, maskOpts))}${noteStr}`;
56
63
  }
57
64
 
58
65
  /**
@@ -60,15 +67,16 @@ function renderEvent(event, mode) {
60
67
  * @param {string} filepath
61
68
  * @param {import('./differ.js').ChangeEvent[]} events
62
69
  * @param {'compact' | 'verbose'} mode
70
+ * @param {{ maskSecrets?: boolean }} [opts]
63
71
  */
64
- export function renderChanges(filepath, events, mode = 'compact') {
72
+ export function renderChanges(filepath, events, mode = 'compact', opts = {}) {
65
73
  const ts = chalk.dim(`[${timestamp()}]`);
66
74
  const file = chalk.cyan(filepath);
67
75
  const count = `${events.length} change${events.length !== 1 ? 's' : ''}`;
68
76
 
69
77
  console.log(`${ts} ${file} — ${count}`);
70
78
  for (const event of events) {
71
- console.log(renderEvent(event, mode));
79
+ console.log(renderEvent(event, mode, opts));
72
80
  }
73
81
 
74
82
  if (mode === 'verbose') {
@@ -80,8 +88,9 @@ export function renderChanges(filepath, events, mode = 'compact') {
80
88
  * Print a diff result (for --diff mode) to stdout.
81
89
  * @param {string} filepath
82
90
  * @param {import('./differ.js').ChangeEvent[]} events
91
+ * @param {{ maskSecrets?: boolean }} [opts]
83
92
  */
84
- export function renderDiff(filepath, events) {
93
+ export function renderDiff(filepath, events, opts = {}) {
85
94
  if (events.length === 0) {
86
95
  console.log(chalk.green(`✓ ${filepath} matches snapshot — no changes`));
87
96
  return;
@@ -89,7 +98,7 @@ export function renderDiff(filepath, events) {
89
98
 
90
99
  console.log(chalk.cyan(`${filepath}`) + ` — ${events.length} change${events.length !== 1 ? 's' : ''} from snapshot:`);
91
100
  for (const event of events) {
92
- console.log(renderEvent(event, 'compact'));
101
+ console.log(renderEvent(event, 'compact', opts));
93
102
  }
94
103
  }
95
104
 
@@ -129,6 +138,46 @@ export function renderPolicyFindings(findings) {
129
138
  : f.severity === 'warn'
130
139
  ? chalk.yellow('! policy(warn)')
131
140
  : chalk.blue('! policy(info)');
132
- console.log(` ${prefix} ${chalk.cyan(f.path)}: ${f.message}`);
141
+ const pack = f.pack ? chalk.dim(` [${f.pack}]`) : '';
142
+ console.log(` ${prefix}${pack} ${chalk.cyan(f.path)}: ${f.message}`);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Mask secret-like values in a plain object tree for CI output.
148
+ * @param {unknown} value
149
+ * @param {string} [path]
150
+ * @returns {unknown}
151
+ */
152
+ export function maskSensitiveValue(value, path = '') {
153
+ if (SECRET_PATH_RE.test(path)) return '***';
154
+ if (Array.isArray(value)) {
155
+ return value.map((v, i) => maskSensitiveValue(v, `${path}[${i}]`));
133
156
  }
157
+ if (
158
+ value
159
+ && typeof value === 'object'
160
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
161
+ ) {
162
+ /** @type {Record<string, unknown>} */
163
+ const out = {};
164
+ for (const [k, v] of Object.entries(value)) {
165
+ const child = path ? `${path}.${k}` : k;
166
+ out[k] = maskSensitiveValue(v, child);
167
+ }
168
+ return out;
169
+ }
170
+ return value;
171
+ }
172
+
173
+ /**
174
+ * @param {import('./differ.js').ChangeEvent} event
175
+ * @returns {import('./differ.js').ChangeEvent}
176
+ */
177
+ export function maskChangeEvent(event) {
178
+ return {
179
+ ...event,
180
+ before: event.before === undefined ? undefined : maskSensitiveValue(event.before, event.path),
181
+ after: event.after === undefined ? undefined : maskSensitiveValue(event.after, event.path),
182
+ };
134
183
  }
package/src/watcher.js CHANGED
@@ -11,6 +11,9 @@ import { renderWarn, renderInfo } from './renderer.js';
11
11
  * @property {boolean} [polling] Force polling mode (default: false)
12
12
  * @property {string} [mode] Output mode: 'compact' | 'verbose'
13
13
  * @property {string[]} [ignorePaths] Key paths to suppress in diffs
14
+ * @property {string | null} [arrayIdKey]
15
+ * @property {boolean} [arrayIdentity]
16
+ * @property {boolean} [arrayIgnoreOrder]
14
17
  */
15
18
 
16
19
  /**
@@ -18,13 +21,19 @@ import { renderWarn, renderInfo } from './renderer.js';
18
21
  *
19
22
  * @param {string} filepath
20
23
  * @param {WatcherOptions} options
21
- * @param {(event: { kind: 'changes', filepath: string, events: ChangeEvent[] } | { kind: 'lifecycle', filepath: string, lifecycle: { type: string, message: string } }) => void} onEvent
24
+ * @param {(event: { kind: 'changes', filepath: string, events: ChangeEvent[] } | { kind: 'lifecycle', filepath: string, lifecycle: { type: string, message: string } }) => void | Promise<void>} onEvent
22
25
  * @returns {import('chokidar').FSWatcher}
23
26
  */
24
27
  export function startWatcher(filepath, options = {}, onEvent) {
25
28
  const interval = options.interval ?? 100;
26
29
  const ignorePaths = options.ignorePaths ?? [];
27
30
  const polling = options.polling ?? false;
31
+ const diffOpts = {
32
+ ignorePaths,
33
+ arrayIdKey: options.arrayIdKey ?? null,
34
+ arrayIdentity: options.arrayIdentity !== false,
35
+ arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
36
+ };
28
37
 
29
38
  /** @type {unknown | null} */
30
39
  let lastGoodState = null;
@@ -35,7 +44,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
35
44
  } catch (err) {
36
45
  renderWarn(`Could not parse initial state of "${filepath}": ${err.message}`);
37
46
  renderWarn('Watching anyway — will use first successful parse as baseline.');
38
- onEvent({
47
+ safelyEmit(onEvent, {
39
48
  kind: 'lifecycle',
40
49
  filepath,
41
50
  lifecycle: { type: 'initial-parse-failed', message: err.message },
@@ -60,17 +69,17 @@ export function startWatcher(filepath, options = {}, onEvent) {
60
69
  const scheduleRead = (reason) => {
61
70
  if (debounceTimer) clearTimeout(debounceTimer);
62
71
  debounceTimer = setTimeout(() => {
63
- handleChange(filepath, ignorePaths, lastGoodState, (newState, events, lifecycle) => {
72
+ handleChange(filepath, diffOpts, lastGoodState, (newState, events, lifecycle) => {
64
73
  if (newState !== null) {
65
74
  lastGoodState = newState;
66
75
  }
67
76
  if (lifecycle) {
68
- onEvent({ kind: 'lifecycle', filepath, lifecycle });
77
+ safelyEmit(onEvent, { kind: 'lifecycle', filepath, lifecycle });
69
78
  }
70
79
  if (events.length > 0) {
71
- onEvent({ kind: 'changes', filepath, events });
80
+ safelyEmit(onEvent, { kind: 'changes', filepath, events });
72
81
  } else if (reason === 'add') {
73
- onEvent({
82
+ safelyEmit(onEvent, {
74
83
  kind: 'lifecycle',
75
84
  filepath,
76
85
  lifecycle: { type: 'file-restored', message: 'File content reloaded after add event.' },
@@ -89,7 +98,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
89
98
  watcher.on('unlink', () => {
90
99
  // File temporarily missing; keep last good state and wait for add.
91
100
  renderWarn(`File disappeared: "${filepath}" (waiting for it to reappear)`);
92
- onEvent({
101
+ safelyEmit(onEvent, {
93
102
  kind: 'lifecycle',
94
103
  filepath,
95
104
  lifecycle: { type: 'file-missing', message: 'File disappeared; waiting for restore.' },
@@ -98,7 +107,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
98
107
 
99
108
  watcher.on('error', (err) => {
100
109
  renderWarn(`Watcher error: ${err.message}`);
101
- onEvent({
110
+ safelyEmit(onEvent, {
102
111
  kind: 'lifecycle',
103
112
  filepath,
104
113
  lifecycle: { type: 'watcher-error', message: err.message },
@@ -108,14 +117,22 @@ export function startWatcher(filepath, options = {}, onEvent) {
108
117
  return watcher;
109
118
  }
110
119
 
120
+ function safelyEmit(onEvent, event) {
121
+ Promise.resolve()
122
+ .then(() => onEvent(event))
123
+ .catch((err) => {
124
+ renderWarn(`Watcher event handler error: ${err?.message ?? String(err)}`);
125
+ });
126
+ }
127
+
111
128
  /**
112
129
  * Internal: re-parse the file and diff against the previous state.
113
130
  * @param {string} filepath
114
- * @param {string[]} ignorePaths
131
+ * @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} diffOpts
115
132
  * @param {unknown | null} lastGoodState
116
133
  * @param {(newState: unknown | null, events: ChangeEvent[], lifecycle: { type: string, message: string } | null) => void} callback
117
134
  */
118
- function handleChange(filepath, ignorePaths, lastGoodState, callback) {
135
+ function handleChange(filepath, diffOpts, lastGoodState, callback) {
119
136
  let newState;
120
137
  try {
121
138
  newState = parseFile(filepath);
@@ -132,6 +149,6 @@ function handleChange(filepath, ignorePaths, lastGoodState, callback) {
132
149
  return;
133
150
  }
134
151
 
135
- const events = diffTrees(lastGoodState, newState, { ignorePaths });
152
+ const events = diffTrees(lastGoodState, newState, diffOpts);
136
153
  callback(newState, events, null);
137
154
  }