flecto 2.1.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,480 @@
1
+ import { readFileSync } from 'fs';
2
+ import { isAbsolute, relative } from 'path';
3
+
4
+ /**
5
+ * Hidden marker embedded in every rendered body. Flecto finds its own comment by
6
+ * searching for this string, so repeated runs update one sticky comment instead
7
+ * of appending a new one on every push.
8
+ */
9
+ export const PR_COMMENT_MARKER = '<!-- flecto:pr-comment -->';
10
+
11
+ const DEFAULT_API_URL = 'https://api.github.com';
12
+ const DEFAULT_TIMEOUT_MS = 10_000;
13
+ /** GitHub rejects comment bodies longer than 65536 characters. */
14
+ const MAX_BODY_CHARS = 60_000;
15
+ const MAX_INLINE_CHANGES = 10;
16
+ const MAX_VALUE_CHARS = 120;
17
+ const MAX_COMMENT_PAGES = 10;
18
+ const COMMENTS_PER_PAGE = 100;
19
+ const SEVERITY_ORDER = ['error', 'warn', 'info'];
20
+ const SEVERITY_HEADINGS = { error: 'Errors', warn: 'Warnings', info: 'Notices' };
21
+ const SEVERITY_NOUNS = { error: 'error', warn: 'warning', info: 'notice' };
22
+
23
+ /**
24
+ * @typedef {{ file: string, envelope: import('./envelope.js').FlectoEnvelope, policies?: import('./policy.js').PolicyFinding[] }} CiResult
25
+ * @typedef {{ repo: string, prNumber: number, token: string, apiUrl: string }} PrCommentContext
26
+ */
27
+
28
+ /**
29
+ * Render a value inside a markdown code span, widening the fence so embedded
30
+ * backticks cannot terminate it early.
31
+ * @param {string} text
32
+ * @returns {string}
33
+ */
34
+ function inlineCode(text) {
35
+ const value = String(text);
36
+ const runs = value.match(/`+/g) ?? [];
37
+ const fence = '`'.repeat(Math.max(0, ...runs.map((run) => run.length)) + 1);
38
+ const pad = value.startsWith('`') || value.endsWith('`') ? ' ' : '';
39
+ return `${fence}${pad}${value}${pad}${fence}`;
40
+ }
41
+
42
+ /**
43
+ * Make a string safe for a markdown table cell: pipes end the cell (even inside
44
+ * a code span) and newlines end the row.
45
+ * @param {string} text
46
+ * @returns {string}
47
+ */
48
+ function tableCell(text) {
49
+ return String(text).replaceAll(/\r?\n/gu, ' ').replaceAll('|', '\\|');
50
+ }
51
+
52
+ /**
53
+ * @param {string} text
54
+ * @param {number} max
55
+ * @returns {string}
56
+ */
57
+ function truncate(text, max) {
58
+ return text.length > max ? `${text.slice(0, max)}…` : text;
59
+ }
60
+
61
+ /**
62
+ * Format a change value for display, or return null when the side is absent.
63
+ * @param {unknown} value
64
+ * @returns {string | null}
65
+ */
66
+ function formatValue(value) {
67
+ if (value === undefined) return null;
68
+ const json = JSON.stringify(value);
69
+ return truncate(json === undefined ? String(value) : json, MAX_VALUE_CHARS);
70
+ }
71
+
72
+ /**
73
+ * Show repository-relative paths where possible; absolute runner paths are
74
+ * noise in a PR comment.
75
+ * @param {string} file
76
+ * @param {string} [cwd]
77
+ * @returns {string}
78
+ */
79
+ function displayPath(file, cwd) {
80
+ const value = String(file);
81
+ if (!cwd || !isAbsolute(value)) return value;
82
+ const rel = relative(cwd, value);
83
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) return value;
84
+ return rel.replaceAll('\\', '/');
85
+ }
86
+
87
+ /**
88
+ * @param {number} count
89
+ * @param {string} noun
90
+ * @returns {string}
91
+ */
92
+ function plural(count, noun) {
93
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
94
+ }
95
+
96
+ /**
97
+ * @param {CiResult} result
98
+ * @returns {import('./differ.js').ChangeEvent[]}
99
+ */
100
+ function changesOf(result) {
101
+ return result?.envelope?.changes ?? [];
102
+ }
103
+
104
+ /**
105
+ * @param {CiResult} result
106
+ * @returns {import('./policy.js').PolicyFinding[]}
107
+ */
108
+ function findingsOf(result) {
109
+ return result?.policies ?? result?.envelope?.policies ?? [];
110
+ }
111
+
112
+ /**
113
+ * Render the markdown body of the sticky pull request comment.
114
+ *
115
+ * Pure: it reads nothing but its arguments, so the exact body posted to GitHub
116
+ * is the body printed to stdout.
117
+ * @param {CiResult[]} results
118
+ * @param {{ cwd?: string, failed?: boolean, marker?: string, maxInlineChanges?: number, maxBodyChars?: number }} [options]
119
+ * @returns {string} Markdown, always beginning with the sticky marker
120
+ */
121
+ export function renderPrComment(results, options = {}) {
122
+ const list = Array.isArray(results) ? results : [];
123
+ const marker = options.marker ?? PR_COMMENT_MARKER;
124
+ const maxInlineChanges = options.maxInlineChanges ?? MAX_INLINE_CHANGES;
125
+ const maxBodyChars = options.maxBodyChars ?? MAX_BODY_CHARS;
126
+ const cwd = options.cwd;
127
+
128
+ const counts = { changed: 0, added: 0, removed: 0 };
129
+ const severityCounts = { error: 0, warn: 0, info: 0 };
130
+ /** @type {Record<string, Array<{ file: string, finding: import('./policy.js').PolicyFinding }>>} */
131
+ const bySeverity = { error: [], warn: [], info: [] };
132
+ let totalChanges = 0;
133
+
134
+ for (const result of list) {
135
+ const file = displayPath(result.file, cwd);
136
+ for (const change of changesOf(result)) {
137
+ totalChanges += 1;
138
+ if (change.type in counts) counts[change.type] += 1;
139
+ }
140
+ for (const finding of findingsOf(result)) {
141
+ const severity = SEVERITY_ORDER.includes(finding.severity) ? finding.severity : 'info';
142
+ severityCounts[severity] += 1;
143
+ bySeverity[severity].push({ file, finding });
144
+ }
145
+ }
146
+
147
+ const totalFindings = SEVERITY_ORDER.reduce((sum, s) => sum + severityCounts[s], 0);
148
+ const lines = [marker, '', '## Flecto — config change report', ''];
149
+
150
+ const status = options.failed === true
151
+ ? '❌ **Check failing** — '
152
+ : options.failed === false
153
+ ? '✅ **Check passing** — '
154
+ : '';
155
+
156
+ if (list.length === 0) {
157
+ lines.push(`${status}no files were diffed.`);
158
+ } else if (totalChanges === 0) {
159
+ lines.push(`${status}no semantic changes in ${plural(list.length, 'file')}.`);
160
+ } else {
161
+ lines.push(
162
+ `${status}${plural(totalChanges, 'change')} in ${plural(list.length, 'file')}` +
163
+ ` — ${counts.changed} changed, ${counts.added} added, ${counts.removed} removed.`,
164
+ );
165
+ }
166
+
167
+ lines.push('');
168
+ lines.push(totalFindings === 0
169
+ ? '**Policy:** no findings.'
170
+ : `**Policy:** ${SEVERITY_ORDER
171
+ .filter((severity) => severityCounts[severity] > 0)
172
+ .map((severity) => plural(severityCounts[severity], SEVERITY_NOUNS[severity]))
173
+ .join(', ')}.`);
174
+
175
+ if (totalFindings > 0) {
176
+ lines.push('', '### Policy findings');
177
+ for (const severity of SEVERITY_ORDER) {
178
+ const entries = bySeverity[severity];
179
+ if (entries.length === 0) continue;
180
+ lines.push('', `#### ${SEVERITY_HEADINGS[severity]} (${entries.length})`, '');
181
+ lines.push('| File | Path | Rule | Message |', '|---|---|---|---|');
182
+ for (const { file, finding } of entries) {
183
+ const rule = finding.pack
184
+ ? `${inlineCode(finding.id)} (${inlineCode(finding.pack)})`
185
+ : inlineCode(finding.id);
186
+ lines.push(
187
+ `| ${tableCell(inlineCode(file))} | ${tableCell(inlineCode(finding.path))}` +
188
+ ` | ${tableCell(rule)} | ${tableCell(finding.message ?? '')} |`,
189
+ );
190
+ }
191
+ }
192
+ }
193
+
194
+ if (totalChanges > 0) {
195
+ const collapse = totalChanges > maxInlineChanges;
196
+ lines.push('', '### Changes');
197
+ if (collapse) {
198
+ lines.push('', '<details>', `<summary>Show all ${plural(totalChanges, 'change')}</summary>`);
199
+ }
200
+ for (const result of list) {
201
+ const changes = changesOf(result);
202
+ if (changes.length === 0) continue;
203
+ const file = displayPath(result.file, cwd);
204
+ lines.push('', `**${inlineCode(file)}** — ${plural(changes.length, 'change')}`, '');
205
+ lines.push('| Change | Path | Before | After |', '|---|---|---|---|');
206
+ for (const change of changes) {
207
+ const kind = change.note ? `${change.type} (${change.note})` : String(change.type);
208
+ const before = formatValue(change.before);
209
+ const after = formatValue(change.after);
210
+ lines.push(
211
+ `| ${tableCell(kind)} | ${tableCell(inlineCode(change.path))}` +
212
+ ` | ${before === null ? '—' : tableCell(inlineCode(before))}` +
213
+ ` | ${after === null ? '—' : tableCell(inlineCode(after))} |`,
214
+ );
215
+ }
216
+ }
217
+ if (collapse) lines.push('', '</details>');
218
+ }
219
+
220
+ lines.push(
221
+ '',
222
+ '---',
223
+ '',
224
+ '<sub>Posted by [Flecto](https://github.com/myselfsiddharth/Flecto) —' +
225
+ ' this comment is updated in place on every run.</sub>',
226
+ '',
227
+ );
228
+
229
+ const body = lines.join('\n');
230
+ if (body.length <= maxBodyChars) return body;
231
+ const notice = '\n\n_Report truncated to fit GitHub\'s comment size limit._\n';
232
+ return `${body.slice(0, Math.max(marker.length, maxBodyChars - notice.length))}${notice}`;
233
+ }
234
+
235
+ /**
236
+ * @param {Record<string, string | undefined>} env
237
+ * @param {(path: string) => string} readEventFile
238
+ * @returns {number | null}
239
+ */
240
+ function resolvePrNumber(env, readEventFile) {
241
+ const fromRef = /^refs\/pull\/(\d+)\/(?:merge|head)$/u.exec(env.GITHUB_REF ?? '');
242
+ if (fromRef) return Number(fromRef[1]);
243
+
244
+ const eventPath = env.GITHUB_EVENT_PATH;
245
+ if (!eventPath) return null;
246
+
247
+ let event;
248
+ try {
249
+ event = JSON.parse(readEventFile(eventPath));
250
+ } catch {
251
+ return null;
252
+ }
253
+
254
+ const candidates = [
255
+ event?.pull_request?.number,
256
+ // issue_comment events on a PR carry the PR number under `issue`, but a
257
+ // plain issue must not be mistaken for one.
258
+ event?.issue?.pull_request ? event?.issue?.number : undefined,
259
+ event?.number,
260
+ ];
261
+ for (const candidate of candidates) {
262
+ const value = Number(candidate);
263
+ if (Number.isInteger(value) && value > 0) return value;
264
+ }
265
+ return null;
266
+ }
267
+
268
+ /**
269
+ * Resolve the GitHub API context needed to post a pull request comment.
270
+ *
271
+ * Only `GITHUB_TOKEN` is honored — `GH_TOKEN` is deliberately ignored because
272
+ * `gh auth login` exports it on developer machines, where posting would be a
273
+ * surprise.
274
+ * @param {Record<string, string | undefined>} [env]
275
+ * @param {{ readEventFile?: (path: string) => string }} [options]
276
+ * @returns {{ ok: true, context: PrCommentContext } | { ok: false, reason: string }}
277
+ */
278
+ export function resolvePrCommentContext(env = process.env, options = {}) {
279
+ const readEventFile = options.readEventFile ?? ((path) => readFileSync(path, 'utf8'));
280
+ const token = String(env.GITHUB_TOKEN ?? '').trim();
281
+ if (!token) {
282
+ return { ok: false, reason: 'GITHUB_TOKEN is not set' };
283
+ }
284
+
285
+ const repo = String(env.GITHUB_REPOSITORY ?? '').trim();
286
+ if (!/^[^/\s]+\/[^/\s]+$/u.test(repo)) {
287
+ return { ok: false, reason: 'GITHUB_REPOSITORY is not set to "owner/repo"' };
288
+ }
289
+
290
+ const prNumber = resolvePrNumber(env, readEventFile);
291
+ if (!prNumber) {
292
+ return {
293
+ ok: false,
294
+ reason: 'no pull request number in GITHUB_REF or GITHUB_EVENT_PATH (not a pull request run)',
295
+ };
296
+ }
297
+
298
+ const apiUrl = String(env.GITHUB_API_URL || DEFAULT_API_URL).replace(/\/+$/u, '');
299
+ return { ok: true, context: { repo, prNumber, token, apiUrl } };
300
+ }
301
+
302
+ /**
303
+ * Remove the token from text that may be surfaced to the user.
304
+ * @param {string} text
305
+ * @param {string} token
306
+ * @returns {string}
307
+ */
308
+ function redact(text, token) {
309
+ return token ? String(text).replaceAll(token, '***') : String(text);
310
+ }
311
+
312
+ /**
313
+ * @param {Response} response
314
+ * @param {string} token
315
+ * @returns {Promise<string>}
316
+ */
317
+ async function errorDetail(response, token) {
318
+ let text = '';
319
+ try {
320
+ text = await response.text();
321
+ } catch {
322
+ return '';
323
+ }
324
+ let message = text;
325
+ try {
326
+ const parsed = JSON.parse(text);
327
+ if (typeof parsed?.message === 'string') message = parsed.message;
328
+ } catch {
329
+ // Non-JSON error bodies are used as-is.
330
+ }
331
+ const safe = truncate(redact(message, token).replaceAll(/\s+/gu, ' ').trim(), 200);
332
+ return safe ? `: ${safe}` : '';
333
+ }
334
+
335
+ /**
336
+ * @param {{ fetchImpl: typeof fetch, url: string, method: string, token: string, body?: unknown, timeoutMs: number }} request
337
+ * @returns {Promise<Response>}
338
+ */
339
+ async function githubRequest({ fetchImpl, url, method, token, body, timeoutMs }) {
340
+ const controller = new AbortController();
341
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
342
+ let response;
343
+ try {
344
+ response = await fetchImpl(url, {
345
+ method,
346
+ headers: {
347
+ Accept: 'application/vnd.github+json',
348
+ Authorization: `Bearer ${token}`,
349
+ 'Content-Type': 'application/json',
350
+ 'User-Agent': 'flecto',
351
+ 'X-GitHub-Api-Version': '2022-11-28',
352
+ },
353
+ body: body === undefined ? undefined : JSON.stringify(body),
354
+ signal: controller.signal,
355
+ });
356
+ } catch (err) {
357
+ const reason = err?.name === 'AbortError'
358
+ ? `timed out after ${timeoutMs}ms`
359
+ : redact(err?.message ?? String(err), token);
360
+ throw new Error(`GitHub API ${method} failed: ${reason}`);
361
+ } finally {
362
+ clearTimeout(timer);
363
+ }
364
+
365
+ if (!response.ok) {
366
+ throw new Error(
367
+ `GitHub API ${method} returned HTTP ${response.status}${await errorDetail(response, token)}`,
368
+ );
369
+ }
370
+ return response;
371
+ }
372
+
373
+ /**
374
+ * Find the sticky Flecto comment on a pull request, if one exists.
375
+ * @param {PrCommentContext} context
376
+ * @param {{ fetchImpl: typeof fetch, marker: string, timeoutMs: number }} options
377
+ * @returns {Promise<{ id: number, body?: string, html_url?: string } | null>}
378
+ */
379
+ async function findStickyComment(context, { fetchImpl, marker, timeoutMs }) {
380
+ for (let page = 1; page <= MAX_COMMENT_PAGES; page += 1) {
381
+ const url = `${context.apiUrl}/repos/${context.repo}/issues/${context.prNumber}`
382
+ + `/comments?per_page=${COMMENTS_PER_PAGE}&page=${page}`;
383
+ const response = await githubRequest({
384
+ fetchImpl, url, method: 'GET', token: context.token, timeoutMs,
385
+ });
386
+ const comments = await response.json();
387
+ if (!Array.isArray(comments) || comments.length === 0) return null;
388
+ const match = comments.find((c) => typeof c?.body === 'string' && c.body.includes(marker));
389
+ if (match) return match;
390
+ if (comments.length < COMMENTS_PER_PAGE) return null;
391
+ }
392
+ return null;
393
+ }
394
+
395
+ /**
396
+ * Create, update, or leave alone the single sticky Flecto comment on a PR.
397
+ * Throws on API failure; callers decide how to degrade.
398
+ * @param {string} body Markdown containing the sticky marker
399
+ * @param {PrCommentContext} context
400
+ * @param {{ fetchImpl?: typeof fetch, marker?: string, timeoutMs?: number }} [options]
401
+ * @returns {Promise<{ action: 'created' | 'updated' | 'unchanged', url?: string }>}
402
+ */
403
+ export async function upsertPrComment(body, context, options = {}) {
404
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
405
+ const marker = options.marker ?? PR_COMMENT_MARKER;
406
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
407
+ if (typeof fetchImpl !== 'function') {
408
+ throw new Error('Global fetch unavailable. Use Node.js >= 20.19.0.');
409
+ }
410
+
411
+ const existing = await findStickyComment(context, { fetchImpl, marker, timeoutMs });
412
+
413
+ if (!existing) {
414
+ const response = await githubRequest({
415
+ fetchImpl,
416
+ url: `${context.apiUrl}/repos/${context.repo}/issues/${context.prNumber}/comments`,
417
+ method: 'POST',
418
+ token: context.token,
419
+ body: { body },
420
+ timeoutMs,
421
+ });
422
+ const created = await response.json().catch(() => ({}));
423
+ return { action: 'created', url: created?.html_url };
424
+ }
425
+
426
+ // Rendering is deterministic, so an identical body means nothing moved since
427
+ // the last run — skip the write and the "edited" noise it creates.
428
+ if (existing.body === body) {
429
+ return { action: 'unchanged', url: existing.html_url };
430
+ }
431
+
432
+ const response = await githubRequest({
433
+ fetchImpl,
434
+ url: `${context.apiUrl}/repos/${context.repo}/issues/comments/${existing.id}`,
435
+ method: 'PATCH',
436
+ token: context.token,
437
+ body: { body },
438
+ timeoutMs,
439
+ });
440
+ const updated = await response.json().catch(() => ({}));
441
+ return { action: 'updated', url: updated?.html_url ?? existing.html_url };
442
+ }
443
+
444
+ /**
445
+ * Post the sticky comment when — and only when — posting was explicitly enabled
446
+ * and a complete GitHub pull request context is present.
447
+ *
448
+ * Never throws and never reports the token: a delivery problem must not change
449
+ * the CI exit code, which belongs to the diff and policy result alone.
450
+ * @param {string} body
451
+ * @param {{
452
+ * enabled?: boolean,
453
+ * env?: Record<string, string | undefined>,
454
+ * fetchImpl?: typeof fetch,
455
+ * marker?: string,
456
+ * timeoutMs?: number,
457
+ * readEventFile?: (path: string) => string
458
+ * }} [options]
459
+ * @returns {Promise<{ posted: boolean, action?: 'created' | 'updated' | 'unchanged', url?: string, reason?: string }>}
460
+ */
461
+ export async function deliverPrComment(body, options = {}) {
462
+ if (!options.enabled) {
463
+ return { posted: false, reason: 'posting is disabled (pass --pr-comment-post to enable)' };
464
+ }
465
+
466
+ const resolved = resolvePrCommentContext(options.env ?? process.env, options);
467
+ if (!resolved.ok) {
468
+ return { posted: false, reason: resolved.reason };
469
+ }
470
+
471
+ try {
472
+ const result = await upsertPrComment(body, resolved.context, options);
473
+ return { posted: true, ...result };
474
+ } catch (err) {
475
+ return {
476
+ posted: false,
477
+ reason: redact(err?.message ?? String(err), resolved.context.token),
478
+ };
479
+ }
480
+ }
package/src/renderer.js CHANGED
@@ -1,5 +1,18 @@
1
1
  import chalk from 'chalk';
