@still-running/health-check 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/adapters/make.d.ts +14 -0
- package/dist/adapters/make.js +213 -0
- package/dist/adapters/n8n.d.ts +9 -0
- package/dist/adapters/n8n.js +273 -0
- package/dist/core/checks/others.d.ts +10 -0
- package/dist/core/checks/others.js +160 -0
- package/dist/core/checks/protections.d.ts +11 -0
- package/dist/core/checks/protections.js +96 -0
- package/dist/core/checks/zero-write.d.ts +27 -0
- package/dist/core/checks/zero-write.js +245 -0
- package/dist/core/model.d.ts +141 -0
- package/dist/core/model.js +19 -0
- package/dist/core/providers.d.ts +48 -0
- package/dist/core/providers.js +181 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +68 -0
- package/dist/package.json +1 -0
- package/package.json +34 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CHECK 3 — credential expiry risk.
|
|
4
|
+
* CHECK 2 — triggers with no expected cadence.
|
|
5
|
+
* CHECK 1 — error handling. Table stakes: six free tools already do this, so it
|
|
6
|
+
* is a short footer, never the headline.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.checkCredentialExpiry = checkCredentialExpiry;
|
|
10
|
+
exports.checkCadence = checkCadence;
|
|
11
|
+
exports.checkErrorHandling = checkErrorHandling;
|
|
12
|
+
const providers_js_1 = require("../providers.js");
|
|
13
|
+
// ---------------------------------------------------------------- check 3
|
|
14
|
+
function checkCredentialExpiry(wf) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
for (const node of wf.nodes) {
|
|
18
|
+
if (node.disabled)
|
|
19
|
+
continue;
|
|
20
|
+
for (const cred of node.credentials) {
|
|
21
|
+
const provider = cred.providerId
|
|
22
|
+
? providers_js_1.PROVIDERS.find((p) => p.id === cred.providerId)
|
|
23
|
+
: (0, providers_js_1.resolveProvider)(cred.rawType, wf.platform);
|
|
24
|
+
// One finding per provider per workflow, not one per node. An agency with
|
|
25
|
+
// eight Google Sheets nodes has one Google problem, not eight.
|
|
26
|
+
const key = provider ? provider.id : cred.rawType;
|
|
27
|
+
if (seen.has(key))
|
|
28
|
+
continue;
|
|
29
|
+
if (provider) {
|
|
30
|
+
seen.add(key);
|
|
31
|
+
const conditional = provider.rules.filter((r) => r.certainty === 'conditional');
|
|
32
|
+
const shortest = provider.rules[0];
|
|
33
|
+
const severity = !provider.autoRefreshable ? 'high' : conditional.length > 0 ? 'medium' : 'low';
|
|
34
|
+
findings.push({
|
|
35
|
+
checkId: 'credential-expiry',
|
|
36
|
+
severity,
|
|
37
|
+
nodeId: node.id,
|
|
38
|
+
nodeLabel: node.label,
|
|
39
|
+
title: `${provider.displayName} connection on "${node.label}" — expiry depends on how it was set up`,
|
|
40
|
+
ifItGoesQuiet: provider.autoRefreshable
|
|
41
|
+
? `The token stops refreshing, the trigger stops firing, and there is no failed run to see — because there is no run at all.`
|
|
42
|
+
: `Nothing on ${provider.displayName} refreshes this automatically. When it lapses, someone has to paste in a new token by hand, and until they do the workflow is silent.`,
|
|
43
|
+
detail: [
|
|
44
|
+
`We cannot read the expiry from a pasted workflow — the export names the connection, not the settings behind it. Here is what it depends on:`,
|
|
45
|
+
...provider.rules.map((r) => `- ${r.condition}: ${r.window}. ${r.detail}`),
|
|
46
|
+
].join('\n'),
|
|
47
|
+
howToCheck: (shortest?.howToCheck ?? conditional[0]?.howToCheck) || undefined,
|
|
48
|
+
sources: provider.sources,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
else if (cred.authKind === 'oauth2') {
|
|
52
|
+
seen.add(key);
|
|
53
|
+
findings.push({
|
|
54
|
+
checkId: 'credential-expiry',
|
|
55
|
+
severity: 'low',
|
|
56
|
+
nodeId: node.id,
|
|
57
|
+
nodeLabel: node.label,
|
|
58
|
+
title: `"${node.label}" uses an OAuth connection we do not have expiry data for yet`,
|
|
59
|
+
ifItGoesQuiet: 'If this provider expires refresh tokens on a schedule, the trigger stops and no error is raised.',
|
|
60
|
+
detail: `Connection type: ${cred.rawType}. Not in our provider table yet.`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return findings;
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------- check 2
|
|
68
|
+
function checkCadence(wf) {
|
|
69
|
+
if (wf.cadence.expectedIntervalKnown)
|
|
70
|
+
return [];
|
|
71
|
+
const triggers = wf.nodes.filter((n) => wf.triggerIds.includes(n.id) && !n.disabled);
|
|
72
|
+
// No trigger in the file at all — usually a fragment or a sub-workflow. Say
|
|
73
|
+
// that plainly instead of inventing a cadence complaint about nothing.
|
|
74
|
+
if (triggers.length === 0 || wf.cadence.kind === 'unknown') {
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
checkId: 'no-cadence',
|
|
78
|
+
severity: 'info',
|
|
79
|
+
nodeId: null,
|
|
80
|
+
nodeLabel: null,
|
|
81
|
+
title: 'No trigger in this file',
|
|
82
|
+
ifItGoesQuiet: 'Nothing to say yet — without a trigger there is no expected rhythm to measure against.',
|
|
83
|
+
detail: 'This is either a fragment, or a sub-workflow called by another one. Paste the workflow that calls it to see how often this is meant to run.',
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
const label = triggers[0]?.label ?? 'the trigger';
|
|
88
|
+
if (wf.cadence.kind === 'manual') {
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
checkId: 'no-cadence',
|
|
92
|
+
severity: 'low',
|
|
93
|
+
nodeId: triggers[0]?.id ?? null,
|
|
94
|
+
nodeLabel: triggers[0]?.label ?? null,
|
|
95
|
+
title: 'This workflow only runs when somebody presses the button',
|
|
96
|
+
ifItGoesQuiet: 'Nothing to detect — a manual workflow that never runs is not broken.',
|
|
97
|
+
detail: 'Nothing to monitor here until it gets a schedule or a webhook.',
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
checkId: 'no-cadence',
|
|
104
|
+
severity: wf.cadence.kind === 'event' ? 'high' : 'medium',
|
|
105
|
+
nodeId: triggers[0]?.id ?? null,
|
|
106
|
+
nodeLabel: triggers[0]?.label ?? null,
|
|
107
|
+
title: `Nothing here says how often "${label}" should fire`,
|
|
108
|
+
ifItGoesQuiet: 'There is no expected rhythm to compare against, so an idle workflow and a dead workflow look identical. Nobody can tell you it stopped, because nobody knows what "running normally" looks like.',
|
|
109
|
+
detail: wf.cadence.kind === 'event'
|
|
110
|
+
? `This fires on an outside event (${wf.cadence.description}). If the source stops sending — a renamed field, a revoked webhook, a client who turned something off — the workflow simply never runs. There is no failed execution, because there is no execution.`
|
|
111
|
+
: `Trigger type: ${wf.cadence.description}. Without an expected interval there is no baseline.`,
|
|
112
|
+
howToCheck: 'Write down the number you would expect on a normal day: runs per day, or rows per run. That single number is what turns silence into an alert.',
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
// ---------------------------------------------------------------- check 1
|
|
117
|
+
function checkErrorHandling(wf) {
|
|
118
|
+
const findings = [];
|
|
119
|
+
const live = wf.nodes.filter((n) => !n.disabled && n.role !== 'note');
|
|
120
|
+
const unhandled = live.filter((n) => (n.role === 'write' || n.role === 'read' || n.platformType.includes('httpRequest')) &&
|
|
121
|
+
!n.errorHandling.hasErrorBranch &&
|
|
122
|
+
!n.errorHandling.retries);
|
|
123
|
+
if (!wf.errorPolicy.workflowLevelHandler) {
|
|
124
|
+
findings.push({
|
|
125
|
+
checkId: 'error-handling',
|
|
126
|
+
severity: 'medium',
|
|
127
|
+
nodeId: null,
|
|
128
|
+
nodeLabel: null,
|
|
129
|
+
title: 'No workflow-level error handler is set',
|
|
130
|
+
ifItGoesQuiet: 'A step that throws stops the run. Somebody has to be watching the execution list to find out.',
|
|
131
|
+
detail: wf.platform === 'n8n'
|
|
132
|
+
? 'This is a different miss from "a node has no error branch", and easier to overlook, because the canvas looks fine. It lives in workflow Settings -> Error Workflow.'
|
|
133
|
+
: 'No error route on the scenario. Make will retry per its own settings and then stop.',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (wf.errorPolicy.storesFailedRuns === false) {
|
|
137
|
+
findings.push({
|
|
138
|
+
checkId: 'error-handling',
|
|
139
|
+
severity: 'high',
|
|
140
|
+
nodeId: null,
|
|
141
|
+
nodeLabel: null,
|
|
142
|
+
title: 'Incomplete executions are switched off',
|
|
143
|
+
ifItGoesQuiet: 'A run that fails part-way is not stored, so there is nothing to resume and nothing to inspect afterwards. The data that run was carrying is gone.',
|
|
144
|
+
detail: 'Make stores failed runs only when this is on, and it is off by default. Scenario settings -> Allow storing of Incomplete Executions.',
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (unhandled.length > 0) {
|
|
148
|
+
const names = unhandled.slice(0, 4).map((n) => `"${n.label}"`).join(', ');
|
|
149
|
+
findings.push({
|
|
150
|
+
checkId: 'error-handling',
|
|
151
|
+
severity: 'low',
|
|
152
|
+
nodeId: unhandled[0].id,
|
|
153
|
+
nodeLabel: unhandled[0].label,
|
|
154
|
+
title: `${unhandled.length} step${unhandled.length === 1 ? '' : 's'} with no retry and no error branch`,
|
|
155
|
+
ifItGoesQuiet: 'These throw on a bad day and the run stops where it stands, part-done.',
|
|
156
|
+
detail: `${names}${unhandled.length > 4 ? `, and ${unhandled.length - 4} more` : ''}. This is the check every free auditor already does — it is here for completeness, not because it is the interesting part.`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return findings;
|
|
160
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is holding this workflow together.
|
|
3
|
+
*
|
|
4
|
+
* Most pastes produce nothing critical. A blank result panel teaches the reader
|
|
5
|
+
* that the tool found nothing useful, which is both untrue and the fastest way
|
|
6
|
+
* to lose them. So we say what protects it, from the same analysis — never
|
|
7
|
+
* invented, never padded. If none of these are true, we show nothing rather
|
|
8
|
+
* than reach for filler.
|
|
9
|
+
*/
|
|
10
|
+
import type { Protection, Workflow } from '../model.js';
|
|
11
|
+
export declare function checkProtections(wf: Workflow): Protection[];
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What is holding this workflow together.
|
|
4
|
+
*
|
|
5
|
+
* Most pastes produce nothing critical. A blank result panel teaches the reader
|
|
6
|
+
* that the tool found nothing useful, which is both untrue and the fastest way
|
|
7
|
+
* to lose them. So we say what protects it, from the same analysis — never
|
|
8
|
+
* invented, never padded. If none of these are true, we show nothing rather
|
|
9
|
+
* than reach for filler.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.checkProtections = checkProtections;
|
|
13
|
+
const zero_write_js_1 = require("./zero-write.js");
|
|
14
|
+
function checkProtections(wf) {
|
|
15
|
+
const out = [];
|
|
16
|
+
const byId = new Map(wf.nodes.map((n) => [n.id, n]));
|
|
17
|
+
if (wf.cadence.expectedIntervalKnown) {
|
|
18
|
+
out.push({
|
|
19
|
+
kind: 'known-cadence',
|
|
20
|
+
nodeId: null,
|
|
21
|
+
nodeLabel: null,
|
|
22
|
+
title: `Runs ${wf.cadence.description}`,
|
|
23
|
+
detail: 'There is a stated rhythm here, so a missed run is measurable. Most of the workflows we see have no declared cadence at all, which is why nobody can tell idle from dead.',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (wf.errorPolicy.workflowLevelHandler) {
|
|
27
|
+
out.push({
|
|
28
|
+
kind: 'error-handler',
|
|
29
|
+
nodeId: null,
|
|
30
|
+
nodeLabel: null,
|
|
31
|
+
title: 'A workflow-level error handler is set',
|
|
32
|
+
detail: wf.platform === 'n8n'
|
|
33
|
+
? 'Anything that throws reaches your error workflow rather than sitting in the execution list waiting to be noticed.'
|
|
34
|
+
: 'The scenario has an error route, so a failure goes somewhere instead of just stopping.',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (wf.errorPolicy.storesFailedRuns === true) {
|
|
38
|
+
out.push({
|
|
39
|
+
kind: 'stores-failed-runs',
|
|
40
|
+
nodeId: null,
|
|
41
|
+
nodeLabel: null,
|
|
42
|
+
title: 'Incomplete executions are switched on',
|
|
43
|
+
detail: 'Make keeps failed runs so you can look at them and resume them. This is off by default, so somebody turned it on deliberately.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const alertIds = (0, zero_write_js_1.findAlertNodes)(wf);
|
|
47
|
+
const dom = (0, zero_write_js_1.computeDominators)(wf);
|
|
48
|
+
const writes = wf.nodes.filter((n) => n.role === 'write' && !n.disabled && !alertIds.has(n.id));
|
|
49
|
+
for (const w of writes) {
|
|
50
|
+
const dominators = dom.get(w.id) ?? new Set();
|
|
51
|
+
const gates = [...dominators]
|
|
52
|
+
.filter((d) => d !== w.id)
|
|
53
|
+
.map((d) => byId.get(d))
|
|
54
|
+
.filter((n) => !!n && !!n.zeroEmit);
|
|
55
|
+
if (gates.length === 0)
|
|
56
|
+
continue;
|
|
57
|
+
if (!gates.every((g) => (0, zero_write_js_1.isGuarded)(wf, g.id)))
|
|
58
|
+
continue;
|
|
59
|
+
out.push({
|
|
60
|
+
kind: 'guarded-write',
|
|
61
|
+
nodeId: w.id,
|
|
62
|
+
nodeLabel: w.label,
|
|
63
|
+
title: `"${w.label}" is covered if it gets nothing`,
|
|
64
|
+
detail: `Every step that could starve it — ${gates
|
|
65
|
+
.slice(0, 2)
|
|
66
|
+
.map((g) => `"${g.label}"`)
|
|
67
|
+
.join(', ')} — has somewhere else to send the empty case. The quiet run does not vanish.`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (alertIds.size > 0) {
|
|
71
|
+
const names = [...alertIds]
|
|
72
|
+
.map((id) => byId.get(id)?.label)
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.slice(0, 2);
|
|
75
|
+
if (names.length) {
|
|
76
|
+
out.push({
|
|
77
|
+
kind: 'alert-branch',
|
|
78
|
+
nodeId: null,
|
|
79
|
+
nodeLabel: null,
|
|
80
|
+
title: `Somebody gets told: ${names.map((n) => `"${n}"`).join(', ')}`,
|
|
81
|
+
detail: 'There is a message on a branch that only runs when something goes the wrong way. That is the difference between a quiet failure and a known one.',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const retried = wf.nodes.filter((n) => n.errorHandling.retries && !n.disabled);
|
|
86
|
+
if (retried.length > 0) {
|
|
87
|
+
out.push({
|
|
88
|
+
kind: 'retries',
|
|
89
|
+
nodeId: retried[0].id,
|
|
90
|
+
nodeLabel: retried[0].label,
|
|
91
|
+
title: `${retried.length} step${retried.length === 1 ? '' : 's'} retry before giving up`,
|
|
92
|
+
detail: 'A blip on someone else\'s API does not end the run.',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHECK 4 — write steps that can complete having written zero rows.
|
|
3
|
+
*
|
|
4
|
+
* The question this answers is NOT "did this workflow write nothing" — static
|
|
5
|
+
* JSON can never answer that. It answers "can this workflow finish green having
|
|
6
|
+
* written nothing, and would anyone notice". That is a blind-spot report, not a
|
|
7
|
+
* bug report, and the wording throughout keeps that distinction.
|
|
8
|
+
*
|
|
9
|
+
* Method: for every write node W, find the nodes that DOMINATE it — the steps
|
|
10
|
+
* that every path from a trigger to W must pass through. If any dominator can
|
|
11
|
+
* emit zero items, W is skipped entirely and the run still reports success.
|
|
12
|
+
*
|
|
13
|
+
* Dominators rather than path enumeration, because a 246-node workflow with 40
|
|
14
|
+
* branches has too many paths to walk, and because a gate only truly starves W
|
|
15
|
+
* if there is no alternative path around it. Dominance is exactly that property.
|
|
16
|
+
*/
|
|
17
|
+
import type { Finding, Workflow } from '../model.js';
|
|
18
|
+
/** Iterative dominator computation from a virtual entry over all triggers. */
|
|
19
|
+
export declare function computeDominators(wf: Workflow): Map<string, Set<string>>;
|
|
20
|
+
/**
|
|
21
|
+
* Is the empty case caught? A gate is "guarded" when something else leaves it —
|
|
22
|
+
* an else branch, an error route, an alert. Then somebody at least hears about
|
|
23
|
+
* the empty run. Unguarded means the silence is total.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isGuarded(wf: Workflow, gateId: string): boolean;
|
|
26
|
+
export declare function findAlertNodes(wf: Workflow): Set<string>;
|
|
27
|
+
export declare function checkZeroWrite(wf: Workflow): Finding[];
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CHECK 4 — write steps that can complete having written zero rows.
|
|
4
|
+
*
|
|
5
|
+
* The question this answers is NOT "did this workflow write nothing" — static
|
|
6
|
+
* JSON can never answer that. It answers "can this workflow finish green having
|
|
7
|
+
* written nothing, and would anyone notice". That is a blind-spot report, not a
|
|
8
|
+
* bug report, and the wording throughout keeps that distinction.
|
|
9
|
+
*
|
|
10
|
+
* Method: for every write node W, find the nodes that DOMINATE it — the steps
|
|
11
|
+
* that every path from a trigger to W must pass through. If any dominator can
|
|
12
|
+
* emit zero items, W is skipped entirely and the run still reports success.
|
|
13
|
+
*
|
|
14
|
+
* Dominators rather than path enumeration, because a 246-node workflow with 40
|
|
15
|
+
* branches has too many paths to walk, and because a gate only truly starves W
|
|
16
|
+
* if there is no alternative path around it. Dominance is exactly that property.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.computeDominators = computeDominators;
|
|
20
|
+
exports.isGuarded = isGuarded;
|
|
21
|
+
exports.findAlertNodes = findAlertNodes;
|
|
22
|
+
exports.checkZeroWrite = checkZeroWrite;
|
|
23
|
+
const model_js_1 = require("../model.js");
|
|
24
|
+
/** Iterative dominator computation from a virtual entry over all triggers. */
|
|
25
|
+
function computeDominators(wf) {
|
|
26
|
+
const ENTRY = '\u0000entry';
|
|
27
|
+
const ids = wf.nodes.filter((n) => !n.disabled).map((n) => n.id);
|
|
28
|
+
const idSet = new Set(ids);
|
|
29
|
+
const preds = new Map();
|
|
30
|
+
for (const id of ids)
|
|
31
|
+
preds.set(id, []);
|
|
32
|
+
preds.set(ENTRY, []);
|
|
33
|
+
for (const e of wf.edges) {
|
|
34
|
+
// Error branches are not the normal path; a node reached only via an error
|
|
35
|
+
// branch should not be treated as part of the happy path.
|
|
36
|
+
if (e.channel === 'error')
|
|
37
|
+
continue;
|
|
38
|
+
if (!idSet.has(e.from) || !idSet.has(e.to))
|
|
39
|
+
continue;
|
|
40
|
+
preds.get(e.to).push(e.from);
|
|
41
|
+
}
|
|
42
|
+
for (const t of wf.triggerIds) {
|
|
43
|
+
if (idSet.has(t))
|
|
44
|
+
preds.get(t).push(ENTRY);
|
|
45
|
+
}
|
|
46
|
+
// Nodes with no predecessor at all hang off the entry, otherwise they are
|
|
47
|
+
// unreachable and dominance is undefined for them.
|
|
48
|
+
for (const id of ids) {
|
|
49
|
+
if (preds.get(id).length === 0)
|
|
50
|
+
preds.get(id).push(ENTRY);
|
|
51
|
+
}
|
|
52
|
+
const all = new Set([ENTRY, ...ids]);
|
|
53
|
+
const dom = new Map();
|
|
54
|
+
dom.set(ENTRY, new Set([ENTRY]));
|
|
55
|
+
for (const id of ids)
|
|
56
|
+
dom.set(id, new Set(all));
|
|
57
|
+
let changed = true;
|
|
58
|
+
let guard = 0;
|
|
59
|
+
while (changed && guard++ < 200) {
|
|
60
|
+
changed = false;
|
|
61
|
+
for (const id of ids) {
|
|
62
|
+
const ps = preds.get(id);
|
|
63
|
+
let next = null;
|
|
64
|
+
for (const p of ps) {
|
|
65
|
+
const dp = dom.get(p);
|
|
66
|
+
if (!dp)
|
|
67
|
+
continue;
|
|
68
|
+
if (next === null) {
|
|
69
|
+
next = new Set(dp);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
for (const x of [...next])
|
|
73
|
+
if (!dp.has(x))
|
|
74
|
+
next.delete(x);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (next === null)
|
|
78
|
+
next = new Set();
|
|
79
|
+
next.add(id);
|
|
80
|
+
const cur = dom.get(id);
|
|
81
|
+
if (next.size !== cur.size || [...next].some((x) => !cur.has(x))) {
|
|
82
|
+
dom.set(id, next);
|
|
83
|
+
changed = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return dom;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Is the empty case caught? A gate is "guarded" when something else leaves it —
|
|
91
|
+
* an else branch, an error route, an alert. Then somebody at least hears about
|
|
92
|
+
* the empty run. Unguarded means the silence is total.
|
|
93
|
+
*/
|
|
94
|
+
function isGuarded(wf, gateId) {
|
|
95
|
+
const out = wf.edges.filter((e) => e.from === gateId);
|
|
96
|
+
const channels = new Set(out.map((e) => e.channel));
|
|
97
|
+
// More than one live output channel means the empty/false case goes somewhere.
|
|
98
|
+
if (channels.size > 1)
|
|
99
|
+
return true;
|
|
100
|
+
if (channels.has('error'))
|
|
101
|
+
return true;
|
|
102
|
+
// A downstream alert node directly off this gate also counts.
|
|
103
|
+
return out.some((e) => {
|
|
104
|
+
const n = wf.nodes.find((x) => x.id === e.to);
|
|
105
|
+
return n?.role === 'alert' || n?.role === 'error-handler';
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A message sent on a false/else/error branch is an alert, not a data write.
|
|
110
|
+
* Distinguishing these is what stops the check flagging every Slack node in
|
|
111
|
+
* every workflow — and it tells us whether the empty case reaches a human.
|
|
112
|
+
*/
|
|
113
|
+
const ALERT_CHANNELS = new Set(['false', 'else', 'error']);
|
|
114
|
+
const ALERT_TARGETS = /slack|telegram|discord|gmail|email|twilio|whatsapp|pushover/i;
|
|
115
|
+
function findAlertNodes(wf) {
|
|
116
|
+
const alerts = new Set();
|
|
117
|
+
for (const e of wf.edges) {
|
|
118
|
+
if (!ALERT_CHANNELS.has(e.channel))
|
|
119
|
+
continue;
|
|
120
|
+
const n = wf.nodes.find((x) => x.id === e.to);
|
|
121
|
+
if (!n)
|
|
122
|
+
continue;
|
|
123
|
+
if (n.writeKind === 'send' && ALERT_TARGETS.test(n.platformType + ' ' + (n.writeTarget ?? ''))) {
|
|
124
|
+
alerts.add(n.id);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const n of wf.nodes) {
|
|
128
|
+
if (n.role === 'error-handler')
|
|
129
|
+
alerts.add(n.id);
|
|
130
|
+
}
|
|
131
|
+
return alerts;
|
|
132
|
+
}
|
|
133
|
+
function checkZeroWrite(wf) {
|
|
134
|
+
const findings = [];
|
|
135
|
+
const byId = new Map(wf.nodes.map((n) => [n.id, n]));
|
|
136
|
+
const alertIds = findAlertNodes(wf);
|
|
137
|
+
// Alerts are not data writes. A skipped alert is a real problem but a
|
|
138
|
+
// different one, and lumping them together is what makes these tools noisy.
|
|
139
|
+
const writes = wf.nodes.filter((n) => n.role === 'write' && !n.disabled && !alertIds.has(n.id));
|
|
140
|
+
if (writes.length === 0)
|
|
141
|
+
return findings;
|
|
142
|
+
/**
|
|
143
|
+
* Could anything in this workflow notice an empty run? Either somebody gets
|
|
144
|
+
* told, or there is a known rhythm to measure against. If neither, silence is
|
|
145
|
+
* total — and that is the only case worth shouting about.
|
|
146
|
+
*/
|
|
147
|
+
const hasSafetyNet = alertIds.size > 0 ||
|
|
148
|
+
wf.cadence.expectedIntervalKnown ||
|
|
149
|
+
wf.errorPolicy.workflowLevelHandler;
|
|
150
|
+
const dom = computeDominators(wf);
|
|
151
|
+
const soleWrite = writes.length === 1;
|
|
152
|
+
for (const w of writes) {
|
|
153
|
+
const dominators = dom.get(w.id) ?? new Set();
|
|
154
|
+
const starvers = [];
|
|
155
|
+
for (const dId of dominators) {
|
|
156
|
+
if (dId === w.id)
|
|
157
|
+
continue;
|
|
158
|
+
const d = byId.get(dId);
|
|
159
|
+
if (!d || !d.zeroEmit)
|
|
160
|
+
continue;
|
|
161
|
+
starvers.push({ node: d, guarded: isGuarded(wf, dId) });
|
|
162
|
+
}
|
|
163
|
+
// A gate sitting on an edge (Make puts filters on links, not in modules).
|
|
164
|
+
const gateEdges = wf.edges.filter((e) => e.gate && dominators.has(e.from) && dominators.has(e.to));
|
|
165
|
+
if (starvers.length === 0 && gateEdges.length === 0) {
|
|
166
|
+
// Nothing upstream can starve it. Still worth one quiet note if the write
|
|
167
|
+
// is set to swallow its own failures.
|
|
168
|
+
if (w.errorHandling.continueOnFail) {
|
|
169
|
+
findings.push({
|
|
170
|
+
checkId: 'zero-write',
|
|
171
|
+
severity: 'medium',
|
|
172
|
+
nodeId: w.id,
|
|
173
|
+
nodeLabel: w.label,
|
|
174
|
+
title: `"${w.label}" is set to carry on when it fails`,
|
|
175
|
+
ifItGoesQuiet: `The write fails, the run continues to the end, and the execution is recorded as a success. Nothing writes and nothing is flagged.`,
|
|
176
|
+
detail: 'Continue-on-fail is the right setting when a later step handles the failure. Nothing downstream of this node looks like it does.',
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const unguarded = starvers.filter((s) => !s.guarded);
|
|
182
|
+
const structural = unguarded.filter((s) => s.node.zeroEmit.certainty === 'structural');
|
|
183
|
+
const worst = structural[0] ?? unguarded[0] ?? starvers[0];
|
|
184
|
+
if (!worst)
|
|
185
|
+
continue;
|
|
186
|
+
let severity;
|
|
187
|
+
if (structural.length > 0 && !hasSafetyNet) {
|
|
188
|
+
// Nothing here could tell you it happened.
|
|
189
|
+
severity = soleWrite ? 'critical' : 'high';
|
|
190
|
+
}
|
|
191
|
+
else if (structural.length > 0) {
|
|
192
|
+
// Real, but something in the workflow would surface it.
|
|
193
|
+
severity = soleWrite ? 'medium' : 'low';
|
|
194
|
+
}
|
|
195
|
+
else if (unguarded.length > 0) {
|
|
196
|
+
severity = hasSafetyNet ? 'low' : 'medium';
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
severity = 'low';
|
|
200
|
+
}
|
|
201
|
+
const chainNames = [...new Set([...structural, ...unguarded, ...starvers]
|
|
202
|
+
.slice(0, 3)
|
|
203
|
+
.map((s) => `"${s.node.label}"`))].join(' -> ');
|
|
204
|
+
const target = w.writeTarget ? ` to ${w.writeTarget}` : '';
|
|
205
|
+
findings.push({
|
|
206
|
+
checkId: 'zero-write',
|
|
207
|
+
severity,
|
|
208
|
+
nodeId: w.id,
|
|
209
|
+
nodeLabel: w.label,
|
|
210
|
+
title: `"${w.label}" can be skipped entirely and the run still finishes green`,
|
|
211
|
+
ifItGoesQuiet: `${worst.node.zeroEmit.cause} Every path to "${w.label}" goes through it, so nothing is written${target}, ` +
|
|
212
|
+
`every step shows as successful, and the execution list looks exactly like a normal day.` +
|
|
213
|
+
(soleWrite ? ' This is the only write in the workflow, so the run does nothing at all.' : ''),
|
|
214
|
+
detail: `Chain: ${chainNames} -> "${w.label}". ` +
|
|
215
|
+
(worst.guarded
|
|
216
|
+
? 'There is another branch off that step, so the empty case does reach something.'
|
|
217
|
+
: 'Nothing else leaves that step, so the empty case reaches nobody.') +
|
|
218
|
+
(worst.node.errorHandling.alwaysOutputData
|
|
219
|
+
? ' Note: that step has "always output data" switched on, so it pushes an empty item through rather than stopping — the write may run and write a blank row instead of nothing at all.'
|
|
220
|
+
: ''),
|
|
221
|
+
howToCheck: (hasSafetyNet
|
|
222
|
+
? 'Something in this workflow would surface an empty run, so this is worth knowing rather than worth panicking about. '
|
|
223
|
+
: 'Nothing in this workflow would surface an empty run: no alert branch, no expected rhythm, no error handler. ') +
|
|
224
|
+
'Static analysis can only tell you this is possible. Whether it is happening needs run history: compare items written per run against the same run a week ago.',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
// A 246-node workflow can generate forty of these. Three is a report; forty
|
|
228
|
+
// is wallpaper that gets closed.
|
|
229
|
+
findings.sort((a, b) => model_js_1.SEVERITY_ORDER[a.severity] - model_js_1.SEVERITY_ORDER[b.severity]);
|
|
230
|
+
if (findings.length > 3) {
|
|
231
|
+
const rest = findings.length - 3;
|
|
232
|
+
const top = findings.slice(0, 3);
|
|
233
|
+
top.push({
|
|
234
|
+
checkId: 'zero-write',
|
|
235
|
+
severity: 'info',
|
|
236
|
+
nodeId: null,
|
|
237
|
+
nodeLabel: null,
|
|
238
|
+
title: `${rest} more write step${rest === 1 ? '' : 's'} with the same pattern`,
|
|
239
|
+
ifItGoesQuiet: 'Same story as above, further down the workflow.',
|
|
240
|
+
detail: 'Showing the three that matter most. The rest follow the same shape.',
|
|
241
|
+
});
|
|
242
|
+
return top;
|
|
243
|
+
}
|
|
244
|
+
return findings;
|
|
245
|
+
}
|