@bongos/core 1.19.899 → 1.19.901
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/.bongos-core.json +48 -18
- package/docs/copy-inventory.md +8 -7
- package/docs/copy-registry.json +17 -8
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/profile.js +51 -5
- package/modules/ideas/descent.js +285 -0
- package/modules/ideas/migrations/ideas_007_task_descent_edges.sql +111 -0
- package/modules/ideas/module.json +4 -1
- package/modules/ideas/pollers/descent-writer.js +69 -0
- package/modules/ideas/routes/inbox.js +10 -0
- package/modules/ideas/trail-descent.js +224 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/helpers.mjs +137 -0
- package/tests/idea_task_descent.mjs +347 -0
- package/tests/idea_trail_descent.mjs +399 -0
- package/tests/profile_activity.mjs +4 -122
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// tests/idea_task_descent.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004086 (BV2.R07, goal 1000095) — task→task descent edges. ADR 0312.
|
|
4
|
+
//
|
|
5
|
+
// WHAT IS ASSERTED HERE, AND WHY IT IS DB-FREE. The parts of this feature that
|
|
6
|
+
// can go wrong silently are JavaScript, not SQL: the cycle refusal, the
|
|
7
|
+
// breadth-first de-duplication, and the derivation that turns three existing
|
|
8
|
+
// signals into edges. A cycle does not render oddly — the payout (R09) and the
|
|
9
|
+
// descent (R10) both walk this graph, so a cycle fails to terminate. A merge
|
|
10
|
+
// makes the graph a DAG rather than a tree, so a task reachable by two paths
|
|
11
|
+
// must be counted ONCE at its SHORTEST depth, or the same origin is paid twice
|
|
12
|
+
// for one descendant.
|
|
13
|
+
//
|
|
14
|
+
// The client is injected (every reader and writer takes { client }), so those
|
|
15
|
+
// paths run for real against a recorded query log rather than being simulated.
|
|
16
|
+
// A real-Postgres suite would SELF-SKIP on this machine, and a skipped test
|
|
17
|
+
// prints ok and counts as passed — which is how a green lane hides what never
|
|
18
|
+
// ran. The DDL half is therefore asserted as a SOURCE invariant instead.
|
|
19
|
+
//
|
|
20
|
+
// Run: node tests/idea_task_descent.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const descent = require('../modules/ideas/descent.js');
|
|
31
|
+
|
|
32
|
+
const MIGRATION = path.join(ROOT, 'modules/ideas/migrations/ideas_007_task_descent_edges.sql');
|
|
33
|
+
const DDL = fs.readFileSync(MIGRATION, 'utf8');
|
|
34
|
+
const MANIFEST = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/ideas/module.json'), 'utf8'));
|
|
35
|
+
const WRITER_SRC = fs.readFileSync(path.join(ROOT, 'modules/ideas/pollers/descent-writer.js'), 'utf8');
|
|
36
|
+
|
|
37
|
+
let passed = 0;
|
|
38
|
+
let failed = 0;
|
|
39
|
+
function test(name, fn) {
|
|
40
|
+
return Promise.resolve()
|
|
41
|
+
.then(fn)
|
|
42
|
+
.then(() => { passed += 1; console.log('ok ' + name); })
|
|
43
|
+
.catch((e) => { failed += 1; console.log('FAIL ' + name + '\n ' + (e && (e.message || JSON.stringify(e)))); });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A client that answers each query from a queue, recording what it was asked.
|
|
47
|
+
function fakeClient(queue) {
|
|
48
|
+
const log = [];
|
|
49
|
+
return {
|
|
50
|
+
log,
|
|
51
|
+
query: async (text, params) => { log.push({ text, params }); return { rows: queue.shift() ?? [] }; },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A client that answers the BFS from a real adjacency map, so the walk is
|
|
56
|
+
// exercised as a walk rather than against canned rows.
|
|
57
|
+
function graphClient(edges) {
|
|
58
|
+
// edges: [{ task_id, parent_task_id, relation }]
|
|
59
|
+
const log = [];
|
|
60
|
+
return {
|
|
61
|
+
log,
|
|
62
|
+
query: async (text, params) => {
|
|
63
|
+
log.push({ text, params });
|
|
64
|
+
if (!Array.isArray(params[0])) return { rows: [] }; // the INSERT, not a walk
|
|
65
|
+
const frontier = params[0].map(Number);
|
|
66
|
+
const up = /parent_task_id AS id/.test(text);
|
|
67
|
+
const rows = edges
|
|
68
|
+
.filter((e) => frontier.includes(Number(up ? e.task_id : e.parent_task_id)))
|
|
69
|
+
// from_id is what the real statements select, and what lets one query
|
|
70
|
+
// answer several roots at once — the fake must carry it or the batched
|
|
71
|
+
// walk would be exercised against a shape production never returns.
|
|
72
|
+
.map((e) => ({
|
|
73
|
+
id: up ? e.parent_task_id : e.task_id,
|
|
74
|
+
from_id: up ? e.task_id : e.parent_task_id,
|
|
75
|
+
relation: e.relation,
|
|
76
|
+
}));
|
|
77
|
+
return { rows };
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── the migration ───────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
await test('the migration declares the COMPOSITE key — one edge per (task, parent) pair', () => {
|
|
85
|
+
assert.match(DDL, /PRIMARY KEY \(task_id, parent_task_id\)/,
|
|
86
|
+
'the key must be the pair: a merge gives a task several parents');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await test('the migration refuses a SELF-EDGE in the schema', () => {
|
|
90
|
+
assert.match(DDL, /CHECK \(task_id <> parent_task_id\)/);
|
|
91
|
+
assert.match(DDL, /ideas_task_descent_no_self_edge/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await test('the migration indexes the PARENT direction — the descent walk', () => {
|
|
95
|
+
assert.match(DDL, /CREATE INDEX IF NOT EXISTS idx_ideas_task_descent_parent/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await test('the migration is additive and namespaced (ADR 0083 §5)', () => {
|
|
99
|
+
assert.doesNotMatch(DDL, /DROP TABLE|TRUNCATE|DROP COLUMN/i,
|
|
100
|
+
'a module migration may not carry destructive DDL');
|
|
101
|
+
for (const m of DDL.matchAll(/CREATE (?:TABLE|INDEX)(?: IF NOT EXISTS)? (\w+)/g)) {
|
|
102
|
+
assert.ok(m[1].startsWith('ideas_') || m[1].startsWith('idx_ideas_'),
|
|
103
|
+
`object ${m[1]} is outside the ideas_ namespace`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await test('the migration proves its own effect — the safety net is present', () => {
|
|
108
|
+
assert.match(DDL, /RAISE EXCEPTION/,
|
|
109
|
+
'the ideas_003/005/006 precedent: a migration that cannot prove its effect must not report success');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── the writer ──────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
await test('a SELF-EDGE is refused before any query is issued', async () => {
|
|
115
|
+
const c = fakeClient([]);
|
|
116
|
+
await assert.rejects(
|
|
117
|
+
() => descent.linkTaskDescent(7, 7, 'rewrite', { client: c }),
|
|
118
|
+
(e) => e.code === 'DESCENT_SELF_EDGE'
|
|
119
|
+
);
|
|
120
|
+
assert.equal(c.log.length, 0, 'the self case must not reach the database at all');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await test('a LONGER CYCLE is refused — the walk would not terminate', async () => {
|
|
124
|
+
// 9 already descends from 5 (5 → 9), so making 5 descend from 9 closes a loop.
|
|
125
|
+
const c = graphClient([{ task_id: 9, parent_task_id: 5, relation: 'split' }]);
|
|
126
|
+
await assert.rejects(
|
|
127
|
+
() => descent.linkTaskDescent(5, 9, 'merge', { client: c }),
|
|
128
|
+
(e) => e.code === 'DESCENT_CYCLE'
|
|
129
|
+
);
|
|
130
|
+
assert.ok(
|
|
131
|
+
c.log.every((q) => !/INSERT/i.test(q.text)),
|
|
132
|
+
'the cycle must be refused BEFORE the row is written'
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await test('a cycle DEEPER than the default bound is still refused', async () => {
|
|
137
|
+
// A chain 5 → 6 → … → 80, far past DESCENT_MAX_DEPTH (64). The closing edge
|
|
138
|
+
// is the one that matters, because it is the one that would get written.
|
|
139
|
+
const chain = [];
|
|
140
|
+
for (let i = 5; i < 80; i += 1) chain.push({ task_id: i + 1, parent_task_id: i, relation: 'split' });
|
|
141
|
+
const c = graphClient(chain);
|
|
142
|
+
await assert.rejects(
|
|
143
|
+
() => descent.linkTaskDescent(5, 80, 'merge', { client: c }),
|
|
144
|
+
(e) => e.code === 'DESCENT_CYCLE'
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('an unknown relation is refused by NAME, not by a constraint violation', async () => {
|
|
149
|
+
const c = fakeClient([]);
|
|
150
|
+
await assert.rejects(
|
|
151
|
+
() => descent.linkTaskDescent(5, 9, 'inspired-by', { client: c }),
|
|
152
|
+
(e) => e.code === 'DESCENT_BAD_RELATION' && e.allowed.includes('cascade')
|
|
153
|
+
);
|
|
154
|
+
assert.equal(c.log.length, 0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await test('a legitimate edge IS written, and upserts on the pair', async () => {
|
|
158
|
+
const c = graphClient([]);
|
|
159
|
+
await descent.linkTaskDescent(5, 9, 'split', { client: c });
|
|
160
|
+
const ins = c.log.find((q) => /INSERT INTO ideas_task_descent/i.test(q.text));
|
|
161
|
+
assert.ok(ins, 'the edge must be inserted');
|
|
162
|
+
assert.match(ins.text, /ON CONFLICT \(task_id, parent_task_id\) DO UPDATE/,
|
|
163
|
+
'the derivation runs on every ship, so the write must be idempotent');
|
|
164
|
+
assert.deepEqual(ins.params, [5, 9, 'split']);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await test('a non-numeric id is refused rather than passed to SQL', async () => {
|
|
168
|
+
const c = fakeClient([]);
|
|
169
|
+
await assert.rejects(
|
|
170
|
+
() => descent.linkTaskDescent('abc', 9, 'split', { client: c }),
|
|
171
|
+
(e) => e.code === 'DESCENT_BAD_ID'
|
|
172
|
+
);
|
|
173
|
+
assert.equal(c.log.length, 0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── the walk ────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
await test('a task reachable TWICE is returned ONCE, at its SHORTEST depth', async () => {
|
|
179
|
+
// The DAG a merge produces: 1 → 2 → 4 and 1 → 3 → 4, plus the short edge
|
|
180
|
+
// 1 → 4. Node 4 is reachable at depth 1 and depth 2; the payout must decay by
|
|
181
|
+
// the shorter one, and must not pay twice.
|
|
182
|
+
const c = graphClient([
|
|
183
|
+
{ task_id: 2, parent_task_id: 1, relation: 'split' },
|
|
184
|
+
{ task_id: 3, parent_task_id: 1, relation: 'split' },
|
|
185
|
+
{ task_id: 4, parent_task_id: 2, relation: 'merge' },
|
|
186
|
+
{ task_id: 4, parent_task_id: 3, relation: 'merge' },
|
|
187
|
+
{ task_id: 4, parent_task_id: 1, relation: 'cascade' },
|
|
188
|
+
]);
|
|
189
|
+
const got = await descent.descendantsOfTask(1, { client: c });
|
|
190
|
+
const ids = got.map((r) => Number(r.id));
|
|
191
|
+
assert.deepEqual(ids, [2, 3, 4], 'each node once, nearest first');
|
|
192
|
+
assert.equal(got.find((r) => Number(r.id) === 4).depth, 1, 'shortest path wins');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await test('a node is EXPANDED at most once — the convergent DAG does not blow up', async () => {
|
|
196
|
+
// Same shape, one level deeper. A recursive CTE re-expands every path; this
|
|
197
|
+
// asserts the walk does not, by counting the queries it issues: one per
|
|
198
|
+
// LEVEL, never one per path.
|
|
199
|
+
const c = graphClient([
|
|
200
|
+
{ task_id: 2, parent_task_id: 1, relation: 'split' },
|
|
201
|
+
{ task_id: 3, parent_task_id: 1, relation: 'split' },
|
|
202
|
+
{ task_id: 4, parent_task_id: 2, relation: 'merge' },
|
|
203
|
+
{ task_id: 4, parent_task_id: 3, relation: 'merge' },
|
|
204
|
+
{ task_id: 5, parent_task_id: 4, relation: 'split' },
|
|
205
|
+
]);
|
|
206
|
+
const got = await descent.descendantsOfTask(1, { client: c });
|
|
207
|
+
assert.deepEqual(got.map((r) => Number(r.id)), [2, 3, 4, 5]);
|
|
208
|
+
// One query per LEVEL — {1}, {2,3}, {4}, {5} — with the last returning
|
|
209
|
+
// nothing and ending the walk. Four queries for a graph carrying several
|
|
210
|
+
// distinct paths through node 4: the count tracks DEPTH, not the number of
|
|
211
|
+
// paths, which is exactly what a recursive CTE cannot promise.
|
|
212
|
+
assert.equal(c.log.length, 4, `one query per level, got ${c.log.length}`);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await test('a CYCLE in the stored data cannot hang the walk', async () => {
|
|
216
|
+
const c = graphClient([
|
|
217
|
+
{ task_id: 2, parent_task_id: 1, relation: 'split' },
|
|
218
|
+
{ task_id: 1, parent_task_id: 2, relation: 'merge' }, // the loop
|
|
219
|
+
]);
|
|
220
|
+
const got = await descent.descendantsOfTask(1, { client: c });
|
|
221
|
+
assert.deepEqual(got.map((r) => Number(r.id)), [2], 'the start is never revisited');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await test('both directions walk the same way', async () => {
|
|
225
|
+
const c = graphClient([
|
|
226
|
+
{ task_id: 9, parent_task_id: 5, relation: 'split' },
|
|
227
|
+
{ task_id: 12, parent_task_id: 9, relation: 'rewrite' },
|
|
228
|
+
]);
|
|
229
|
+
const up = await descent.ancestorsOfTask(12, { client: c });
|
|
230
|
+
assert.deepEqual(up.map((r) => Number(r.id)), [9, 5]);
|
|
231
|
+
assert.deepEqual(up.map((r) => r.depth), [1, 2], 'depth is the hop count the payout decays by');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ── the derivation ──────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
await test('a CASCADE task derives its edge from the id inside source_ref', async () => {
|
|
237
|
+
// The key itself contains dashes, so the id is the trailing numeric run.
|
|
238
|
+
const c = fakeClient([[{
|
|
239
|
+
id: 900, source: 'cascade', source_ref: 'cascade-artist-review-on-ship-1003583',
|
|
240
|
+
parent_task_id: null, folded_in: null,
|
|
241
|
+
}]]);
|
|
242
|
+
const edges = await descent.deriveEdgesForTask(900, { client: c });
|
|
243
|
+
assert.deepEqual(edges, [{ parent: 1003583, relation: 'cascade' }]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await test('a SPLIT derives from parent_task_id, and a FOLD from merged_into', async () => {
|
|
247
|
+
const c = fakeClient([[{
|
|
248
|
+
id: 900, source: 'manual', source_ref: null,
|
|
249
|
+
parent_task_id: 500, folded_in: [401, 402],
|
|
250
|
+
}]]);
|
|
251
|
+
const edges = await descent.deriveEdgesForTask(900, { client: c });
|
|
252
|
+
assert.deepEqual(edges, [
|
|
253
|
+
{ parent: 500, relation: 'split' },
|
|
254
|
+
{ parent: 401, relation: 'fold' },
|
|
255
|
+
{ parent: 402, relation: 'fold' },
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await test('a task with no derivation signal produces NO edges', async () => {
|
|
260
|
+
const c = fakeClient([[{
|
|
261
|
+
id: 900, source: 'manual', source_ref: null, parent_task_id: null, folded_in: null,
|
|
262
|
+
}]]);
|
|
263
|
+
assert.deepEqual(await descent.deriveEdgesForTask(900, { client: c }), []);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await test('a source_ref that is not a cascade id yields nothing, not a NaN edge', async () => {
|
|
267
|
+
const c = fakeClient([[{
|
|
268
|
+
id: 900, source: 'cascade', source_ref: 'cascade-some-key-with-no-id',
|
|
269
|
+
parent_task_id: null, folded_in: null,
|
|
270
|
+
}]]);
|
|
271
|
+
assert.deepEqual(await descent.deriveEdgesForTask(900, { client: c }), []);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await test('a self-referential signal is filtered out before it is written', async () => {
|
|
275
|
+
const c = fakeClient([[{
|
|
276
|
+
id: 900, source: 'cascade', source_ref: 'cascade-k-900',
|
|
277
|
+
parent_task_id: 900, folded_in: null,
|
|
278
|
+
}]]);
|
|
279
|
+
assert.deepEqual(await descent.deriveEdgesForTask(900, { client: c }), []);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await test('syncDescentForTask REPORTS a refusal instead of throwing', async () => {
|
|
283
|
+
// A ship must never break because a provenance edge was refused.
|
|
284
|
+
const c = {
|
|
285
|
+
log: [],
|
|
286
|
+
query: async (text, params) => {
|
|
287
|
+
c.log.push({ text, params });
|
|
288
|
+
if (/FROM tasks t/.test(text)) {
|
|
289
|
+
return { rows: [{ id: 5, source: 'manual', source_ref: null, parent_task_id: 9, folded_in: null }] };
|
|
290
|
+
}
|
|
291
|
+
// The cycle probe: 9 already descends from 5.
|
|
292
|
+
if (/parent_task_id = ANY/.test(text)) return { rows: [{ id: 9, from_id: 5, relation: 'split' }] };
|
|
293
|
+
return { rows: [] };
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
const res = await descent.syncDescentForTask(5, { client: c });
|
|
297
|
+
assert.equal(res.written.length, 0);
|
|
298
|
+
assert.equal(res.skipped.length, 1);
|
|
299
|
+
assert.equal(res.skipped[0].code, 'DESCENT_CYCLE');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ── the production call path ────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
await test('the module DECLARES the poller, or nothing ever runs it', () => {
|
|
305
|
+
assert.ok(MANIFEST.contributes.pollers?.includes('descent-writer'),
|
|
306
|
+
'a poller file with no manifest entry is a silent no-op (ADR 0289)');
|
|
307
|
+
assert.ok(fs.existsSync(path.join(ROOT, 'modules/ideas/pollers/descent-writer.js')),
|
|
308
|
+
'and a declared seam whose artifact is absent is the same no-op in reverse');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
await test('the writer subscribes to task.shipped and cannot double-subscribe', () => {
|
|
312
|
+
assert.match(WRITER_SRC, /api\.on\('task\.shipped'|EVENT = 'task\.shipped'/);
|
|
313
|
+
assert.match(WRITER_SRC, /if \(_off\) return;/,
|
|
314
|
+
'a reloaded loader must not accumulate listeners and derive every ship twice');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await test('the writer uses APIs the doorway actually publishes', () => {
|
|
318
|
+
// The first cut guessed `api.log.child(...)`, which does not exist on
|
|
319
|
+
// src/module-api.js. The optional-chaining fallback meant it silently
|
|
320
|
+
// degraded to `console` instead of failing, so nothing caught it — the
|
|
321
|
+
// shape of bug a static read finds and a green suite does not.
|
|
322
|
+
const doorway = require(path.join(ROOT, "src/module-api.js"));
|
|
323
|
+
for (const m of WRITER_SRC.matchAll(/\bapi\.([A-Za-z_$][\w$]*)/g)) {
|
|
324
|
+
assert.ok(m[1] in doorway,
|
|
325
|
+
`the writer calls api.${m[1]}, which the published doorway does not export`);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await test('the writer reads the task id the way the event actually carries it', () => {
|
|
330
|
+
// task.shipped hands the subscriber the TASK ROW — the same subject
|
|
331
|
+
// modules/agents/pollers/event-router.js receives as `task`. Guarding
|
|
332
|
+
// against payload shapes that never occur reads as uncertainty about the
|
|
333
|
+
// contract.
|
|
334
|
+
assert.doesNotMatch(WRITER_SRC, /payload\.task_id|payload\.task\b/,
|
|
335
|
+
"speculative payload shapes: the event carries the task row");
|
|
336
|
+
});
|
|
337
|
+
await test('the handler swallows its own failure — a reactor must not break the actor', async () => {
|
|
338
|
+
const writer = require('../modules/ideas/pollers/descent-writer.js');
|
|
339
|
+
// No client injected, so syncDescentForTask reaches the real pool and throws.
|
|
340
|
+
// The handler must still resolve.
|
|
341
|
+
await writer.onShipped({ id: 999999999 });
|
|
342
|
+
await writer.onShipped({}); // no id at all
|
|
343
|
+
await writer.onShipped(null);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
347
|
+
process.exit(failed === 0 ? 0 : 1);
|