flecto 2.0.0 → 3.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.
@@ -0,0 +1,500 @@
1
+ import { readFileSync } from 'fs';
2
+ import { diffTrees } from './differ.js';
3
+
4
+ /**
5
+ * Terraform plan JSON → Flecto change events.
6
+ *
7
+ * Flecto never runs the `terraform` binary. It reads the JSON Terraform itself
8
+ * produces:
9
+ *
10
+ * terraform plan -out plan.tfplan
11
+ * terraform show -json plan.tfplan > plan.json
12
+ *
13
+ * The conversion flattens each `resource_changes[]` entry into a map of leaf
14
+ * path → value for the "before" and "after" sides, keyed by the resource
15
+ * `address`, then hands both sides to the same {@link diffTrees} every other
16
+ * Flecto command uses. That is what makes `aws_security_group.web` produce
17
+ * paths such as `aws_security_group.web.ingress[0].cidr_blocks[0]`, and it is
18
+ * why `--ignore` behaves identically here and on config files.
19
+ *
20
+ * @typedef {import('./differ.js').ChangeEvent} ChangeEvent
21
+ *
22
+ * @typedef {'create' | 'read' | 'update' | 'delete' | 'replace' | 'no-op'} TerraformAction
23
+ *
24
+ * @typedef {{
25
+ * create: number,
26
+ * update: number,
27
+ * delete: number,
28
+ * replace: number,
29
+ * read: number,
30
+ * noop: number,
31
+ * other: number,
32
+ * resources: number
33
+ * }} TerraformPlanSummary
34
+ *
35
+ * @typedef {{
36
+ * changes: ChangeEvent[],
37
+ * summary: TerraformPlanSummary,
38
+ * formatVersion: string | null,
39
+ * terraformVersion: string | null,
40
+ * warnings: string[]
41
+ * }} TerraformPlanDiff
42
+ */
43
+
44
+ /**
45
+ * Stand-in for a value Terraform cannot resolve until apply. Terraform's own
46
+ * CLI prints this exact phrase, so a Flecto diff reads the same way a
47
+ * `terraform plan` does.
48
+ */
49
+ export const UNKNOWN_VALUE = '(known after apply)';
50
+
51
+ /**
52
+ * Stand-in for a value Terraform marked sensitive. Substituted at parse time —
53
+ * before the policy engine, the envelope, or any formatter can see it — so a
54
+ * value Terraform refuses to print can never leak through Flecto either.
55
+ */
56
+ export const SENSITIVE_VALUE = '(sensitive value)';
57
+
58
+ /**
59
+ * Pseudo-attribute carrying the planned action for a resource. `#` cannot occur
60
+ * in a Terraform attribute name, so this can never collide with a real one, and
61
+ * it gives resource-level policy rules a single event to match instead of one
62
+ * per attribute.
63
+ */
64
+ const ACTION_KEY = '#action';
65
+
66
+ /** Plan format versions this converter has been written against. */
67
+ const SUPPORTED_FORMAT_MAJOR = 1;
68
+
69
+ const GENERATE_HINT = 'Generate one with:\n'
70
+ + '\n'
71
+ + ' terraform plan -out plan.tfplan\n'
72
+ + ' terraform show -json plan.tfplan > plan.json\n'
73
+ + '\n'
74
+ + 'Flecto never runs the terraform binary — it only reads the JSON terraform writes.';
75
+
76
+ /**
77
+ * @param {unknown} value
78
+ * @returns {value is Record<string, unknown>}
79
+ */
80
+ function isPlainObject(value) {
81
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
82
+ }
83
+
84
+ /**
85
+ * Read one position out of an `after_unknown` / `*_sensitive` mirror.
86
+ *
87
+ * A mirror is `true` (everything at and below this position is unknown or
88
+ * sensitive), `false`/absent, or a nested object/array shaped like the value.
89
+ * `true` propagates down so marking a whole map sensitive marks every leaf.
90
+ * @param {unknown} mirror
91
+ * @param {string | number} key
92
+ * @returns {unknown}
93
+ */
94
+ function childMirror(mirror, key) {
95
+ if (mirror === true) return true;
96
+ if (mirror && typeof mirror === 'object') {
97
+ return Object.hasOwn(mirror, key) ? mirror[key] : undefined;
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ /**
103
+ * @param {unknown} mirror
104
+ * @returns {string[]}
105
+ */
106
+ function mirrorKeys(mirror) {
107
+ return isPlainObject(mirror) ? Object.keys(mirror) : [];
108
+ }
109
+
110
+ /**
111
+ * @param {unknown} mirror
112
+ * @returns {number}
113
+ */
114
+ function mirrorLength(mirror) {
115
+ return Array.isArray(mirror) ? mirror.length : 0;
116
+ }
117
+
118
+ /**
119
+ * @typedef {{
120
+ * entries: Array<[string, unknown]>,
121
+ * unknownPaths: Set<string>,
122
+ * sensitivePaths: Set<string>
123
+ * }} FlattenContext
124
+ */
125
+
126
+ /**
127
+ * Flatten one side of a resource change into leaf `path → value` entries.
128
+ *
129
+ * `null` and absent values are dropped rather than emitted, matching how
130
+ * Terraform renders a plan: an optional attribute that is simply unset is not a
131
+ * change. Unsetting an attribute therefore shows up as a removal, and setting
132
+ * one as an addition.
133
+ * @param {unknown} value
134
+ * @param {unknown} unknown the matching `after_unknown` position
135
+ * @param {unknown} sensitive the matching `*_sensitive` position
136
+ * @param {string} path
137
+ * @param {FlattenContext} ctx
138
+ */
139
+ function flatten(value, unknown, sensitive, path, ctx) {
140
+ if (unknown === true) {
141
+ ctx.entries.push([path, UNKNOWN_VALUE]);
142
+ ctx.unknownPaths.add(path);
143
+ if (sensitive === true) ctx.sensitivePaths.add(path);
144
+ return;
145
+ }
146
+
147
+ const objectShaped = isPlainObject(value)
148
+ || (value == null && (isPlainObject(unknown) || isPlainObject(sensitive)));
149
+ if (objectShaped) {
150
+ const object = isPlainObject(value) ? value : {};
151
+ const keys = new Set([...Object.keys(object), ...mirrorKeys(unknown), ...mirrorKeys(sensitive)]);
152
+ if (keys.size === 0) {
153
+ pushLeaf(path, isPlainObject(value) ? value : undefined, sensitive, ctx);
154
+ return;
155
+ }
156
+ for (const key of keys) {
157
+ flatten(
158
+ Object.hasOwn(object, key) ? object[key] : undefined,
159
+ childMirror(unknown, key),
160
+ childMirror(sensitive, key),
161
+ `${path}.${key}`,
162
+ ctx,
163
+ );
164
+ }
165
+ return;
166
+ }
167
+
168
+ const arrayShaped = Array.isArray(value)
169
+ || (value == null && (Array.isArray(unknown) || Array.isArray(sensitive)));
170
+ if (arrayShaped) {
171
+ const list = Array.isArray(value) ? value : [];
172
+ const length = Math.max(list.length, mirrorLength(unknown), mirrorLength(sensitive));
173
+ if (length === 0) {
174
+ pushLeaf(path, Array.isArray(value) ? value : undefined, sensitive, ctx);
175
+ return;
176
+ }
177
+ for (let index = 0; index < length; index += 1) {
178
+ flatten(
179
+ list[index],
180
+ childMirror(unknown, index),
181
+ childMirror(sensitive, index),
182
+ `${path}[${index}]`,
183
+ ctx,
184
+ );
185
+ }
186
+ return;
187
+ }
188
+
189
+ pushLeaf(path, value, sensitive, ctx);
190
+ }
191
+
192
+ /**
193
+ * @param {string} path
194
+ * @param {unknown} value
195
+ * @param {unknown} sensitive
196
+ * @param {FlattenContext} ctx
197
+ */
198
+ function pushLeaf(path, value, sensitive, ctx) {
199
+ // An attribute that is null or absent on this side is not a value; see
200
+ // flatten() for why that is deliberate.
201
+ if (value === undefined || value === null) return;
202
+ ctx.entries.push([path, value]);
203
+ if (sensitive === true) ctx.sensitivePaths.add(path);
204
+ }
205
+
206
+ /**
207
+ * Collapse Terraform's action array into a single verb.
208
+ *
209
+ * `["delete","create"]` and `["create","delete"]` are both a replace — the
210
+ * ordering only says whether the new resource is created before the old one is
211
+ * destroyed. Anything unrecognized is returned verbatim so a future Terraform
212
+ * action is reported rather than silently dropped.
213
+ * @param {unknown} actions
214
+ * @returns {TerraformAction | string}
215
+ */
216
+ export function normalizeAction(actions) {
217
+ const list = Array.isArray(actions) ? actions.map(String) : [];
218
+ if (list.length === 0) return 'no-op';
219
+ if (list.length === 1) return list[0];
220
+ if (list.length === 2 && list.includes('create') && list.includes('delete')) return 'replace';
221
+ return list.join(',');
222
+ }
223
+
224
+ /**
225
+ * @param {unknown} replacePaths
226
+ * @returns {string[]}
227
+ */
228
+ function formatReplacePaths(replacePaths) {
229
+ if (!Array.isArray(replacePaths)) return [];
230
+ return replacePaths
231
+ .map((entry) => (Array.isArray(entry) ? entry.map(String).join('.') : String(entry ?? '')))
232
+ .filter(Boolean);
233
+ }
234
+
235
+ /**
236
+ * Plain-English sentence describing what Terraform will do to a resource.
237
+ * @param {string} action
238
+ * @param {string} address
239
+ * @param {unknown} replacePaths
240
+ * @returns {string}
241
+ */
242
+ function actionNote(action, address, replacePaths) {
243
+ if (action === 'create') return `terraform will create ${address}`;
244
+ if (action === 'update') return `terraform will update ${address} in place`;
245
+ if (action === 'delete') return `terraform will destroy ${address}`;
246
+ if (action === 'replace') {
247
+ const forced = formatReplacePaths(replacePaths);
248
+ const because = forced.length > 0 ? ` (forced by: ${forced.join(', ')})` : '';
249
+ return `terraform will destroy and recreate ${address}${because}`;
250
+ }
251
+ return `terraform will apply "${action}" to ${address}`;
252
+ }
253
+
254
+ /**
255
+ * The `#action` marker values for a planned action.
256
+ *
257
+ * `create` becomes an addition, `delete` a removal, `update` a change — and
258
+ * `replace` is deliberately a **removal** carrying the value `"replace"`. A
259
+ * replace destroys the resource before (or right after) recreating it, so
260
+ * representing it as a change would let it pass a reviewer's eye, and any
261
+ * `--fail-on removed` gate as well. Modelling the destruction is the honest
262
+ * reading: the recreated resource is a new object, with a new id, and anything
263
+ * held only on the old one is gone.
264
+ * @param {string} action
265
+ * @returns {{ before?: string, after?: string }}
266
+ */
267
+ function actionMarker(action) {
268
+ if (action === 'create') return { after: 'create' };
269
+ if (action === 'delete') return { before: 'delete' };
270
+ if (action === 'replace') return { before: 'replace' };
271
+ if (action === 'update') return { before: 'no-op', after: 'update' };
272
+ return { before: 'no-op', after: action };
273
+ }
274
+
275
+ /**
276
+ * Unique key for a resource change. Terraform addresses already carry `count` /
277
+ * `for_each` keys, so only a deposed instance can collide with its live one.
278
+ * @param {Record<string, unknown>} change
279
+ * @param {number} index
280
+ * @returns {string}
281
+ */
282
+ function resourceAddress(change, index) {
283
+ const address = typeof change.address === 'string' && change.address.trim()
284
+ ? change.address.trim()
285
+ : `resource_changes[${index}]`;
286
+ const deposed = typeof change.deposed === 'string' && change.deposed.trim()
287
+ ? change.deposed.trim()
288
+ : null;
289
+ return deposed ? `${address} (deposed ${deposed})` : address;
290
+ }
291
+
292
+ /**
293
+ * @param {string} existing
294
+ * @param {string} addition
295
+ * @returns {string}
296
+ */
297
+ function joinNote(existing, addition) {
298
+ return existing ? `${existing}; ${addition}` : addition;
299
+ }
300
+
301
+ /**
302
+ * Convert one `resource_changes[]` entry into change events.
303
+ * @param {Record<string, unknown>} resourceChange
304
+ * @param {number} index
305
+ * @param {{ ignorePaths?: string[] }} options
306
+ * @returns {{ action: string, events: ChangeEvent[] }}
307
+ */
308
+ function resourceChangeToEvents(resourceChange, index, options) {
309
+ const change = isPlainObject(resourceChange.change) ? resourceChange.change : {};
310
+ const action = normalizeAction(change.actions);
311
+ if (action === 'no-op' || action === 'read') return { action, events: [] };
312
+
313
+ const address = resourceAddress(resourceChange, index);
314
+ const marker = actionMarker(action);
315
+
316
+ /** @type {FlattenContext} */
317
+ const beforeCtx = { entries: [], unknownPaths: new Set(), sensitivePaths: new Set() };
318
+ /** @type {FlattenContext} */
319
+ const afterCtx = { entries: [], unknownPaths: new Set(), sensitivePaths: new Set() };
320
+
321
+ // A create has no prior state and a delete has no planned state, so only the
322
+ // populated side is flattened. There is no `before_unknown`: prior state is
323
+ // always fully known.
324
+ if (action !== 'create') {
325
+ flatten(change.before, undefined, change.before_sensitive, address, beforeCtx);
326
+ }
327
+ if (action !== 'delete') {
328
+ flatten(change.after, change.after_unknown, change.after_sensitive, address, afterCtx);
329
+ }
330
+
331
+ if (marker.before !== undefined) beforeCtx.entries.push([`${address}.${ACTION_KEY}`, marker.before]);
332
+ if (marker.after !== undefined) afterCtx.entries.push([`${address}.${ACTION_KEY}`, marker.after]);
333
+
334
+ const sensitivePaths = new Set([...beforeCtx.sensitivePaths, ...afterCtx.sensitivePaths]);
335
+ const unknownPaths = afterCtx.unknownPaths;
336
+
337
+ // The diff runs on the raw values so an equal-looking pair of redacted
338
+ // sensitive values cannot hide a real change; redaction happens immediately
339
+ // afterwards, before anything leaves this module.
340
+ const events = diffTrees(
341
+ Object.fromEntries(beforeCtx.entries),
342
+ Object.fromEntries(afterCtx.entries),
343
+ { ignorePaths: options.ignorePaths ?? [] },
344
+ );
345
+
346
+ const note = actionNote(action, address, change.replace_paths);
347
+ /** @type {ChangeEvent[]} */
348
+ const out = [];
349
+ for (const event of events) {
350
+ // "This computed attribute will be known after apply" says nothing when the
351
+ // attribute is brand new. The same value replacing a known one does, so
352
+ // only pure additions are dropped.
353
+ if (event.type === 'added' && event.after === UNKNOWN_VALUE) continue;
354
+
355
+ /** @type {ChangeEvent} */
356
+ const next = { ...event };
357
+ if (sensitivePaths.has(event.path)) {
358
+ if (Object.hasOwn(next, 'before')) next.before = SENSITIVE_VALUE;
359
+ if (Object.hasOwn(next, 'after')) next.after = SENSITIVE_VALUE;
360
+ next.note = joinNote(next.note ?? '', 'sensitive');
361
+ }
362
+ if (unknownPaths.has(event.path)) {
363
+ next.note = joinNote(next.note ?? '', 'known after apply');
364
+ }
365
+ if (event.path === `${address}.${ACTION_KEY}`) {
366
+ next.note = joinNote(next.note ?? '', note);
367
+ }
368
+ out.push(next);
369
+ }
370
+
371
+ return { action, events: out };
372
+ }
373
+
374
+ /**
375
+ * True when a parsed JSON value looks like `terraform show -json <planfile>`
376
+ * output.
377
+ * @param {unknown} value
378
+ * @returns {boolean}
379
+ */
380
+ export function isTerraformPlan(value) {
381
+ return isPlainObject(value)
382
+ && typeof value.format_version === 'string'
383
+ && Array.isArray(value.resource_changes);
384
+ }
385
+
386
+ /**
387
+ * True when a parsed JSON value is Terraform *state* rather than a plan —
388
+ * `terraform show -json` with no plan file argument.
389
+ * @param {unknown} value
390
+ * @returns {boolean}
391
+ */
392
+ function isTerraformState(value) {
393
+ return isPlainObject(value)
394
+ && typeof value.format_version === 'string'
395
+ && !Array.isArray(value.resource_changes)
396
+ && isPlainObject(value.values);
397
+ }
398
+
399
+ /**
400
+ * Throw a message that explains how to produce the file Flecto wants.
401
+ * @param {unknown} value
402
+ * @param {string} label
403
+ * @returns {asserts value is Record<string, unknown>}
404
+ */
405
+ export function assertTerraformPlan(value, label) {
406
+ if (isTerraformPlan(value)) return;
407
+ if (isTerraformState(value)) {
408
+ throw new Error(
409
+ `"${label}" is Terraform state, not a plan: it has "values" but no "resource_changes".\n`
410
+ + `${GENERATE_HINT}`,
411
+ );
412
+ }
413
+ throw new Error(
414
+ `"${label}" is not Terraform plan JSON: expected top-level "format_version" and "resource_changes".\n`
415
+ + `${GENERATE_HINT}`,
416
+ );
417
+ }
418
+
419
+ /**
420
+ * Read and validate a Terraform plan JSON file.
421
+ * @param {string} filepath
422
+ * @returns {Record<string, unknown>}
423
+ */
424
+ export function readTerraformPlanFile(filepath) {
425
+ let raw;
426
+ try {
427
+ raw = readFileSync(filepath, 'utf8');
428
+ } catch (error) {
429
+ throw new Error(`Cannot read Terraform plan "${filepath}": ${error.message}`);
430
+ }
431
+ let parsed;
432
+ try {
433
+ parsed = JSON.parse(raw);
434
+ } catch (error) {
435
+ throw new Error(
436
+ `Could not parse "${filepath}" as JSON: ${error.message}\n${GENERATE_HINT}`,
437
+ );
438
+ }
439
+ assertTerraformPlan(parsed, filepath);
440
+ return parsed;
441
+ }
442
+
443
+ /**
444
+ * Convert a parsed Terraform plan into Flecto change events.
445
+ *
446
+ * Only `ignorePaths` is honored from the usual diff options: a plan is already
447
+ * flattened onto Terraform's own indexed addressing (`ingress[0]`), so array
448
+ * identity matching and order-insensitive comparison have nothing to act on.
449
+ * @param {unknown} plan parsed `terraform show -json` output
450
+ * @param {{ ignorePaths?: string[] }} [options]
451
+ * @returns {TerraformPlanDiff}
452
+ */
453
+ export function diffTerraformPlan(plan, options = {}) {
454
+ assertTerraformPlan(plan, 'plan');
455
+
456
+ /** @type {ChangeEvent[]} */
457
+ const changes = [];
458
+ const summary = {
459
+ create: 0, update: 0, delete: 0, replace: 0, read: 0, noop: 0, other: 0, resources: 0,
460
+ };
461
+ /** @type {string[]} */
462
+ const warnings = [];
463
+
464
+ const formatVersion = typeof plan.format_version === 'string' ? plan.format_version : null;
465
+ const terraformVersion = typeof plan.terraform_version === 'string' ? plan.terraform_version : null;
466
+ const major = Number.parseInt(String(formatVersion ?? '').split('.')[0], 10);
467
+ if (Number.isFinite(major) && major > SUPPORTED_FORMAT_MAJOR) {
468
+ warnings.push(
469
+ `Plan format version ${formatVersion} is newer than the ${SUPPORTED_FORMAT_MAJOR}.x format Flecto understands. `
470
+ + 'Attributes may be misread; upgrade Flecto if the diff looks wrong.',
471
+ );
472
+ }
473
+
474
+ const resourceChanges = /** @type {unknown[]} */ (plan.resource_changes);
475
+ for (const [index, entry] of resourceChanges.entries()) {
476
+ if (!isPlainObject(entry)) {
477
+ warnings.push(`Skipping resource_changes[${index}]: expected an object.`);
478
+ continue;
479
+ }
480
+ summary.resources += 1;
481
+ const { action, events } = resourceChangeToEvents(entry, index, options);
482
+ if (action === 'no-op') summary.noop += 1;
483
+ else if (action === 'read') summary.read += 1;
484
+ else if (Object.hasOwn(summary, action)) summary[action] += 1;
485
+ else summary.other += 1;
486
+ changes.push(...events);
487
+ }
488
+
489
+ return { changes, summary, formatVersion, terraformVersion, warnings };
490
+ }
491
+
492
+ /**
493
+ * One-line summary in Terraform's own wording.
494
+ * @param {TerraformPlanSummary} summary
495
+ * @returns {string}
496
+ */
497
+ export function formatPlanSummary(summary) {
498
+ return `Plan: ${summary.create} to add, ${summary.update} to change, `
499
+ + `${summary.delete} to destroy, ${summary.replace} to replace.`;
500
+ }
package/src/watcher.js CHANGED
@@ -5,6 +5,8 @@ import { renderWarn, renderInfo } from './renderer.js';
5
5
 
6
6
  /** @typedef {import('./differ.js').ChangeEvent} ChangeEvent */
7
7
 
8
+ const NO_STATE = Symbol('no-state');
9
+
8
10
  /**
9
11
  * @typedef {Object} WatcherOptions
10
12
  * @property {number} [interval] Polling fallback interval in ms (default: 100)
@@ -12,6 +14,7 @@ import { renderWarn, renderInfo } from './renderer.js';
12
14
  * @property {string} [mode] Output mode: 'compact' | 'verbose'
13
15
  * @property {string[]} [ignorePaths] Key paths to suppress in diffs
14
16
  * @property {string | null} [arrayIdKey]
17
+ * @property {boolean} [arrayIdentity]
15
18
  * @property {boolean} [arrayIgnoreOrder]
16
19
  */
17
20
 
@@ -20,7 +23,7 @@ import { renderWarn, renderInfo } from './renderer.js';
20
23
  *
21
24
  * @param {string} filepath
22
25
  * @param {WatcherOptions} options
23
- * @param {(event: { kind: 'changes', filepath: string, events: ChangeEvent[] } | { kind: 'lifecycle', filepath: string, lifecycle: { type: string, message: string } }) => void} onEvent
26
+ * @param {(event: { kind: 'changes', filepath: string, events: ChangeEvent[] } | { kind: 'lifecycle', filepath: string, lifecycle: { type: string, message: string } }) => void | Promise<void>} onEvent
24
27
  * @returns {import('chokidar').FSWatcher}
25
28
  */
26
29
  export function startWatcher(filepath, options = {}, onEvent) {
@@ -30,11 +33,12 @@ export function startWatcher(filepath, options = {}, onEvent) {
30
33
  const diffOpts = {
31
34
  ignorePaths,
32
35
  arrayIdKey: options.arrayIdKey ?? null,
36
+ arrayIdentity: options.arrayIdentity !== false,
33
37
  arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
34
38
  };
35
39
 
36
- /** @type {unknown | null} */
37
- let lastGoodState = null;
40
+ /** @type {unknown | typeof NO_STATE} */
41
+ let lastGoodState = NO_STATE;
38
42
 
39
43
  // Attempt initial parse so we have a baseline before the first write
40
44
  try {
@@ -42,7 +46,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
42
46
  } catch (err) {
43
47
  renderWarn(`Could not parse initial state of "${filepath}": ${err.message}`);
44
48
  renderWarn('Watching anyway — will use first successful parse as baseline.');
45
- onEvent({
49
+ safelyEmit(onEvent, {
46
50
  kind: 'lifecycle',
47
51
  filepath,
48
52
  lifecycle: { type: 'initial-parse-failed', message: err.message },
@@ -68,16 +72,16 @@ export function startWatcher(filepath, options = {}, onEvent) {
68
72
  if (debounceTimer) clearTimeout(debounceTimer);
69
73
  debounceTimer = setTimeout(() => {
70
74
  handleChange(filepath, diffOpts, lastGoodState, (newState, events, lifecycle) => {
71
- if (newState !== null) {
75
+ if (newState !== NO_STATE) {
72
76
  lastGoodState = newState;
73
77
  }
74
78
  if (lifecycle) {
75
- onEvent({ kind: 'lifecycle', filepath, lifecycle });
79
+ safelyEmit(onEvent, { kind: 'lifecycle', filepath, lifecycle });
76
80
  }
77
81
  if (events.length > 0) {
78
- onEvent({ kind: 'changes', filepath, events });
82
+ safelyEmit(onEvent, { kind: 'changes', filepath, events });
79
83
  } else if (reason === 'add') {
80
- onEvent({
84
+ safelyEmit(onEvent, {
81
85
  kind: 'lifecycle',
82
86
  filepath,
83
87
  lifecycle: { type: 'file-restored', message: 'File content reloaded after add event.' },
@@ -96,7 +100,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
96
100
  watcher.on('unlink', () => {
97
101
  // File temporarily missing; keep last good state and wait for add.
98
102
  renderWarn(`File disappeared: "${filepath}" (waiting for it to reappear)`);
99
- onEvent({
103
+ safelyEmit(onEvent, {
100
104
  kind: 'lifecycle',
101
105
  filepath,
102
106
  lifecycle: { type: 'file-missing', message: 'File disappeared; waiting for restore.' },
@@ -105,7 +109,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
105
109
 
106
110
  watcher.on('error', (err) => {
107
111
  renderWarn(`Watcher error: ${err.message}`);
108
- onEvent({
112
+ safelyEmit(onEvent, {
109
113
  kind: 'lifecycle',
110
114
  filepath,
111
115
  lifecycle: { type: 'watcher-error', message: err.message },
@@ -115,12 +119,20 @@ export function startWatcher(filepath, options = {}, onEvent) {
115
119
  return watcher;
116
120
  }
117
121
 
122
+ function safelyEmit(onEvent, event) {
123
+ Promise.resolve()
124
+ .then(() => onEvent(event))
125
+ .catch((err) => {
126
+ renderWarn(`Watcher event handler error: ${err?.message ?? String(err)}`);
127
+ });
128
+ }
129
+
118
130
  /**
119
131
  * Internal: re-parse the file and diff against the previous state.
120
132
  * @param {string} filepath
121
- * @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} diffOpts
122
- * @param {unknown | null} lastGoodState
123
- * @param {(newState: unknown | null, events: ChangeEvent[], lifecycle: { type: string, message: string } | null) => void} callback
133
+ * @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} diffOpts
134
+ * @param {unknown | typeof NO_STATE} lastGoodState
135
+ * @param {(newState: unknown | typeof NO_STATE, events: ChangeEvent[], lifecycle: { type: string, message: string } | null) => void} callback
124
136
  */
125
137
  function handleChange(filepath, diffOpts, lastGoodState, callback) {
126
138
  let newState;
@@ -128,11 +140,11 @@ function handleChange(filepath, diffOpts, lastGoodState, callback) {
128
140
  newState = parseFile(filepath);
129
141
  } catch (err) {
130
142
  renderWarn(`Parse error — keeping last valid state. ${err.message}`);
131
- callback(lastGoodState, [], { type: 'parse-error', message: err.message });
143
+ callback(NO_STATE, [], { type: 'parse-error', message: err.message });
132
144
  return; // don't update lastGoodState
133
145
  }
134
146
 
135
- if (lastGoodState === null) {
147
+ if (lastGoodState === NO_STATE) {
136
148
  // First successful parse — record as baseline, no diff to show yet
137
149
  renderInfo(`Baseline established for "${filepath}".`);
138
150
  callback(newState, [], { type: 'baseline-created', message: 'First valid state recorded.' });