@pipefy/pipefy-process-coder 0.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.
Files changed (92) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
  19. package/AGENTS.md +456 -0
  20. package/README.md +808 -0
  21. package/bin/pipe.js +13 -0
  22. package/package.json +35 -0
  23. package/src/apply/adopt.ts +150 -0
  24. package/src/apply/agentops.ts +71 -0
  25. package/src/apply/compile.ts +875 -0
  26. package/src/apply/execute.ts +336 -0
  27. package/src/apply/flowops.ts +399 -0
  28. package/src/apply/idmap.ts +241 -0
  29. package/src/apply/mutations.ts +955 -0
  30. package/src/apply/registry.ts +430 -0
  31. package/src/apply/types.ts +134 -0
  32. package/src/cli/args.ts +88 -0
  33. package/src/cli.ts +211 -0
  34. package/src/codec/flow.ts +199 -0
  35. package/src/codec/pack.ts +103 -0
  36. package/src/codec/roundtrip.ts +94 -0
  37. package/src/codec/unpack.ts +198 -0
  38. package/src/commands/agents.ts +184 -0
  39. package/src/commands/apply.ts +1144 -0
  40. package/src/commands/context.ts +119 -0
  41. package/src/commands/create.ts +79 -0
  42. package/src/commands/diff.ts +314 -0
  43. package/src/commands/flows.ts +414 -0
  44. package/src/commands/misc.ts +644 -0
  45. package/src/commands/plan.ts +331 -0
  46. package/src/commands/pull.ts +567 -0
  47. package/src/commands/runs.ts +83 -0
  48. package/src/commands/skills.ts +137 -0
  49. package/src/commands/verify.ts +253 -0
  50. package/src/config.ts +168 -0
  51. package/src/diff/agents.ts +122 -0
  52. package/src/diff/diff.ts +1130 -0
  53. package/src/diff/flow.ts +318 -0
  54. package/src/diff/html.ts +322 -0
  55. package/src/diff/render.ts +101 -0
  56. package/src/model/payload.ts +154 -0
  57. package/src/model/tree.ts +99 -0
  58. package/src/model/volatile.ts +55 -0
  59. package/src/pipefy/agents.ts +165 -0
  60. package/src/pipefy/automations.ts +219 -0
  61. package/src/pipefy/capability.ts +119 -0
  62. package/src/pipefy/client.ts +267 -0
  63. package/src/pipefy/discovery.ts +209 -0
  64. package/src/pipefy/internal.ts +380 -0
  65. package/src/pipefy/ipaas.ts +365 -0
  66. package/src/pipefy/reconstruct.ts +775 -0
  67. package/src/pipefy/reference.ts +251 -0
  68. package/src/pipefy/snapshot.ts +245 -0
  69. package/src/pipefy/toolkit.ts +200 -0
  70. package/src/pipefy/toolkit_bearer.py +137 -0
  71. package/src/report/integrations.ts +231 -0
  72. package/src/report/run.ts +475 -0
  73. package/src/util/fsx.ts +45 -0
  74. package/src/util/git.ts +32 -0
  75. package/src/util/json.ts +55 -0
  76. package/src/util/log.ts +76 -0
  77. package/src/util/pool.ts +48 -0
  78. package/src/util/slug.ts +26 -0
  79. package/src/util/tui.ts +335 -0
  80. package/src/validate/index.ts +123 -0
  81. package/src/validate/integrity.ts +387 -0
  82. package/src/validate/reference.ts +136 -0
  83. package/src/validate/schema.ts +328 -0
  84. package/src/workspace/agents.ts +290 -0
  85. package/src/workspace/docs.ts +407 -0
  86. package/src/workspace/flows.ts +191 -0
  87. package/src/workspace/layout.ts +165 -0
  88. package/src/workspace/lock.ts +148 -0
  89. package/src/workspace/read.ts +165 -0
  90. package/src/workspace/reference.ts +24 -0
  91. package/src/workspace/stamp.ts +301 -0
  92. package/src/workspace/write.ts +225 -0