2
+ import { redactSecretString } from './secrets.js';
3
+ import { ENCRYPTED_DISPLAY, displayEncrypted, isEncryptedSentinel } from './encrypted.js';
4
+ import { secretMatchPath } from './differ.js';
2
5
 
6
+ /**
7
+ * Key names that mean "this value is a credential".
8
+ *
9
+ * Matched against the *configuration* path only. A multi-document file prefixes
10
+ * every path with the document's identity — `Deployment/prod/token-service.…` —
11
+ * and that identity is a resource name the user chose, not a key name. Letting
12
+ * it match here masked every value in the document, numbers and booleans
13
+ * included, so the path reaching this regex is always the one
14
+ * {@link secretMatchPath} produced.
15
+ */
3
16
  const SECRET_PATH_RE = /(secret|token|password|api[_-]?key|private[_-]?key|credential)/i;
4
17
 
5
18
  /**
@@ -10,12 +23,22 @@ const SECRET_PATH_RE = /(secret|token|password|api[_-]?key|private[_-]?key|crede
10
23
  */
11
24
  function fmt(v, opts = {}) {
12
25
  if (v === undefined) return '';
13
- if (opts.maskSecrets && opts.path && SECRET_PATH_RE.test(opts.path)) {
14
- return chalk.dim('"***"');
26
+ // An encrypted value reads the same masked or not: there is nothing to mask,
27
+ // because the parser never carried the ciphertext this far.
28
+ if (isEncryptedSentinel(v)) return chalk.dim(ENCRYPTED_DISPLAY);
29
+ let value = displayEncrypted(v);
30
+ if (opts.maskSecrets) {
31
+ if (opts.path && SECRET_PATH_RE.test(opts.path)) {
32
+ return chalk.dim('"***"');
33
+ }
34
+ // The changed path itself can look benign while the value carries secrets,
35
+ // e.g. "database" holding { password }. Redact those the same way the
36
+ // webhook/CI payloads do.
37
+ value = maskSensitiveValue(value, opts.path ?? '');
15
38
  }
16
- if (typeof v === 'string') return JSON.stringify(v);
17
- if (typeof v === 'object' && v !== null) return JSON.stringify(v);
18
- return String(v);
39
+ if (typeof value === 'string') return JSON.stringify(value);
40
+ if (typeof value === 'object' && value !== null) return JSON.stringify(value);
41
+ return String(value);
19
42
  }
20
43
 
21
44
  /**
@@ -35,23 +58,38 @@ function timestamp() {
35
58
  */
36
59
  function renderEvent(event, mode, opts = {}) {
37
60
  const { type, path, before, after, note } = event;
38
- const maskOpts = { maskSecrets: Boolean(opts.maskSecrets), path };
61
+ // The full path is what gets printed; the stripped one is what secret-name
62
+ // matching is allowed to see.
63
+ const maskOpts = { maskSecrets: Boolean(opts.maskSecrets), path: secretMatchPath(event) };
64
+ // Notes ride on every change type — a Terraform plan explains a create or a
65
+ // destroy there. The tree differ only ever notes changed events, so existing
66
+ // added/removed output is unaffected.
67
+ const noteStr = note ? chalk.dim(` [${note}]`) : '';
39
68
 
40
69
  if (type === 'added') {
41
- const line = ` ${chalk.green('+')} ${chalk.green(path)}: ${chalk.green(fmt(after, maskOpts))}`;
70
+ const line = ` ${chalk.green('+')} ${chalk.green(path)}: ${chalk.green(fmt(after, maskOpts))}${noteStr}`;
42
71
  return mode === 'verbose'
43
72
  ? `${line}\n ${chalk.dim('(key added)')}`
44
73
  : line;
45
74
  }
46
75
 
47
76
  if (type === 'removed') {
48
- const line = ` ${chalk.red('-')} ${chalk.red(path)}: ${chalk.red(fmt(before, maskOpts))}`;
77
+ const line = ` ${chalk.red('-')} ${chalk.red(path)}: ${chalk.red(fmt(before, maskOpts))}${noteStr}`;
49
78
  return mode === 'verbose'
50
79
  ? `${line}\n ${chalk.dim('(key removed)')}`
51
80
  : line;
52
81
  }
53
82
 
54
- const noteStr = note ? chalk.dim(` [${note}]`) : '';
83
+ // Both sides ciphertext: the only honest thing to say is that it moved.
84
+ // Printing the two sentinels would be noise, and printing what they stand for
85
+ // is not something Flecto can do or wants to be able to do.
86
+ if (isEncryptedSentinel(before) && isEncryptedSentinel(after)) {
87
+ const line = ` ${chalk.yellow('~')} ${chalk.yellow(path)}: ${chalk.dim('<encrypted value changed>')}`;
88
+ return mode === 'verbose'
89
+ ? `${line}\n ${chalk.dim('(Flecto never decrypts — only that the ciphertext differs is known)')}`
90
+ : line;
91
+ }
92
+
55
93
  if (mode === 'verbose') {
56
94
  return [
57
95
  ` ${chalk.yellow('~')} ${chalk.yellow(path)}${noteStr}`,
@@ -85,18 +123,22 @@ export function renderChanges(filepath, events, mode = 'compact', opts = {}) {
85
123
  }
86
124
 
87
125
  /**
88
- * Print a diff result (for --diff mode) to stdout.
126
+ * Print a diff result (for `watch --diff` and `compare`) to stdout.
127
+ * `baseline` names the side the events are measured from, so a cross-file
128
+ * comparison reads unambiguously; it defaults to the saved snapshot.
89
129
  * @param {string} filepath
90
130
  * @param {import('./differ.js').ChangeEvent[]} events
91
- * @param {{ maskSecrets?: boolean }} [opts]
131
+ * @param {{ maskSecrets?: boolean, baseline?: string }} [opts]
92
132
  */
93
133
  export function renderDiff(filepath, events, opts = {}) {
134
+ const baseline = opts.baseline ?? 'snapshot';
135
+
94
136
  if (events.length === 0) {
95
- console.log(chalk.green(`✓ ${filepath} matches snapshot — no changes`));
137
+ console.log(chalk.green(`✓ ${filepath} matches ${baseline} — no changes`));
96
138
  return;
97
139
  }
98
140
 
99
- console.log(chalk.cyan(`${filepath}`) + ` — ${events.length} change${events.length !== 1 ? 's' : ''} from snapshot:`);
141
+ console.log(chalk.cyan(`${filepath}`) + ` — ${events.length} change${events.length !== 1 ? 's' : ''} from ${baseline}:`);
100
142
  for (const event of events) {
101
143
  console.log(renderEvent(event, 'compact', opts));
102
144
  }
@@ -126,6 +168,14 @@ export function renderInfo(msg) {
126
168
  console.log(chalk.dim(msg));
127
169
  }
128
170
 
171
+ /**
172
+ * Print a dim status note on stderr, so it stays out of machine-readable stdout.
173
+ * @param {string} msg
174
+ */
175
+ export function renderNote(msg) {
176
+ console.error(chalk.dim(msg));
177
+ }
178
+
129
179
  /**
130
180
  * Print policy findings.
131
181
  * @param {import('./policy.js').PolicyFinding[]} findings
@@ -144,7 +194,9 @@ export function renderPolicyFindings(findings) {
144
194
  }
145
195
 
146
196
  /**
147
- * Mask secret-like values in a plain object tree for CI output.
197
+ * Mask secret-like values in a plain object tree for CI output. A value is
198
+ * masked when its path looks secret, or — see secrets.js — when the value
199
+ * itself does, which covers credentials stored under innocuous key names.
148
200
  * @param {unknown} value
149
201
  * @param {string} [path]
150
202
  * @returns {unknown}
@@ -167,6 +219,7 @@ export function maskSensitiveValue(value, path = '') {
167
219
  }
168
220
  return out;
169
221
  }
222
+ if (typeof value === 'string') return redactSecretString(value);
170
223
  return value;
171
224
  }
172
225
 
@@ -175,9 +228,10 @@ export function maskSensitiveValue(value, path = '') {
175
228
  * @returns {import('./differ.js').ChangeEvent}
176
229
  */
177
230
  export function maskChangeEvent(event) {
231
+ const path = secretMatchPath(event);
178
232
  return {
179
233
  ...event,
180
- before: event.before === undefined ? undefined : maskSensitiveValue(event.before, event.path),
181
- after: event.after === undefined ? undefined : maskSensitiveValue(event.after, event.path),
234
+ before: event.before === undefined ? undefined : maskSensitiveValue(event.before, path),
235
+ after: event.after === undefined ? undefined : maskSensitiveValue(event.after, path),
182
236
  };
183
237
  }