@pipefy/pipefy-process-coder 0.1.0 → 0.1.2

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