@@ -0,0 +1,48 @@
1
+ import { debug } from './log.ts';
2
+
3
+ export const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
4
+
5
+ /** Bounded-concurrency map that preserves input order. */
6
+ export const mapPool = async <T, R>(
7
+ items: readonly T[],
8
+ limit: number,
9
+ fn: (item: T, index: number) => Promise<R>,
10
+ ): Promise<R[]> => {
11
+ const results = new Array<R>(items.length);
12
+ let next = 0;
13
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
14
+ for (;;) {
15
+ const i = next++;
16
+ if (i >= items.length) return;
17
+ results[i] = await fn(items[i] as T, i);
18
+ }
19
+ });
20
+ await Promise.all(workers);
21
+ return results;
22
+ };
23
+
24
+ export type RetryOpts = { tries?: number; baseMs?: number; label?: string };
25
+
26
+ /**
27
+ * Exponential backoff with jitter. PLAN.md §9: rate limits are unmeasured, so
28
+ * every network call goes through this and every retry is logged - which is how
29
+ * we measure the ceiling instead of discovering it during a client apply.
30
+ */
31
+ export const retry = async <T>(fn: () => Promise<T>, opts: RetryOpts = {}): Promise<T> => {
32
+ const tries = opts.tries ?? 5;
33
+ const base = opts.baseMs ?? 600;
34
+ let lastErr: unknown;
35
+ for (let attempt = 1; attempt <= tries; attempt++) {
36
+ try {
37
+ return await fn();
38
+ } catch (e) {
39
+ lastErr = e;
40
+ const retryable = (e as { retryable?: boolean }).retryable !== false;
41
+ if (!retryable || attempt === tries) break;
42
+ const wait = Math.round(base * 2 ** (attempt - 1) * (0.7 + Math.random() * 0.6));
43
+ debug(`retry ${attempt}/${tries - 1} in ${wait}ms${opts.label ? ` (${opts.label})` : ''}: ${(e as Error).message}`);
44
+ await sleep(wait);
45
+ }
46
+ }
47
+ throw lastErr;
48
+ };
@@ -0,0 +1,26 @@
1
+ /** Filesystem-safe, stable, human-readable name segment. */
2
+ export const slug = (s: string | null | undefined, fallback = 'untitled'): string => {
3
+ const base = (s ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '');
4
+ const out = base
5
+ .toLowerCase()
6
+ .replace(/['"`]/g, '')
7
+ .replace(/[^a-z0-9]+/g, '-')
8
+ .replace(/^-+|-+$/g, '')
9
+ .slice(0, 60)
10
+ .replace(/-+$/g, '');
11
+ return out || fallback;
12
+ };
13
+
14
+ /** `01--new-request` - index-prefixed so directory order matches pipe order. */
15
+ export const indexed = (i: number, name: string | null | undefined): string =>
16
+ `${String(Math.trunc(i)).padStart(2, '0')}--${slug(name)}`;
17
+
18
+ /** Disambiguate repeated slugs deterministically: name, name-2, name-3. */
19
+ export const uniquify = (): ((s: string) => string) => {
20
+ const seen = new Map<string, number>();
21
+ return (s: string) => {
22
+ const n = (seen.get(s) ?? 0) + 1;
23
+ seen.set(s, n);
24
+ return n === 1 ? s : `${s}-${n}`;
25
+ };
26
+ };
@@ -0,0 +1,335 @@
1
+ import { bold, claimLogSink, dim, green, red, yellow, blue, magenta } from './log.ts';
2
+
3
+ /**
4
+ * A small live progress display for the long commands — pull, apply, dry-run.
5
+ *
6
+ * Two renderers behind one interface, chosen by whether stderr is a terminal:
7
+ *
8
+ * - **live**: redraws a block of lines in place, with a spinner and elapsed
9
+ * times, so a 63-second snapshot wait looks like work rather than a hang.
10
+ * - **plain**: one line per state change, no cursor control, no repainting.
11
+ * This is what CI, `2>file`, and a backgrounded run get — and it is not an
12
+ * afterthought, since that is how this tool is most often watched.
13
+ *
14
+ * Everything goes to stderr, leaving stdout clean for `--json`.
15
+ */
16
+
17
+ const ESC = '\x1b[';
18
+ const HIDE_CURSOR = `${ESC}?25l`;
19
+ const SHOW_CURSOR = `${ESC}?25h`;
20
+ const CLEAR_LINE = `${ESC}2K`;
21
+ const cursorUp = (n: number) => (n > 0 ? `${ESC}${n}A` : '');
22
+
23
+ /** Braille spinner: 8 frames, reads as motion even at 80ms. */
24
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
25
+
26
+ export type TaskState = 'pending' | 'running' | 'done' | 'failed' | 'skipped';
27
+
28
+ export type Task = {
29
+ id: string;
30
+ label: string;
31
+ state: TaskState;
32
+ /** Replaces the label's tail while running: "polling — 42s (~63s typical)". */
33
+ detail?: string;
34
+ /** Nesting depth, for fields under a phase or steps under a repo. */
35
+ depth?: number;
36
+ startedAt?: number;
37
+ endedAt?: number;
38
+ /** Shown after the label, dimmed: "destructive", "internal-api". */
39
+ tags?: string[];
40
+ };
41
+
42
+ const MARK: Record<TaskState, (s: string) => string> = {
43
+ pending: dim,
44
+ running: blue,
45
+ done: green,
46
+ failed: red,
47
+ skipped: yellow,
48
+ };
49
+
50
+ const GLYPH: Record<TaskState, string> = {
51
+ pending: '·',
52
+ running: '',
53
+ done: '+',
54
+ failed: 'x',
55
+ skipped: '-',
56
+ };
57
+
58
+ const secs = (ms: number) => `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
59
+
60
+ export type ProgressOpts = {
61
+ title: string;
62
+ /** Force plain output regardless of tty — used by tests and `--no-tui`. */
63
+ plain?: boolean;
64
+ /** Where to write. Defaults to stderr. */
65
+ stream?: NodeJS.WriteStream;
66
+ };
67
+
68
+ export class Progress {
69
+ private tasks: Task[] = [];
70
+ private byId = new Map<string, Task>();
71
+ private title: string;
72
+ private stream: NodeJS.WriteStream;
73
+ private live: boolean;
74
+ private frame = 0;
75
+ private timer: NodeJS.Timeout | null = null;
76
+ private linesDrawn = 0;
77
+ private startedAt = Date.now();
78
+ private finished = false;
79
+ /** Extra lines under the task list: warnings that must survive a repaint. */
80
+ private notes: string[] = [];
81
+ private cleanup: (() => void) | null = null;
82
+ /**
83
+ * Renders are coalesced onto the spinner tick rather than run on every state
84
+ * change. Building a 200-step plan calls add() 200 times, and repainting the
85
+ * whole block each time is both wasted work and visible flicker; a 90ms delay
86
+ * before a change appears is not perceptible.
87
+ */
88
+ private dirty = false;
89
+
90
+ constructor(opts: ProgressOpts) {
91
+ this.title = opts.title;
92
+ this.stream = opts.stream ?? process.stderr;
93
+ this.live = !opts.plain && Boolean(this.stream.isTTY) && !process.env.PPC_NO_TUI && !process.env.NO_TUI;
94
+ }
95
+
96
+ /** Begin painting. Safe to call once; a second call is ignored. */
97
+ start() {
98
+ if (this.timer || this.finished) return;
99
+
100
+ /**
101
+ * Claimed in both modes, so the semantics do not depend on whether stderr
102
+ * is a terminal. Live, it stops an interleaved write garbling a repaint;
103
+ * plain, it stops the task list and the old progress chatter saying the same
104
+ * thing twice.
105
+ */
106
+ claimLogSink({ note: (text: string) => this.note(text) });
107
+
108
+ if (!this.live) {
109
+ this.stream.write(bold(this.title) + '\n');
110
+ return;
111
+ }
112
+ this.stream.write(HIDE_CURSOR);
113
+ // Leaving the cursor hidden after a Ctrl-C would break the user's terminal.
114
+ const restore = () => this.stop();
115
+ process.once('SIGINT', restore);
116
+ process.once('SIGTERM', restore);
117
+ process.once('exit', restore);
118
+ this.cleanup = () => {
119
+ process.off('SIGINT', restore);
120
+ process.off('SIGTERM', restore);
121
+ process.off('exit', restore);
122
+ };
123
+ this.timer = setInterval(() => {
124
+ this.frame++;
125
+ // The spinner has to move even when nothing changed, so a running task
126
+ // always repaints; an idle list repaints only when it is stale.
127
+ if (this.dirty || this.tasks.some((t) => t.state === 'running')) this.render();
128
+ }, 90);
129
+ this.timer.unref?.();
130
+ this.render();
131
+ }
132
+
133
+ add(task: Omit<Task, 'state'> & { state?: TaskState }): Task {
134
+ const t: Task = { state: 'pending', ...task };
135
+ this.tasks.push(t);
136
+ this.byId.set(t.id, t);
137
+ this.touch();
138
+ return t;
139
+ }
140
+
141
+ update(id: string, patch: Partial<Omit<Task, 'id'>>) {
142
+ const t = this.byId.get(id);
143
+ if (!t) return;
144
+ const before = t.state;
145
+ Object.assign(t, patch);
146
+ if (patch.state === 'running' && !t.startedAt) {
147
+ t.startedAt = Date.now();
148
+ // Seeded so the first heartbeat is one interval away, not immediate:
149
+ // otherwise every task announces its first stage and fast work is noise.
150
+ this.lastHeartbeat.set(t.id, t.startedAt);
151
+ }
152
+ if (patch.state && patch.state !== 'running' && !t.endedAt) t.endedAt = Date.now();
153
+ if (!this.live) {
154
+ if (patch.state && patch.state !== before) this.onPlainTransition(t);
155
+ else if (patch.detail !== undefined) this.plainHeartbeat(t);
156
+ } else {
157
+ this.touch();
158
+ }
159
+ }
160
+
161
+ /**
162
+ * One line per task in plain mode, plus a heartbeat for slow work.
163
+ *
164
+ * Every task reports its outcome, so nothing starts and then vanishes from the
165
+ * log. Work that announces a stage while running — a snapshot poll, a download
166
+ * — also reports that, throttled, because a log that goes silent for 63
167
+ * seconds is indistinguishable from a hang. A 200-step plan of fast calls is
168
+ * 200 lines.
169
+ */
170
+ private static readonly HEARTBEAT_MS = 15_000;
171
+ private lastHeartbeat = new Map<string, number>();
172
+
173
+ private onPlainTransition(t: Task) {
174
+ // Terminal states always speak.
175
+ if (t.state === 'done' || t.state === 'failed' || t.state === 'skipped') this.emitPlain(t);
176
+ }
177
+
178
+ private plainHeartbeat(t: Task) {
179
+ if (this.live || t.state !== 'running') return;
180
+ const now = Date.now();
181
+ const last = this.lastHeartbeat.get(t.id) ?? 0;
182
+ if (now - last < Progress.HEARTBEAT_MS) return;
183
+ this.lastHeartbeat.set(t.id, now);
184
+ this.emitPlain(t);
185
+ }
186
+
187
+ /**
188
+ * Mark the frame stale. Before start() there is no timer, so paint at once —
189
+ * that is what makes a short-lived Progress still show something.
190
+ */
191
+ private touch() {
192
+ if (!this.live) return;
193
+ this.dirty = true;
194
+ if (!this.timer) this.render();
195
+ }
196
+
197
+ /** Whether a task with this id already exists. */
198
+ has(id: string) {
199
+ return this.byId.has(id);
200
+ }
201
+
202
+ /** The last few only: a frame that grows without bound scrolls the terminal. */
203
+ private static readonly MAX_NOTES = 5;
204
+
205
+ /** A line that stays visible under the task list. */
206
+ note(text: string) {
207
+ this.notes.push(text);
208
+ if (this.notes.length > Progress.MAX_NOTES) this.notes.splice(0, this.notes.length - Progress.MAX_NOTES);
209
+ if (!this.live) this.stream.write(text + '\n');
210
+ else this.render();
211
+ }
212
+
213
+ /** Stop painting and leave the final frame on screen. */
214
+ stop(summary?: string) {
215
+ if (this.finished) return;
216
+ this.finished = true;
217
+ if (this.timer) clearInterval(this.timer);
218
+ this.timer = null;
219
+ this.cleanup?.();
220
+ claimLogSink(null);
221
+ if (this.live) {
222
+ this.render(true);
223
+ this.stream.write(SHOW_CURSOR);
224
+ }
225
+ if (summary) this.stream.write(summary + '\n');
226
+ }
227
+
228
+ get elapsed() {
229
+ return Date.now() - this.startedAt;
230
+ }
231
+
232
+ // ── rendering ─────────────────────────────────────────────────────────────
233
+
234
+ private emitPlain(t: Task) {
235
+ const paint = MARK[t.state];
236
+ const indent = ' '.repeat(t.depth ?? 0);
237
+ const tail = t.detail ? ` ${dim(t.detail)}` : '';
238
+ const took = t.startedAt && t.endedAt ? ` ${dim(secs(t.endedAt - t.startedAt))}` : '';
239
+ this.stream.write(`${indent}${paint(GLYPH[t.state] || '>')} ${t.label}${tail}${took}\n`);
240
+ }
241
+
242
+ private line(t: Task): string {
243
+ const paint = MARK[t.state];
244
+ const glyph = t.state === 'running' ? blue(FRAMES[this.frame % FRAMES.length] as string) : paint(GLYPH[t.state]);
245
+ const indent = ' '.repeat(t.depth ?? 0);
246
+
247
+ const label = t.state === 'pending' ? dim(t.label) : t.label;
248
+ const tags = t.tags?.length ? ' ' + t.tags.map((g) => dim(`[${g}]`)).join(' ') : '';
249
+
250
+ let timing = '';
251
+ if (t.state === 'running' && t.startedAt) timing = ` ${dim(secs(Date.now() - t.startedAt))}`;
252
+ else if (t.startedAt && t.endedAt) timing = ` ${dim(secs(t.endedAt - t.startedAt))}`;
253
+
254
+ const detail = t.detail ? ` ${dim('— ' + t.detail)}` : '';
255
+ return `${indent} ${glyph} ${label}${tags}${detail}${timing}`;
256
+ }
257
+
258
+ private render(final = false) {
259
+ if (!this.live) return;
260
+
261
+ const counts = this.tasks.reduce<Record<TaskState, number>>(
262
+ (a, t) => {
263
+ a[t.state]++;
264
+ return a;
265
+ },
266
+ { pending: 0, running: 0, done: 0, failed: 0, skipped: 0 },
267
+ );
268
+
269
+ const head =
270
+ bold(this.title) +
271
+ dim(
272
+ ` · ${counts.done}/${this.tasks.length}` +
273
+ (counts.failed ? ` · ${counts.failed} failed` : '') +
274
+ (counts.skipped ? ` · ${counts.skipped} skipped` : '') +
275
+ ` · ${secs(this.elapsed)}`,
276
+ );
277
+
278
+ /**
279
+ * Only the tail is kept when the list is long: a 200-step plan must not
280
+ * scroll the terminal on every frame. Finished work above the window is
281
+ * summarised in the header, which is the number people actually watch.
282
+ */
283
+ const MAX = 14;
284
+ const visible =
285
+ this.tasks.length <= MAX
286
+ ? this.tasks
287
+ : (() => {
288
+ const active = this.tasks.findIndex((t) => t.state === 'running' || t.state === 'pending');
289
+ const from = Math.max(0, Math.min(active === -1 ? this.tasks.length - MAX : active - 3, this.tasks.length - MAX));
290
+ return this.tasks.slice(from, from + MAX);
291
+ })();
292
+
293
+ const hiddenAbove = this.tasks.indexOf(visible[0] as Task);
294
+ const hiddenBelow = this.tasks.length - (hiddenAbove + visible.length);
295
+
296
+ const body = [
297
+ head,
298
+ ...(hiddenAbove > 0 ? [dim(` … ${hiddenAbove} earlier`)] : []),
299
+ ...visible.map((t) => this.line(t)),
300
+ ...(hiddenBelow > 0 ? [dim(` … ${hiddenBelow} more`)] : []),
301
+ ...this.notes.map((n) => ` ${n}`),
302
+ ];
303
+
304
+ // Repaint in place: up N lines, then clear-and-write each one.
305
+ const out = cursorUp(this.linesDrawn) + body.map((l) => CLEAR_LINE + l).join('\n') + '\n';
306
+ this.stream.write(out);
307
+ this.linesDrawn = final ? 0 : body.length;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * A single long wait with no sub-steps — a snapshot poll, a download.
313
+ * Returns a function to finish it.
314
+ */
315
+ export const spinner = (title: string, opts: { plain?: boolean } = {}) => {
316
+ const p = new Progress({ title, plain: opts.plain });
317
+ const id = 'only';
318
+ p.add({ id, label: title, state: 'running', startedAt: Date.now() });
319
+ p.start();
320
+ return {
321
+ detail: (text: string) => p.update(id, { detail: text }),
322
+ done: (label?: string) => {
323
+ p.update(id, { state: 'done', ...(label ? { label } : {}) });
324
+ p.stop();
325
+ },
326
+ fail: (label?: string) => {
327
+ p.update(id, { state: 'failed', ...(label ? { label } : {}) });
328
+ p.stop();
329
+ },
330
+ };
331
+ };
332
+
333
+ export const isLive = () => Boolean(process.stderr.isTTY) && !process.env.PPC_NO_TUI && !process.env.NO_TUI;
334
+
335
+ export { magenta };
@@ -0,0 +1,123 @@
1
+ import { green, red, yellow, dim, bold } from '../util/log.ts';
2
+ import type { Tree } from '../model/tree.ts';
3
+ import { validateSchema, type Finding } from './schema.ts';
4
+ import { validateIntegrity, summarise } from './integrity.ts';
5
+ import { validateAgainstReference } from './reference.ts';
6
+ import type { Reference } from '../pipefy/reference.ts';
7
+ import type { AgentsSection } from '../workspace/agents.ts';
8
+
9
+ export type { Finding } from './schema.ts';
10
+ export { summarise } from './integrity.ts';
11
+
12
+ export type ValidateOpts = {
13
+ knownRepoIds?: number[];
14
+ /**
15
+ * The last pull, if available. Findings that are already true of the baseline
16
+ * are marked pre-existing and stop being blocking.
17
+ *
18
+ * This is not a convenience. Real pipes ship with `phase_jumps`
19
+ * pointing at phases that no longer exist — real data from a real pipe. Without
20
+ * this distinction, a freshly pulled, unedited workspace fails validation and
21
+ * the FDE is blamed for it.
22
+ */
23
+ baseline?: Tree | null;
24
+ /**
25
+ * The closed sets from the last pull. Absent means those checks are skipped
26
+ * — never that a value was confirmed valid.
27
+ */
28
+ reference?: Reference | null;
29
+ /** This repo's AI agents, read from `agents/`. Absent means unread, not absent. */
30
+ agents?: AgentsSection | null;
31
+ };
32
+
33
+ /**
34
+ * Entity plus message, deliberately *not* including the file.
35
+ *
36
+ * Renaming a phase changes the file label of every finding inside it, so a
37
+ * file-keyed fingerprint would report a pre-existing problem as newly
38
+ * introduced the moment the FDE renames something nearby.
39
+ */
40
+ const fingerprint = (f: Finding) => `${f.entity}::${f.message}`;
41
+
42
+ export const validateTree = (tree: Tree, opts: ValidateOpts = {}): Finding[] => {
43
+ const findings = [
44
+ ...validateSchema(tree),
45
+ ...validateIntegrity(tree, { knownRepoIds: opts.knownRepoIds, agents: opts.agents }),
46
+ ...validateAgainstReference(tree, opts.reference ?? null),
47
+ ];
48
+
49
+ if (!opts.baseline) return findings;
50
+
51
+ /**
52
+ * A multiset, not a set: if the baseline had one dangling reference of a kind
53
+ * and the edit introduced a second, the second must still be reported.
54
+ */
55
+ const budget = new Map<string, number>();
56
+ for (const f of [
57
+ ...validateSchema(opts.baseline),
58
+ ...validateIntegrity(opts.baseline, { knownRepoIds: opts.knownRepoIds, agents: opts.agents }),
59
+ ...validateAgainstReference(opts.baseline, opts.reference ?? null),
60
+ ]) {
61
+ const key = fingerprint(f);
62
+ budget.set(key, (budget.get(key) ?? 0) + 1);
63
+ }
64
+
65
+ return findings.map((f) => {
66
+ const key = fingerprint(f);
67
+ const left = budget.get(key) ?? 0;
68
+ if (left <= 0) return f;
69
+ budget.set(key, left - 1);
70
+ return { ...f, severity: 'warning' as const, preExisting: true };
71
+ });
72
+ };
73
+
74
+ /** Errors the edit introduced — what plan and apply actually gate on. */
75
+ export const blockingErrors = (findings: Finding[]): Finding[] =>
76
+ findings.filter((f) => f.severity === 'error' && !f.preExisting);
77
+
78
+ export const renderFindings = (findings: Finding[], label: string): string => {
79
+ const introduced = findings.filter((f) => !f.preExisting);
80
+ const preExisting = findings.filter((f) => f.preExisting);
81
+ const { errors, warnings } = summarise(introduced);
82
+ const lines: string[] = [];
83
+
84
+ if (!findings.length) {
85
+ return green(`+ ${label}: valid`);
86
+ }
87
+
88
+ lines.push(bold(label));
89
+
90
+ const group = (items: Finding[]) => {
91
+ const byFile = new Map<string, Finding[]>();
92
+ for (const f of items) {
93
+ const key = f.file ?? f.entity;
94
+ const arr = byFile.get(key) ?? [];
95
+ arr.push(f);
96
+ byFile.set(key, arr);
97
+ }
98
+ for (const [file, items2] of byFile) {
99
+ lines.push(` ${dim(file)}`);
100
+ for (const f of items2) {
101
+ const mark = f.severity === 'error' ? red('x') : yellow('!');
102
+ lines.push(` ${mark} ${f.message}`);
103
+ if (f.fix) lines.push(` ${dim('-> ' + f.fix)}`);
104
+ }
105
+ }
106
+ };
107
+
108
+ if (introduced.length) group(introduced);
109
+
110
+ if (preExisting.length) {
111
+ lines.push('');
112
+ lines.push(dim(` ${preExisting.length} pre-existing issue${preExisting.length === 1 ? '' : 's'} — present in the last pull, not caused by these edits:`));
113
+ for (const f of preExisting) lines.push(` ${dim('·')} ${dim(`${f.entity}: ${f.message}`)}`);
114
+ }
115
+
116
+ lines.push('');
117
+ lines.push(
118
+ `${errors ? red(`${errors} error${errors === 1 ? '' : 's'}`) : green('0 errors')}, ` +
119
+ `${warnings ? yellow(`${warnings} warning${warnings === 1 ? '' : 's'}`) : '0 warnings'}` +
120
+ `${preExisting.length ? dim(`, ${preExisting.length} pre-existing`) : ''}`,
121
+ );
122
+ return lines.join('\n');
123
+ };