@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.
- package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
- package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
- package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
- package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
- package/AGENTS.md +456 -0
- package/README.md +808 -0
- package/bin/pipe.js +13 -0
- package/package.json +35 -0
- package/src/apply/adopt.ts +150 -0
- package/src/apply/agentops.ts +71 -0
- package/src/apply/compile.ts +875 -0
- package/src/apply/execute.ts +336 -0
- package/src/apply/flowops.ts +399 -0
- package/src/apply/idmap.ts +241 -0
- package/src/apply/mutations.ts +955 -0
- package/src/apply/registry.ts +430 -0
- package/src/apply/types.ts +134 -0
- package/src/cli/args.ts +88 -0
- package/src/cli.ts +211 -0
- package/src/codec/flow.ts +199 -0
- package/src/codec/pack.ts +103 -0
- package/src/codec/roundtrip.ts +94 -0
- package/src/codec/unpack.ts +198 -0
- package/src/commands/agents.ts +184 -0
- package/src/commands/apply.ts +1144 -0
- package/src/commands/context.ts +119 -0
- package/src/commands/create.ts +79 -0
- package/src/commands/diff.ts +314 -0
- package/src/commands/flows.ts +414 -0
- package/src/commands/misc.ts +644 -0
- package/src/commands/plan.ts +331 -0
- package/src/commands/pull.ts +567 -0
- package/src/commands/runs.ts +83 -0
- package/src/commands/skills.ts +137 -0
- package/src/commands/verify.ts +253 -0
- package/src/config.ts +168 -0
- package/src/diff/agents.ts +122 -0
- package/src/diff/diff.ts +1130 -0
- package/src/diff/flow.ts +318 -0
- package/src/diff/html.ts +322 -0
- package/src/diff/render.ts +101 -0
- package/src/model/payload.ts +154 -0
- package/src/model/tree.ts +99 -0
- package/src/model/volatile.ts +55 -0
- package/src/pipefy/agents.ts +165 -0
- package/src/pipefy/automations.ts +219 -0
- package/src/pipefy/capability.ts +119 -0
- package/src/pipefy/client.ts +267 -0
- package/src/pipefy/discovery.ts +209 -0
- package/src/pipefy/internal.ts +380 -0
- package/src/pipefy/ipaas.ts +365 -0
- package/src/pipefy/reconstruct.ts +775 -0
- package/src/pipefy/reference.ts +251 -0
- package/src/pipefy/snapshot.ts +245 -0
- package/src/pipefy/toolkit.ts +200 -0
- package/src/pipefy/toolkit_bearer.py +137 -0
- package/src/report/integrations.ts +231 -0
- package/src/report/run.ts +475 -0
- package/src/util/fsx.ts +45 -0
- package/src/util/git.ts +32 -0
- package/src/util/json.ts +55 -0
- package/src/util/log.ts +76 -0
- package/src/util/pool.ts +48 -0
- package/src/util/slug.ts +26 -0
- package/src/util/tui.ts +335 -0
- package/src/validate/index.ts +123 -0
- package/src/validate/integrity.ts +387 -0
- package/src/validate/reference.ts +136 -0
- package/src/validate/schema.ts +328 -0
- package/src/workspace/agents.ts +290 -0
- package/src/workspace/docs.ts +407 -0
- package/src/workspace/flows.ts +191 -0
- package/src/workspace/layout.ts +165 -0
- package/src/workspace/lock.ts +148 -0
- package/src/workspace/read.ts +165 -0
- package/src/workspace/reference.ts +24 -0
- package/src/workspace/stamp.ts +301 -0
- 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
|
+
};
|