@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,387 @@
1
+ import type { Row } from '../model/payload.ts';
2
+ import type { Tree } from '../model/tree.ts';
3
+ import type { Finding } from './schema.ts';
4
+ import type { AgentsSection } from '../workspace/agents.ts';
5
+
6
+ /**
7
+ * Referential integrity — PLAN.md §5.1.
8
+ *
9
+ * The rule that earns its keep: **deleting a field obliges you to fix every
10
+ * reference to it.** References hide inside opaque JSON as `%{<fieldId>}`,
11
+ * `"field_address": "<fieldId>"` and `"trigger_field_ids": ["<fieldId>"]`, so
12
+ * they are found by walking the structure rather than by checking a column.
13
+ */
14
+
15
+ const REFERENCE_RE = /%\{(\d+)\}/g;
16
+ const PLACEHOLDER_RE = /%\{_new:([^}]+)\}/g;
17
+
18
+ /** Every field id referenced anywhere inside a value, with its location. */
19
+ const collectReferences = (
20
+ value: unknown,
21
+ where: string,
22
+ out: Array<{ id: string; where: string; kind: 'template' | 'bare' }>,
23
+ keyIsIdBearing = false,
24
+ ) => {
25
+ if (typeof value === 'string') {
26
+ for (const m of value.matchAll(REFERENCE_RE)) out.push({ id: m[1] as string, where, kind: 'template' });
27
+ if (keyIsIdBearing && /^\d+$/.test(value)) out.push({ id: value, where, kind: 'bare' });
28
+ return;
29
+ }
30
+ if (typeof value === 'number' && keyIsIdBearing) {
31
+ out.push({ id: String(value), where, kind: 'bare' });
32
+ return;
33
+ }
34
+ if (Array.isArray(value)) {
35
+ value.forEach((v, i) => collectReferences(v, `${where}[${i}]`, out, keyIsIdBearing));
36
+ return;
37
+ }
38
+ if (value && typeof value === 'object') {
39
+ for (const [k, v] of Object.entries(value as Row)) {
40
+ const bearing = k === 'field_address' || k === 'field_id' || k === 'fieldId' || k === 'field_ids' || k === 'fieldIds' || k === 'trigger_field_ids' || k === 'triggerFieldIds';
41
+ collectReferences(v, `${where}.${k}`, out, bearing);
42
+ }
43
+ }
44
+ };
45
+
46
+ /** Phase ids referenced by an automation's params. */
47
+ const phaseReferences = (value: unknown, where: string, out: Array<{ id: string; where: string }>) => {
48
+ if (Array.isArray(value)) {
49
+ value.forEach((v, i) => phaseReferences(v, `${where}[${i}]`, out));
50
+ return;
51
+ }
52
+ if (value && typeof value === 'object') {
53
+ for (const [k, v] of Object.entries(value as Row)) {
54
+ if (/^(to_phase_id|from_phase_id|in_phase_id)$/.test(k) && (typeof v === 'string' || typeof v === 'number')) {
55
+ if (String(v)) out.push({ id: String(v), where: `${where}.${k}` });
56
+ }
57
+ phaseReferences(v, `${where}.${k}`, out);
58
+ }
59
+ }
60
+ };
61
+
62
+ const AI_ACTIONS = new Set(['ai_behavior', 'generate_with_ai']);
63
+
64
+ /** `%{_new:<key>}` — a reference to an entity created in the same edit. */
65
+ const PLACEHOLDER_REF = /^%{_new:([^}]+)}$/;
66
+
67
+ const placeholderKey = (v: unknown): string | null => {
68
+ if (typeof v !== 'string') return null;
69
+ return PLACEHOLDER_REF.exec(v)?.[1] ?? null;
70
+ };
71
+
72
+ export const validateIntegrity = (
73
+ tree: Tree,
74
+ opts: { knownRepoIds?: number[]; agents?: AgentsSection | null } = {},
75
+ ): Finding[] => {
76
+ const findings: Finding[] = [];
77
+
78
+ const fields = tree.phases.flatMap((p) => p.fields.map((f) => ({ f, phase: p })));
79
+ const fieldIds = new Set(fields.map(({ f }) => String(f['id'])).filter((id) => id && id !== 'null'));
80
+ const phaseIds = new Set(tree.phases.map((p) => String(p['id'])).filter((id) => id && id !== 'null'));
81
+ const repoId = Number(tree.repo['id']);
82
+ const knownRepos = new Set((opts.knownRepoIds ?? [repoId]).map(String));
83
+
84
+ const newFieldAliases = new Set<string>();
85
+ for (const { f } of fields) {
86
+ if (f['id'] === null || f['_new'] === true) {
87
+ if (f['uuid']) newFieldAliases.add(String(f['uuid']));
88
+ if (f['label']) newFieldAliases.add(String(f['label']));
89
+ }
90
+ }
91
+
92
+ const newPhaseAliases = new Set<string>();
93
+ for (const p of tree.phases) {
94
+ if (p['id'] === null || p['_new'] === true) {
95
+ if (p['uuid']) newPhaseAliases.add(String(p['uuid']));
96
+ if (p['name']) newPhaseAliases.add(String(p['name']));
97
+ }
98
+ }
99
+
100
+ /**
101
+ * `otherRepo` names the repo whose fields a value refers to, when that is not
102
+ * this one.
103
+ *
104
+ * A cross-repo automation's action side is written in the *target* pipe's field
105
+ * ids: `create_card` in another pipe maps values into that pipe's fields, so
106
+ * every id in `field_maps` and `fields_map_order` is foreign by construction.
107
+ * Checking them against this repo reported nine dangling references for an
108
+ * automation that was correct, and blocked the plan — which made a handoff
109
+ * between two pipes impossible to express. The reference is reported as
110
+ * unverifiable instead, which is what it is: the other pipe may not even be in
111
+ * this workspace.
112
+ */
113
+ const checkRefs = (value: unknown, label: string, file: string, entity: string, otherRepo?: number | null) => {
114
+ const refs: Array<{ id: string; where: string; kind: 'template' | 'bare' }> = [];
115
+ collectReferences(value, label, refs);
116
+ for (const r of refs) {
117
+ if (fieldIds.has(r.id)) continue;
118
+ if (otherRepo) {
119
+ findings.push({
120
+ severity: 'warning',
121
+ file,
122
+ entity,
123
+ message:
124
+ `references field ${r.id} at ${r.where}, which belongs to repo ${otherRepo} — ` +
125
+ 'not checked here',
126
+ fix: `pull repo ${otherRepo} into this workspace to have it verified`,
127
+ });
128
+ continue;
129
+ }
130
+ findings.push({
131
+ severity: 'error',
132
+ file,
133
+ entity,
134
+ message: `references field ${r.id} at ${r.where}, and no field with that id exists in this repo`,
135
+ fix: 'point it at a field that exists, or restore the deleted field',
136
+ });
137
+ }
138
+
139
+ // Placeholders for fields created in the same edit must name a real new field.
140
+ if (typeof value === 'object' && value !== null) {
141
+ const text = JSON.stringify(value);
142
+ for (const m of text.matchAll(PLACEHOLDER_RE)) {
143
+ const key = m[1] as string;
144
+ if (!newFieldAliases.has(key)) {
145
+ findings.push({
146
+ severity: 'error',
147
+ file,
148
+ entity,
149
+ message: `placeholder %{_new:${key}} does not match the uuid or label of any new field in this workspace`,
150
+ fix: 'use the new field\'s uuid, exactly as written in its phase file',
151
+ });
152
+ }
153
+ }
154
+ }
155
+ };
156
+
157
+ // ── automations ────────────────────────────────────────────────────────────
158
+ for (const a of tree.automations) {
159
+ const file = `automations/${String(a['name'] ?? '')}`;
160
+
161
+ /**
162
+ * The action side and the event side of an automation are read in different
163
+ * repos when `action_repo_id` points elsewhere. The event side is always this
164
+ * repo — that is where the trigger fires.
165
+ */
166
+ const actionRepo = Number(a['action_repo_id']);
167
+ const foreign = Number.isFinite(actionRepo) && actionRepo !== repoId ? actionRepo : null;
168
+
169
+ checkRefs(a['action_params'], 'action_params', file, 'automations', foreign);
170
+ checkRefs(a['event_params'], 'event_params', file, 'automations');
171
+ checkRefs(a['search_for'], 'search_for', file, 'automations');
172
+ checkRefs(a.field_maps, 'field_maps', file, 'automations', foreign);
173
+ if (a.condition) checkRefs(a.condition, 'condition', file, 'automations');
174
+
175
+ const phaseRefs: Array<{ id: string; where: string }> = [];
176
+ phaseReferences(a['action_params'], 'action_params', phaseRefs);
177
+ phaseReferences(a['event_params'], 'event_params', phaseRefs);
178
+ for (const r of phaseRefs) {
179
+ if (phaseIds.has(r.id)) continue;
180
+ // A cross-repo automation legitimately names a phase in the other repo.
181
+ if (Number(a['action_repo_id']) !== repoId) continue;
182
+ findings.push({
183
+ severity: 'error',
184
+ file,
185
+ entity: 'automations',
186
+ message: `references phase ${r.id} at ${r.where}, which is not a phase of this repo`,
187
+ });
188
+ }
189
+
190
+ for (const key of ['event_repo_id', 'action_repo_id']) {
191
+ const v = a[key];
192
+ if (v === null || v === undefined) continue;
193
+ if (knownRepos.has(String(v))) continue;
194
+ findings.push({
195
+ severity: 'warning',
196
+ file,
197
+ entity: 'automations',
198
+ message: `${key} is ${String(v)}, which is not a repo in this workspace`,
199
+ fix: 'pull with a greater --depth, or check that the token can administer that repo',
200
+ });
201
+ }
202
+
203
+ // AI config is absent from the snapshot payload (ROUTE-B-V2.md ask 5), so a
204
+ // round trip drops it silently. Refusing here is the only protection.
205
+ const usesAi =
206
+ AI_ACTIONS.has(String(a['action_id'])) ||
207
+ JSON.stringify(a['action_params'] ?? {}).includes('fill_with_ai') ||
208
+ a.field_maps.some((m) => String(m['input_mode']) === 'fill_with_ai');
209
+ if (usesAi && (opts.agents?.agents.length ?? 0) === 0) {
210
+ /**
211
+ * An error only when the agents were readable and there genuinely are
212
+ * none — then the automation references configuration that does not
213
+ * exist. When they could not be read at all (no `agents/` section, or
214
+ * one whose coverage is 'unknown'), their absence proves nothing, so
215
+ * this is a warning: the same distinction coverage makes for every
216
+ * other entity group.
217
+ */
218
+ const readable = opts.agents?.meta.coverage === 'complete';
219
+ findings.push({
220
+ severity: readable ? 'error' : 'warning',
221
+ file,
222
+ entity: 'automations',
223
+ message: readable
224
+ ? 'this automation uses AI, but this repo has no AI agents'
225
+ : 'this automation uses AI, and the AI agents could not be read on this pull',
226
+ fix: readable
227
+ ? 'point it at an agent that exists, or remove the AI action'
228
+ : 'the payload has no ai_agents array (ROUTE-B-V2.md ask 5), and agents/ is missing or unreadable, so a round trip can drop AI configuration silently — do not edit this automation',
229
+ });
230
+ }
231
+ }
232
+
233
+ // ── field conditions ───────────────────────────────────────────────────────
234
+ for (const fc of tree.field_conditions) {
235
+ const file = `field-conditions/${String(fc['name'] ?? '')}`;
236
+ const fcPhasePlaceholder = placeholderKey(fc['phase_id']);
237
+ if (fcPhasePlaceholder) {
238
+ if (!newPhaseAliases.has(fcPhasePlaceholder)) {
239
+ findings.push({
240
+ severity: 'error',
241
+ file,
242
+ entity: 'field_conditions',
243
+ message: `phase_id names %{_new:${fcPhasePlaceholder}}, which is not the uuid or name of any new phase here`,
244
+ fix: "use the new phase's uuid, exactly as written in its phase file",
245
+ });
246
+ }
247
+ } else if (!phaseIds.has(String(fc['phase_id']))) {
248
+ findings.push({ severity: 'error', file, entity: 'field_conditions', message: `phase_id ${String(fc['phase_id'])} is not a phase of this repo` });
249
+ }
250
+
251
+ for (const action of fc.actions) {
252
+ const raw = action['field_id'];
253
+ const placeholder = placeholderKey(raw);
254
+ if (placeholder) {
255
+ if (!newFieldAliases.has(placeholder)) {
256
+ findings.push({
257
+ severity: 'error',
258
+ file,
259
+ entity: 'field_condition_actions',
260
+ message: `action targets %{_new:${placeholder}}, which is not the uuid or label of any new field here`,
261
+ });
262
+ }
263
+ continue;
264
+ }
265
+ const fid = String(raw ?? '');
266
+ if (fid && !fieldIds.has(fid)) {
267
+ findings.push({ severity: 'error', file, entity: 'field_condition_actions', message: `action targets field ${fid}, which does not exist` });
268
+ }
269
+ }
270
+ if (fc.condition) checkRefs(fc.condition, 'condition', file, 'field_conditions');
271
+ if (fc.condition && (fc.condition.expressions ?? []).length === 0) {
272
+ findings.push({ severity: 'error', file, entity: 'conditions', message: 'condition has no expressions — it can never evaluate' });
273
+ }
274
+ }
275
+
276
+ /**
277
+ * The two hidden-attribute lists are closed enumerations, and the endpoint
278
+ * only says so in its rejection: "There is an invalid attribute, accepted
279
+ * attributes: Assignees, Attachments, Checklist, Comments, DueDate, Labels".
280
+ * The value that provoked it — a plausible-looking `"clone"` — cost a failed
281
+ * step in the middle of an otherwise clean apply, and the list is nowhere in
282
+ * the schema. Checking it offline is free.
283
+ */
284
+ const HIDEABLE = new Set(['Assignees', 'Attachments', 'Checklist', 'Comments', 'DueDate', 'Labels']);
285
+ if (tree.preferences) {
286
+ for (const key of ['hidden_top_buttons', 'hidden_start_form_attributes']) {
287
+ const list = tree.preferences[key];
288
+ if (!Array.isArray(list)) continue;
289
+ for (const v of list) {
290
+ if (HIDEABLE.has(String(v))) continue;
291
+ findings.push({
292
+ severity: 'error',
293
+ file: 'preferences.json',
294
+ entity: 'repo_preferences',
295
+ message: `${key} contains "${String(v)}", which the endpoint does not accept`,
296
+ fix: `use one of: ${[...HIDEABLE].join(', ')}`,
297
+ });
298
+ }
299
+ }
300
+ }
301
+
302
+ // ── phase jumps ────────────────────────────────────────────────────────────
303
+ /** `%{_new:<uuid or name>}` naming a phase this edit creates. */
304
+ const namesNewPhase = (t: unknown): boolean => {
305
+ const m = /^%\{_new:(.+)\}$/.exec(String(t));
306
+ return m ? newPhaseAliases.has(m[1] as string) : false;
307
+ };
308
+
309
+ for (const p of tree.phases) {
310
+ for (const t of p.jump_target_ids) {
311
+ if (namesNewPhase(t)) continue;
312
+ if (!phaseIds.has(String(t))) {
313
+ findings.push({
314
+ severity: 'error',
315
+ file: `phases/… ${String(p['name'] ?? '')}`,
316
+ entity: 'phase_jumps',
317
+ message: `jumps to phase ${t}, which is not a phase of this repo`,
318
+ });
319
+ }
320
+ if (String(t) === String(p['id'])) {
321
+ findings.push({
322
+ severity: 'error',
323
+ file: `phases/… ${String(p['name'] ?? '')}`,
324
+ entity: 'phase_jumps',
325
+ message: 'a phase cannot jump to itself',
326
+ });
327
+ }
328
+ }
329
+ }
330
+
331
+ // ── title field must be a start-form field ─────────────────────────────────
332
+ const titleFieldId = tree.repo['title_field_id'];
333
+ if (titleFieldId) {
334
+ const startForm = tree.phases.find((p) => Number(p['index']) === 0);
335
+ const inStartForm = (startForm?.fields ?? []).some((f) => String(f['id']) === String(titleFieldId));
336
+ if (!inStartForm) {
337
+ findings.push({
338
+ severity: 'error',
339
+ file: 'pipe.json',
340
+ entity: 'repo',
341
+ message: `title_field_id ${String(titleFieldId)} is not a field on the start form`,
342
+ fix: 'the card title must come from a start-form field',
343
+ });
344
+ }
345
+ }
346
+
347
+ // ── connector fields must point somewhere we pulled ────────────────────────
348
+ for (const { f, phase } of fields) {
349
+ if (f['type_id'] !== 'connector') continue;
350
+ const target = f['connected_pipe_id'];
351
+ if (target === null || target === undefined) continue;
352
+ if (knownRepos.has(String(target))) continue;
353
+ findings.push({
354
+ severity: 'warning',
355
+ file: `phases/… ${String(phase['name'] ?? '')} / ${String(f['label'] ?? '')}`,
356
+ entity: 'fields',
357
+ message: `connects to repo ${String(target)}, which is not in this workspace`,
358
+ fix: 'pull with a greater --depth so the connected repo is present and checkable',
359
+ });
360
+ }
361
+
362
+ // ── coverage: never edit what was never read ───────────────────────────────
363
+ for (const [entity, cov] of Object.entries(tree.meta.coverage ?? {})) {
364
+ if (cov !== 'unknown') continue;
365
+ const populated =
366
+ (entity === 'webhooks' && tree.webhooks.length > 0) ||
367
+ (entity === 'labels' && tree.labels.length > 0) ||
368
+ (entity === 'automations' && tree.automations.length > 0) ||
369
+ (entity === 'public_forms' && tree.public_form !== null) ||
370
+ (entity === 'repo_preferences' && tree.preferences !== null);
371
+ if (populated) {
372
+ findings.push({
373
+ severity: 'error',
374
+ entity,
375
+ message: `${entity} was recorded as unknown by the pull, but this workspace has rows for it`,
376
+ fix: 'the rows were added by hand into an entity group that has no read path — remove them, or re-pull from a snapshot',
377
+ });
378
+ }
379
+ }
380
+
381
+ return findings;
382
+ };
383
+
384
+ export const summarise = (findings: Finding[]) => ({
385
+ errors: findings.filter((f) => f.severity === 'error').length,
386
+ warnings: findings.filter((f) => f.severity === 'warning').length,
387
+ });
@@ -0,0 +1,136 @@
1
+ import type { Row } from '../model/payload.ts';
2
+ import type { Tree } from '../model/tree.ts';
3
+ import { checkAutomationParams } from '../pipefy/automations.ts';
4
+ import { toCatalogue, enumValues, type Reference } from '../pipefy/reference.ts';
5
+ import type { Finding } from './schema.ts';
6
+
7
+ /**
8
+ * Closed-set validation, offline, against what the API said on the last pull.
9
+ *
10
+ * The values checked here are all published by the endpoint and enforced by none
11
+ * of it: the inputs that consume them are typed `ID` and `String`, so a wrong
12
+ * value survives query validation and returns `is invalid` with nothing named.
13
+ * Checking it here means the refusal happens before a send, names the value, and
14
+ * lists what belongs there.
15
+ *
16
+ * Why not hardcode the lists? Because that was the previous approach and it
17
+ * silently rotted. `FIELD_TYPES` in `schema.ts` still carries `time_range`, which
18
+ * the API does not offer, and is missing `dynamic_content`, which it does — so it
19
+ * warned about a valid field type and accepted an invalid one, and nothing
20
+ * revealed either. A list read from the endpoint is stamped with when it was read.
21
+ *
22
+ * Everything is skipped when there is no reference. An unread closed set carries
23
+ * no information, exactly as an unread entity group does.
24
+ */
25
+
26
+ /** Where a closed set is consumed, and which set governs it. */
27
+ const ENUM_SITES: Array<{
28
+ enumName: string;
29
+ entity: string;
30
+ key: string;
31
+ /** A value the payload uses that the enum does not list, and which is fine. */
32
+ tolerated?: string[];
33
+ }> = [
34
+ { enumName: 'FieldTypeId', entity: 'fields', key: 'type_id' },
35
+ { enumName: 'FieldConditionFilter', entity: 'field_conditions', key: 'filter' },
36
+ ];
37
+
38
+ const checkEnum = (
39
+ ref: Reference,
40
+ row: Row,
41
+ site: (typeof ENUM_SITES)[number],
42
+ file: string,
43
+ findings: Finding[],
44
+ ) => {
45
+ const values = enumValues(ref, site.enumName);
46
+ if (!values) return;
47
+ const raw = row[site.key];
48
+ if (raw === null || raw === undefined || raw === '') return;
49
+ const value = String(raw);
50
+ if (values.includes(value) || site.tolerated?.includes(value)) return;
51
+ findings.push({
52
+ severity: 'error',
53
+ file,
54
+ entity: site.entity,
55
+ message: `${site.key} "${value}" is not a value the API accepts`,
56
+ fix: `accepted: ${values.join(', ')}`,
57
+ });
58
+ };
59
+
60
+ /**
61
+ * Colors are checked case-insensitively and only warned about.
62
+ *
63
+ * The payload stores a phase colour in more than one spelling across pipe
64
+ * vintages, and an apply that sends a colour the endpoint rejects fails on one
65
+ * cosmetic column — worth reporting, not worth blocking a structural change for.
66
+ */
67
+ const checkColor = (ref: Reference, row: Row, entity: string, file: string, findings: Finding[]) => {
68
+ const values = enumValues(ref, 'Colors');
69
+ const raw = row['color'];
70
+ if (!values || raw === null || raw === undefined || raw === '') return;
71
+ const value = String(raw).toLowerCase();
72
+ if (values.some((v) => v.toLowerCase() === value)) return;
73
+ findings.push({
74
+ severity: 'warning',
75
+ file,
76
+ entity,
77
+ message: `color "${String(raw)}" is not one the API offers`,
78
+ fix: `accepted: ${values.join(', ')}`,
79
+ });
80
+ };
81
+
82
+ /**
83
+ * Validate every authored value that must come from a closed set.
84
+ *
85
+ * Automations reuse `checkAutomationParams` — the same function the compiler
86
+ * gates on — so `pipe validate` and `pipe plan` cannot disagree about whether an
87
+ * automation is sendable.
88
+ */
89
+ export const validateAgainstReference = (tree: Tree, ref: Reference | null): Finding[] => {
90
+ if (!ref) return [];
91
+ const findings: Finding[] = [];
92
+ const catalogue = toCatalogue(ref);
93
+
94
+ for (const phase of tree.phases) {
95
+ const pfile = `phases/${String(phase['name'] ?? '')}`;
96
+ checkColor(ref, phase, 'phases', pfile, findings);
97
+ for (const f of phase.fields) {
98
+ const ffile = `${pfile} / ${String(f['label'] ?? '')}`;
99
+ for (const site of ENUM_SITES) {
100
+ if (site.entity === 'fields') checkEnum(ref, f, site, ffile, findings);
101
+ }
102
+ }
103
+ }
104
+
105
+ for (const fc of tree.field_conditions) {
106
+ const file = `field-conditions/${String(fc['name'] ?? fc['id'] ?? '')}`;
107
+ for (const site of ENUM_SITES) {
108
+ if (site.entity === 'field_conditions') checkEnum(ref, fc, site, file, findings);
109
+ }
110
+ }
111
+
112
+ for (const auto of tree.automations) {
113
+ /**
114
+ * A behavior (`action_id: "ai_behavior"`) is not in this pipe's automation
115
+ * catalogue at all — confirmed live — because it is never sent through
116
+ * `createAutomation`/`updateAutomation`. Checking it here would refuse
117
+ * every behavior, always, regardless of whether it is actually sendable
118
+ * through the real path (`createAiAgent`/`updateAiAgent`, diff/agents.ts).
119
+ */
120
+ if (auto['action_id'] === 'ai_behavior') continue;
121
+ const file = `automations/${String(auto['name'] ?? auto['id'] ?? '')}`;
122
+ for (const f of checkAutomationParams(auto, catalogue)) {
123
+ findings.push({
124
+ severity: f.severity,
125
+ file,
126
+ entity: 'automations',
127
+ message: f.message,
128
+ ...(f.fix ? { fix: f.fix } : {}),
129
+ });
130
+ }
131
+ }
132
+
133
+ checkColor(ref, tree.repo, 'repo', 'pipe.json', findings);
134
+
135
+ return findings;
136
+ };