flecto 2.0.0 → 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/policy.js CHANGED
@@ -17,13 +17,35 @@ import yaml from 'js-yaml';
17
17
  * id: string,
18
18
  * severity: PolicySeverity,
19
19
  * when?: Array<'added' | 'removed' | 'changed'>,
20
- * match?: { path?: string, pathFlags?: string },
20
+ * match?: { path?: string, pathFlags?: string, pathEquals?: string, pathPrefix?: string },
21
+ * beforeEquals?: unknown,
21
22
  * afterEquals?: unknown,
23
+ * beforeIn?: unknown[],
24
+ * afterIn?: unknown[],
25
+ * beforeTruthy?: true,
26
+ * afterTruthy?: true,
27
+ * afterMatches?: string,
22
28
  * numericJump?: { minMultiple: number },
29
+ * numericDelta?: { min: number },
30
+ * allOf?: PolicyMatchClause[],
31
+ * anyOf?: PolicyMatchClause[],
23
32
  * message?: string,
24
33
  * messageTemplate?: string
25
34
  * }} PolicyRule
26
35
  *
36
+ * @typedef {{
37
+ * match?: { path?: string, pathFlags?: string, pathEquals?: string, pathPrefix?: string },
38
+ * beforeEquals?: unknown,
39
+ * afterEquals?: unknown,
40
+ * beforeIn?: unknown[],
41
+ * afterIn?: unknown[],
42
+ * beforeTruthy?: true,
43
+ * afterTruthy?: true,
44
+ * afterMatches?: string,
45
+ * numericJump?: { minMultiple: number },
46
+ * numericDelta?: { min: number }
47
+ * }} PolicyMatchClause
48
+ *
27
49
  * @typedef {{ id: string, rules: PolicyRule[] }} PolicyPack
28
50
  *
29
51
  * @typedef {{
@@ -32,12 +54,85 @@ import yaml from 'js-yaml';
32
54
  * profile?: string | null,
33
55
  * source?: 'watch' | 'ci' | 'diff',
34
56
  * policies?: string[],
35
- * plugins?: string[]
57
+ * plugins?: string[],
58
+ * severityRemap?: Record<string, PolicySeverity | 'off'>
36
59
  * }} PolicyEvalOptions
37
60
  */
38
61
 
39
62
  const SEVERITY_RANK = { info: 1, warn: 2, error: 3 };
40
63
  const PACKS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'packs');
64
+ const CHANGE_TYPES = new Set(['added', 'removed', 'changed']);
65
+ const RULE_FIELDS = new Set([
66
+ 'id', 'severity', 'when', 'match', 'beforeEquals', 'afterEquals',
67
+ 'beforeIn', 'afterIn', 'beforeTruthy', 'afterTruthy', 'numericJump',
68
+ 'afterMatches', 'numericDelta', 'allOf', 'anyOf', 'message', 'messageTemplate',
69
+ ]);
70
+ const CLAUSE_FIELDS = new Set([
71
+ 'match', 'beforeEquals', 'afterEquals', 'beforeIn', 'afterIn',
72
+ 'beforeTruthy', 'afterTruthy', 'afterMatches', 'numericJump', 'numericDelta',
73
+ ]);
74
+ const MATCH_FIELDS = new Set(['path', 'pathFlags', 'pathEquals', 'pathPrefix']);
75
+
76
+ /**
77
+ * @param {string} path
78
+ * @param {string} message
79
+ * @returns {never}
80
+ */
81
+ function invalidPack(path, message) {
82
+ throw new Error(`Invalid policy pack at ${path}: ${message}`);
83
+ }
84
+
85
+ /**
86
+ * @param {unknown} value
87
+ * @returns {value is Record<string, unknown>}
88
+ */
89
+ function isObject(value) {
90
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
91
+ }
92
+
93
+ /**
94
+ * @param {unknown} value
95
+ * @returns {boolean}
96
+ */
97
+ function isTruthyToggle(value) {
98
+ if (value === true) return true;
99
+ if (typeof value !== 'string') return false;
100
+ return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
101
+ }
102
+
103
+ /**
104
+ * Validate a parsed policy pack and reject typos before evaluation.
105
+ * @param {unknown} pack
106
+ * @param {string} path
107
+ * @returns {asserts pack is PolicyPack}
108
+ */
109
+ function validatePack(pack, path) {
110
+ if (!isObject(pack)) invalidPack(path, 'pack must be an object');
111
+
112
+ const packFields = new Set(['id', 'rules']);
113
+ for (const field of Object.keys(pack)) {
114
+ if (!packFields.has(field)) invalidPack(path, `pack.${field} is not allowed`);
115
+ }
116
+ if (Object.hasOwn(pack, 'id') && (typeof pack.id !== 'string' || !pack.id.trim())) {
117
+ invalidPack(path, 'pack.id must be a non-empty string');
118
+ }
119
+ if (!Array.isArray(pack.rules)) invalidPack(path, 'pack.rules must be an array');
120
+
121
+ for (const [index, rule] of pack.rules.entries()) {
122
+ try {
123
+ validateRule(rule, `rules[${index}]`);
124
+ } catch (error) {
125
+ const label = isObject(rule) && typeof rule.id === 'string' && rule.id
126
+ ? `rule "${rule.id}"`
127
+ : `rules[${index}]`;
128
+ const message = error.message.replace(/^Invalid policy rule at [^:]+: /, '')
129
+ .replace(/^unknown field "([^"]+)"$/, '$1 is not allowed (unknown field "$1")')
130
+ .replace(/^unknown match field "([^"]+)"$/, 'match.$1 is not allowed (unknown match field "$1")')
131
+ .replace(/^match\.path is not a valid regular expression$/, 'match.path must be a valid regular expression');
132
+ invalidPack(path, `${label}.${message}`);
133
+ }
134
+ }
135
+ }
41
136
 
42
137
  /**
43
138
  * @param {string} cwd
@@ -59,18 +154,150 @@ function resolvePackPath(cwd, packId) {
59
154
 
60
155
  /**
61
156
  * @param {string} path
157
+ * @param {string} fallbackId
62
158
  * @returns {PolicyPack}
63
159
  */
64
- function readPackFile(path) {
160
+ function readPackFile(path, fallbackId) {
65
161
  const raw = readFileSync(path, 'utf8');
66
- const parsed = path.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
67
- if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rules)) {
68
- throw new Error(`Invalid policy pack at ${path}: expected { id, rules[] }`);
162
+ let parsed;
163
+ try {
164
+ parsed = path.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
165
+ } catch (error) {
166
+ invalidPack(path, `could not parse file (${error.message})`);
69
167
  }
70
- return {
71
- id: String(parsed.id ?? ''),
72
- rules: parsed.rules,
73
- };
168
+ validatePack(parsed, path);
169
+ return { ...parsed, id: parsed.id ?? fallbackId };
170
+ }
171
+
172
+ /**
173
+ * Validate a rule or composition clause so pack typos fail closed at load time.
174
+ * @param {unknown} candidate
175
+ * @param {string} location
176
+ * @param {boolean} [isClause]
177
+ */
178
+ function validateRule(candidate, location, isClause = false) {
179
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
180
+ throw new Error(`Invalid policy rule at ${location}: expected an object`);
181
+ }
182
+
183
+ const allowedFields = isClause ? CLAUSE_FIELDS : RULE_FIELDS;
184
+ for (const key of Object.keys(candidate)) {
185
+ if (!allowedFields.has(key)) {
186
+ throw new Error(`Invalid policy rule at ${location}: unknown field "${key}"`);
187
+ }
188
+ }
189
+
190
+ const rule = /** @type {Record<string, unknown>} */ (candidate);
191
+ if (!isClause && (typeof rule.id !== 'string' || !rule.id)) {
192
+ throw new Error(`Invalid policy rule at ${location}: id is required`);
193
+ }
194
+ if (!isClause && (!Object.hasOwn(rule, 'severity') || !Object.hasOwn(SEVERITY_RANK, rule.severity))) {
195
+ throw new Error(`Invalid policy rule at ${location}: severity must be one of: info, warn, error`);
196
+ }
197
+ if (rule.when !== undefined
198
+ && (!Array.isArray(rule.when) || rule.when.length === 0 || rule.when.some((type) => !CHANGE_TYPES.has(type)))) {
199
+ const invalidIndex = Array.isArray(rule.when)
200
+ ? rule.when.findIndex((type) => !CHANGE_TYPES.has(type))
201
+ : -1;
202
+ throw new Error(`Invalid policy rule at ${location}: when${invalidIndex >= 0 ? `[${invalidIndex}]` : ''} must be one of: added, removed, changed`);
203
+ }
204
+ validateMatch(rule.match, location);
205
+ validateArrayPredicate(rule.beforeIn, 'beforeIn', location);
206
+ validateArrayPredicate(rule.afterIn, 'afterIn', location);
207
+ validateTruthyPredicate(rule.beforeTruthy, 'beforeTruthy', location);
208
+ validateTruthyPredicate(rule.afterTruthy, 'afterTruthy', location);
209
+ validateRegexPredicate(rule.afterMatches, 'afterMatches', location);
210
+ validateNumericPredicate(rule.numericJump, 'numericJump', 'minMultiple', location, true);
211
+ validateNumericPredicate(rule.numericDelta, 'numericDelta', 'min', location, false);
212
+ for (const name of ['message', 'messageTemplate']) {
213
+ if (rule[name] !== undefined && typeof rule[name] !== 'string') {
214
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be a string`);
215
+ }
216
+ }
217
+
218
+ if (!isClause) {
219
+ validateComposition(rule.allOf, 'allOf', location);
220
+ validateComposition(rule.anyOf, 'anyOf', location);
221
+ }
222
+ }
223
+
224
+ /** @param {unknown} match @param {string} location */
225
+ function validateMatch(match, location) {
226
+ if (match === undefined) return;
227
+ if (!match || typeof match !== 'object' || Array.isArray(match)) {
228
+ throw new Error(`Invalid policy rule at ${location}: match must be an object`);
229
+ }
230
+ for (const key of Object.keys(match)) {
231
+ if (!MATCH_FIELDS.has(key)) {
232
+ throw new Error(`Invalid policy rule at ${location}: unknown match field "${key}"`);
233
+ }
234
+ }
235
+ const typedMatch = /** @type {Record<string, unknown>} */ (match);
236
+ for (const key of MATCH_FIELDS) {
237
+ if (typedMatch[key] !== undefined && typeof typedMatch[key] !== 'string') {
238
+ throw new Error(`Invalid policy rule at ${location}: match.${key} must be a string`);
239
+ }
240
+ }
241
+ if (typedMatch.path !== undefined) {
242
+ try {
243
+ new RegExp(typedMatch.path, typedMatch.pathFlags ?? '');
244
+ } catch {
245
+ if (typedMatch.pathFlags !== undefined) {
246
+ try {
247
+ new RegExp('(?:)', typedMatch.pathFlags);
248
+ } catch {
249
+ throw new Error(`Invalid policy rule at ${location}: match.pathFlags must be valid regular expression flags`);
250
+ }
251
+ }
252
+ throw new Error(`Invalid policy rule at ${location}: match.path is not a valid regular expression`);
253
+ }
254
+ }
255
+ }
256
+
257
+ /** @param {unknown} value @param {string} name @param {string} location */
258
+ function validateArrayPredicate(value, name, location) {
259
+ if (value !== undefined && !Array.isArray(value)) {
260
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be an array`);
261
+ }
262
+ }
263
+
264
+ /** @param {unknown} value @param {string} name @param {string} location */
265
+ function validateTruthyPredicate(value, name, location) {
266
+ if (value !== undefined && value !== true) {
267
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be true`);
268
+ }
269
+ }
270
+
271
+ /** @param {unknown} value @param {string} name @param {string} location */
272
+ function validateRegexPredicate(value, name, location) {
273
+ if (value === undefined) return;
274
+ if (typeof value !== 'string') {
275
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be a string`);
276
+ }
277
+ try {
278
+ new RegExp(value);
279
+ } catch {
280
+ throw new Error(`Invalid policy rule at ${location}: ${name} is not a valid regular expression`);
281
+ }
282
+ }
283
+
284
+ /** @param {unknown} value @param {string} name @param {string} property @param {string} location @param {boolean} positive */
285
+ function validateNumericPredicate(value, name, property, location, positive) {
286
+ if (value === undefined) return;
287
+ if (!value || typeof value !== 'object' || Array.isArray(value)
288
+ || typeof value[property] !== 'number' || !Number.isFinite(value[property])
289
+ || (positive ? value[property] <= 0 : value[property] < 0)) {
290
+ throw new Error(`Invalid policy rule at ${location}: ${name}.${property} must be a ${positive ? 'positive' : 'non-negative'} finite number`);
291
+ }
292
+ }
293
+
294
+ /** @param {unknown} clauses @param {string} name @param {string} location */
295
+ function validateComposition(clauses, name, location) {
296
+ if (clauses === undefined) return;
297
+ if (!Array.isArray(clauses) || clauses.length === 0) {
298
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be a non-empty array of match clauses`);
299
+ }
300
+ clauses.forEach((clause, index) => validateRule(clause, `${location}.${name}[${index}]`, true));
74
301
  }
75
302
 
76
303
  /**
@@ -86,9 +313,7 @@ export function loadPack(packId, cwd = process.cwd()) {
86
313
  if (!path) {
87
314
  throw new Error(`Unknown policy pack "${id}". Add policies/${id}.json or use a built-in pack.`);
88
315
  }
89
- const pack = readPackFile(path);
90
- if (!pack.id) pack.id = id;
91
- return pack;
316
+ return readPackFile(path, id);
92
317
  }
93
318
 
94
319
  /**
@@ -102,6 +327,47 @@ export function listBuiltinPackIds() {
102
327
  .map((f) => f.replace(/\.json$/, ''));
103
328
  }
104
329
 
330
+ /**
331
+ * List every policy pack resolvable from a working directory. Local packs take
332
+ * precedence over built-ins using the same order as loadPack().
333
+ * @param {string} [cwd]
334
+ * @returns {Array<{
335
+ * id: string,
336
+ * sourcePath: string,
337
+ * source: 'builtin' | 'local',
338
+ * ruleCount: number,
339
+ * overridesBuiltin: boolean
340
+ * }>}
341
+ */
342
+ export function listPolicyPacks(cwd = process.cwd()) {
343
+ const localDir = resolve(cwd, 'policies');
344
+ const localIds = existsSync(localDir)
345
+ ? readdirSync(localDir)
346
+ .filter((file) => /\.(json|yaml|yml)$/.test(file))
347
+ .map((file) => file.replace(/\.(json|yaml|yml)$/, ''))
348
+ : [];
349
+ const builtinIds = listBuiltinPackIds();
350
+ const builtinIdSet = new Set(builtinIds);
351
+
352
+ return [...new Set([...builtinIds, ...localIds])]
353
+ .sort()
354
+ .map((id) => {
355
+ const sourcePath = resolvePackPath(cwd, id);
356
+ if (!sourcePath) {
357
+ throw new Error(`Unable to resolve policy pack "${id}"`);
358
+ }
359
+ const pack = readPackFile(sourcePath, id);
360
+ const isLocal = localIds.includes(id);
361
+ return {
362
+ id,
363
+ sourcePath,
364
+ source: isLocal ? 'local' : 'builtin',
365
+ ruleCount: pack.rules.length,
366
+ overridesBuiltin: isLocal && builtinIdSet.has(id),
367
+ };
368
+ });
369
+ }
370
+
105
371
  /**
106
372
  * @param {PolicyRule} rule
107
373
  * @param {import('./differ.js').ChangeEvent} change
@@ -110,22 +376,44 @@ export function listBuiltinPackIds() {
110
376
  function ruleMatches(rule, change) {
111
377
  const when = rule.when ?? ['added', 'removed', 'changed'];
112
378
  if (!when.includes(change.type)) return false;
379
+ if (!matchClause(rule, change)) return false;
380
+ if (rule.allOf?.some((clause) => !matchClause(clause, change))) return false;
381
+ if (rule.anyOf && !rule.anyOf.some((clause) => matchClause(clause, change))) return false;
382
+ return true;
383
+ }
113
384
 
114
- if (rule.match?.path) {
115
- const flags = rule.match.pathFlags ?? '';
116
- const re = new RegExp(rule.match.path, flags);
117
- if (!re.test(change.path ?? '')) return false;
118
- }
385
+ /**
386
+ * @param {PolicyMatchClause} clause
387
+ * @param {import('./differ.js').ChangeEvent} change
388
+ * @returns {boolean}
389
+ */
390
+ function matchClause(clause, change) {
391
+ const path = change.path ?? '';
392
+ const match = clause.match;
393
+ if (match?.path && !new RegExp(match.path, match.pathFlags ?? '').test(path)) return false;
394
+ if (match?.pathEquals !== undefined && path !== match.pathEquals) return false;
395
+ if (match?.pathPrefix !== undefined && !path.startsWith(match.pathPrefix)) return false;
396
+
397
+ if (Object.prototype.hasOwnProperty.call(clause, 'beforeEquals') && change.before !== clause.beforeEquals) return false;
398
+ if (Object.prototype.hasOwnProperty.call(clause, 'afterEquals') && change.after !== clause.afterEquals) return false;
399
+ if (clause.beforeIn && !clause.beforeIn.includes(change.before)) return false;
400
+ if (clause.afterIn && !clause.afterIn.includes(change.after)) return false;
401
+ if (clause.beforeTruthy && !isTruthyToggle(change.before)) return false;
402
+ if (clause.afterTruthy && !isTruthyToggle(change.after)) return false;
403
+ if (clause.afterMatches && (typeof change.after !== 'string' || !new RegExp(clause.afterMatches).test(change.after))) return false;
119
404
 
120
- if (Object.prototype.hasOwnProperty.call(rule, 'afterEquals')) {
121
- if (change.after !== rule.afterEquals) return false;
405
+ if (clause.numericJump) {
406
+ const before = change.before;
407
+ const after = change.after;
408
+ if (typeof before !== 'number' || typeof after !== 'number') return false;
409
+ if (!(before > 0 && after >= before * clause.numericJump.minMultiple)) return false;
122
410
  }
123
411
 
124
- if (rule.numericJump) {
412
+ if (clause.numericDelta) {
125
413
  const before = change.before;
126
414
  const after = change.after;
127
415
  if (typeof before !== 'number' || typeof after !== 'number') return false;
128
- if (!(before > 0 && after >= before * rule.numericJump.minMultiple)) return false;
416
+ if (Math.abs(after - before) < clause.numericDelta.min) return false;
129
417
  }
130
418
 
131
419
  return true;
@@ -149,17 +437,20 @@ function formatMessage(rule, change) {
149
437
  /**
150
438
  * @param {PolicyPack} pack
151
439
  * @param {import('./differ.js').ChangeEvent[]} changes
440
+ * @param {Record<string, PolicySeverity | 'off'>} [severityRemap]
152
441
  * @returns {PolicyFinding[]}
153
442
  */
154
- export function evaluatePack(pack, changes) {
443
+ export function evaluatePack(pack, changes, severityRemap = {}) {
155
444
  /** @type {PolicyFinding[]} */
156
445
  const findings = [];
157
446
  for (const change of changes) {
158
447
  for (const rule of pack.rules ?? []) {
159
448
  if (!ruleMatches(rule, change)) continue;
449
+ const severity = severityRemap[String(rule.id)] ?? rule.severity ?? 'warn';
450
+ if (severity === 'off') continue;
160
451
  findings.push({
161
452
  id: String(rule.id),
162
- severity: rule.severity ?? 'warn',
453
+ severity,
163
454
  path: change.path ?? '',
164
455
  message: formatMessage(rule, change),
165
456
  pack: pack.id,
@@ -241,12 +532,19 @@ export async function evaluatePolicies(changes, options = {}) {
241
532
  const cwd = options.cwd ?? process.cwd();
242
533
  const packIds = options.policies?.length ? options.policies : ['default'];
243
534
  const plugins = options.plugins ?? [];
535
+ const severityRemap = options.severityRemap ?? {};
244
536
 
245
537
  /** @type {PolicyFinding[]} */
246
538
  const findings = [];
247
- for (const packId of packIds) {
248
- const pack = loadPack(packId, cwd);
249
- findings.push(...evaluatePack(pack, changes));
539
+ const packs = packIds.map((packId) => loadPack(packId, cwd));
540
+ const knownRuleIds = new Set(packs.flatMap((pack) => pack.rules.map((rule) => String(rule.id))));
541
+ for (const ruleId of Object.keys(severityRemap)) {
542
+ if (!knownRuleIds.has(ruleId)) {
543
+ console.warn(`Unknown policy rule id in severityRemap: "${ruleId}"`);
544
+ }
545
+ }
546
+ for (const pack of packs) {
547
+ findings.push(...evaluatePack(pack, changes, severityRemap));
250
548
  }
251
549
 
252
550
  const ctx = {
package/src/renderer.js CHANGED
@@ -154,7 +154,11 @@ export function maskSensitiveValue(value, path = '') {
154
154
  if (Array.isArray(value)) {
155
155
  return value.map((v, i) => maskSensitiveValue(v, `${path}[${i}]`));
156
156
  }
157
- if (value && typeof value === 'object') {
157
+ if (
158
+ value
159
+ && typeof value === 'object'
160
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
161
+ ) {
158
162
  /** @type {Record<string, unknown>} */
159
163
  const out = {};
160
164
  for (const [k, v] of Object.entries(value)) {
@@ -171,10 +175,9 @@ export function maskSensitiveValue(value, path = '') {
171
175
  * @returns {import('./differ.js').ChangeEvent}
172
176
  */
173
177
  export function maskChangeEvent(event) {
174
- if (!SECRET_PATH_RE.test(event.path ?? '')) return event;
175
178
  return {
176
179
  ...event,
177
- before: event.before === undefined ? undefined : '***',
178
- after: event.after === undefined ? undefined : '***',
180
+ before: event.before === undefined ? undefined : maskSensitiveValue(event.before, event.path),
181
+ after: event.after === undefined ? undefined : maskSensitiveValue(event.after, event.path),
179
182
  };
180
183
  }
package/src/watcher.js CHANGED
@@ -12,6 +12,7 @@ import { renderWarn, renderInfo } from './renderer.js';
12
12
  * @property {string} [mode] Output mode: 'compact' | 'verbose'
13
13
  * @property {string[]} [ignorePaths] Key paths to suppress in diffs
14
14
  * @property {string | null} [arrayIdKey]
15
+ * @property {boolean} [arrayIdentity]
15
16
  * @property {boolean} [arrayIgnoreOrder]
16
17
  */
17
18
 
@@ -20,7 +21,7 @@ import { renderWarn, renderInfo } from './renderer.js';
20
21
  *
21
22
  * @param {string} filepath
22
23
  * @param {WatcherOptions} options
23
- * @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
24
25
  * @returns {import('chokidar').FSWatcher}
25
26
  */
26
27
  export function startWatcher(filepath, options = {}, onEvent) {
@@ -30,6 +31,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
30
31
  const diffOpts = {
31
32
  ignorePaths,
32
33
  arrayIdKey: options.arrayIdKey ?? null,
34
+ arrayIdentity: options.arrayIdentity !== false,
33
35
  arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
34
36
  };
35
37
 
@@ -42,7 +44,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
42
44
  } catch (err) {
43
45
  renderWarn(`Could not parse initial state of "${filepath}": ${err.message}`);
44
46
  renderWarn('Watching anyway — will use first successful parse as baseline.');
45
- onEvent({
47
+ safelyEmit(onEvent, {
46
48
  kind: 'lifecycle',
47
49
  filepath,
48
50
  lifecycle: { type: 'initial-parse-failed', message: err.message },
@@ -72,12 +74,12 @@ export function startWatcher(filepath, options = {}, onEvent) {
72
74
  lastGoodState = newState;
73
75
  }
74
76
  if (lifecycle) {
75
- onEvent({ kind: 'lifecycle', filepath, lifecycle });
77
+ safelyEmit(onEvent, { kind: 'lifecycle', filepath, lifecycle });
76
78
  }
77
79
  if (events.length > 0) {
78
- onEvent({ kind: 'changes', filepath, events });
80
+ safelyEmit(onEvent, { kind: 'changes', filepath, events });
79
81
  } else if (reason === 'add') {
80
- onEvent({
82
+ safelyEmit(onEvent, {
81
83
  kind: 'lifecycle',
82
84
  filepath,
83
85
  lifecycle: { type: 'file-restored', message: 'File content reloaded after add event.' },
@@ -96,7 +98,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
96
98
  watcher.on('unlink', () => {
97
99
  // File temporarily missing; keep last good state and wait for add.
98
100
  renderWarn(`File disappeared: "${filepath}" (waiting for it to reappear)`);
99
- onEvent({
101
+ safelyEmit(onEvent, {
100
102
  kind: 'lifecycle',
101
103
  filepath,
102
104
  lifecycle: { type: 'file-missing', message: 'File disappeared; waiting for restore.' },
@@ -105,7 +107,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
105
107
 
106
108
  watcher.on('error', (err) => {
107
109
  renderWarn(`Watcher error: ${err.message}`);
108
- onEvent({
110
+ safelyEmit(onEvent, {
109
111
  kind: 'lifecycle',
110
112
  filepath,
111
113
  lifecycle: { type: 'watcher-error', message: err.message },
@@ -115,10 +117,18 @@ export function startWatcher(filepath, options = {}, onEvent) {
115
117
  return watcher;
116
118
  }
117
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
+
118
128
  /**
119
129
  * Internal: re-parse the file and diff against the previous state.
120
130
  * @param {string} filepath
121
- * @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} diffOpts
131
+ * @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} diffOpts
122
132
  * @param {unknown | null} lastGoodState
123
133
  * @param {(newState: unknown | null, events: ChangeEvent[], lifecycle: { type: string, message: string } | null) => void} callback
124
134
  */