agentic-workflow-manager 3.3.1 → 3.5.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/dist/src/commands/job/exec-wrapper.js +136 -0
- package/dist/src/commands/job/export.js +94 -0
- package/dist/src/commands/job/gate.js +118 -0
- package/dist/src/commands/job/heartbeat.js +15 -0
- package/dist/src/commands/job/index.js +246 -0
- package/dist/src/commands/job/query.js +37 -0
- package/dist/src/commands/job/reap.js +24 -0
- package/dist/src/commands/job/reconcile.js +112 -0
- package/dist/src/commands/job/request.js +27 -0
- package/dist/src/commands/ledger/index.js +1 -1
- package/dist/src/commands/watch/apply.js +352 -0
- package/dist/src/commands/watch/generations.js +249 -0
- package/dist/src/commands/watch/index.js +49 -0
- package/dist/src/commands/watch/init.js +72 -0
- package/dist/src/commands/watch/lock.js +89 -0
- package/dist/src/commands/watch/runner.js +191 -0
- package/dist/src/commands/watch/supervisor.js +266 -0
- package/dist/src/core/atomic-file.js +31 -0
- package/dist/src/core/export/pack.js +7 -1
- package/dist/src/core/export/transform.js +51 -1
- package/dist/src/core/journal/adapter.js +27 -0
- package/dist/src/core/journal/fingerprint.js +80 -0
- package/dist/src/core/journal/paths.js +56 -0
- package/dist/src/core/journal/process.js +284 -0
- package/dist/src/core/journal/redact.js +142 -0
- package/dist/src/core/journal/requests.js +132 -0
- package/dist/src/core/journal/store.js +107 -0
- package/dist/src/core/journal/types.js +165 -0
- package/dist/src/core/ledger/cluster.js +138 -0
- package/dist/src/core/ledger/store.js +20 -11
- package/dist/src/index.js +4 -0
- package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
- package/dist/tests/commands/job/export.test.js +76 -0
- package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
- package/dist/tests/commands/job/reap-cli.test.js +101 -0
- package/dist/tests/commands/job/verbs.test.js +56 -0
- package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
- package/dist/tests/commands/watch/apply.test.js +397 -0
- package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
- package/dist/tests/commands/watch/generations.test.js +115 -0
- package/dist/tests/commands/watch/integration.test.js +124 -0
- package/dist/tests/commands/watch/lock.test.js +60 -0
- package/dist/tests/commands/watch/runner.test.js +239 -0
- package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
- package/dist/tests/commands/watch/watch-init.test.js +43 -0
- package/dist/tests/core/atomic-file-durable.test.js +42 -0
- package/dist/tests/core/export/engine.test.js +7 -2
- package/dist/tests/core/export/transform.test.js +77 -0
- package/dist/tests/core/journal/adapter.test.js +27 -0
- package/dist/tests/core/journal/fingerprint.test.js +164 -0
- package/dist/tests/core/journal/paths.test.js +35 -0
- package/dist/tests/core/journal/process.test.js +213 -0
- package/dist/tests/core/journal/redact.test.js +59 -0
- package/dist/tests/core/journal/requests.test.js +134 -0
- package/dist/tests/core/journal/store.test.js +88 -0
- package/dist/tests/core/journal/types.test.js +78 -0
- package/dist/tests/core/ledger/cluster.test.js +240 -0
- package/dist/tests/core/ledger/store.test.js +42 -7
- package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
- package/package.json +1 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const cluster_1 = require("../../../src/core/ledger/cluster");
|
|
4
|
+
describe('normalizeTokens', () => {
|
|
5
|
+
test('lowercases and splits slugs on non-alphanumeric boundaries', () => {
|
|
6
|
+
expect([...(0, cluster_1.normalizeTokens)('Validator-Skips_Agents.References')].sort())
|
|
7
|
+
.toEqual(['agents', 'references', 'skips', 'validator']);
|
|
8
|
+
});
|
|
9
|
+
test('drops tokens shorter than two characters', () => {
|
|
10
|
+
expect([...(0, cluster_1.normalizeTokens)('a b ok x')].sort()).toEqual(['ok']);
|
|
11
|
+
});
|
|
12
|
+
test('drops stopwords so they cannot inflate affinity', () => {
|
|
13
|
+
expect([...(0, cluster_1.normalizeTokens)('the gate walks only the skills')].sort())
|
|
14
|
+
.toEqual(['gate', 'skills', 'walks']);
|
|
15
|
+
});
|
|
16
|
+
test('merges tokens across every text it is given', () => {
|
|
17
|
+
expect([...(0, cluster_1.normalizeTokens)('gate-walks', 'skills dir')].sort())
|
|
18
|
+
.toEqual(['dir', 'gate', 'skills', 'walks']);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('affinity', () => {
|
|
22
|
+
test('is the overlap coefficient, so a contained short set scores 1', () => {
|
|
23
|
+
const short = (0, cluster_1.normalizeTokens)('validator gate');
|
|
24
|
+
const long = (0, cluster_1.normalizeTokens)('validator gate walks the skills directory only');
|
|
25
|
+
expect((0, cluster_1.affinity)(short, long)).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
test('is 0 for disjoint sets', () => {
|
|
28
|
+
expect((0, cluster_1.affinity)((0, cluster_1.normalizeTokens)('alpha slug'), (0, cluster_1.normalizeTokens)('beta timeout'))).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
test('is 0 when either side is empty', () => {
|
|
31
|
+
expect((0, cluster_1.affinity)(new Set(), (0, cluster_1.normalizeTokens)('alpha'))).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('normalizeRef', () => {
|
|
35
|
+
test('strips the line number and keeps the file locus', () => {
|
|
36
|
+
expect((0, cluster_1.normalizeRef)('scripts/validate-portability.mjs:41')).toBe('scripts/validate-portability.mjs');
|
|
37
|
+
});
|
|
38
|
+
test('accepts a bare filename with an extension', () => {
|
|
39
|
+
expect((0, cluster_1.normalizeRef)('split.ts:12')).toBe('split.ts');
|
|
40
|
+
});
|
|
41
|
+
test('rejects a non-file ref: a whole PR is not a defect locus', () => {
|
|
42
|
+
expect((0, cluster_1.normalizeRef)('PR #16')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
test('rejects a URL, whose pre-colon portion carries no locus', () => {
|
|
45
|
+
expect((0, cluster_1.normalizeRef)('https://github.com/Kodria/agentic-workflow/pull/15')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
test('returns null for a missing ref', () => {
|
|
48
|
+
expect((0, cluster_1.normalizeRef)(undefined)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
function entry(over = {}) {
|
|
52
|
+
return {
|
|
53
|
+
ts: '2026-07-25T00:00:00.000Z',
|
|
54
|
+
branch: 'feat-x',
|
|
55
|
+
phase: 'post-qa',
|
|
56
|
+
source_skill: 'post-implementation-qa',
|
|
57
|
+
polarity: 'finding',
|
|
58
|
+
class: 'logica',
|
|
59
|
+
signature: 'some-finding',
|
|
60
|
+
severity: 'important',
|
|
61
|
+
desc: 'something is wrong',
|
|
62
|
+
ref: 'src/some.ts:1',
|
|
63
|
+
...over,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
describe('clusterEntries — exact signature floor', () => {
|
|
67
|
+
test('groups identical signatures and honours min', () => {
|
|
68
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
69
|
+
entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }),
|
|
70
|
+
entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }),
|
|
71
|
+
entry({ signature: 'solo', desc: 'beta timeout on retry', ref: 'src/b.ts:9' }),
|
|
72
|
+
], 2);
|
|
73
|
+
expect(clusters).toHaveLength(1);
|
|
74
|
+
expect(clusters[0]).toMatchObject({ signature: 'dup', count: 2, kind: 'exact' });
|
|
75
|
+
});
|
|
76
|
+
test('unions identical signatures even across unrelated files and descriptions', () => {
|
|
77
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
78
|
+
entry({ signature: 'same-slug', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }),
|
|
79
|
+
entry({ signature: 'same-slug', desc: 'beta timeout on retry', ref: 'src/b.ts:9' }),
|
|
80
|
+
], 2);
|
|
81
|
+
expect(clusters).toHaveLength(1);
|
|
82
|
+
expect(clusters[0].count).toBe(2);
|
|
83
|
+
});
|
|
84
|
+
test('unions identical signatures regardless of polarity, as before', () => {
|
|
85
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
86
|
+
entry({ signature: 'same-slug', polarity: 'finding' }),
|
|
87
|
+
entry({ signature: 'same-slug', polarity: 'win' }),
|
|
88
|
+
], 2);
|
|
89
|
+
expect(clusters).toHaveLength(1);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('clusterEntries — convergence on a shared file', () => {
|
|
93
|
+
// El caso real del 2026-07-25: tres lentes aisladas, tres slugs distintos,
|
|
94
|
+
// un solo defecto (el gate de portabilidad recorría solo skills/).
|
|
95
|
+
const threeLenses = () => [
|
|
96
|
+
entry({
|
|
97
|
+
signature: 'validator-skips-agents-references',
|
|
98
|
+
desc: 'the portability validator never walks agents/ references',
|
|
99
|
+
ref: 'scripts/validate-portability.mjs:41',
|
|
100
|
+
source_skill: 'fidelity-lens',
|
|
101
|
+
}),
|
|
102
|
+
entry({
|
|
103
|
+
signature: 'validator-scope-skills-only',
|
|
104
|
+
desc: 'validator scope covers skills only, missing sibling trees',
|
|
105
|
+
ref: 'scripts/validate-portability.mjs:41',
|
|
106
|
+
source_skill: 'logic-lens',
|
|
107
|
+
}),
|
|
108
|
+
entry({
|
|
109
|
+
signature: 'gate-walks-skills-only',
|
|
110
|
+
desc: 'the gate walks skills and nothing else',
|
|
111
|
+
ref: 'scripts/validate-portability.mjs:58',
|
|
112
|
+
source_skill: 'robustness-lens',
|
|
113
|
+
}),
|
|
114
|
+
];
|
|
115
|
+
test('clusters three independent lenses on one defect', () => {
|
|
116
|
+
const clusters = (0, cluster_1.clusterEntries)(threeLenses(), 2);
|
|
117
|
+
expect(clusters).toHaveLength(1);
|
|
118
|
+
expect(clusters[0].count).toBe(3);
|
|
119
|
+
});
|
|
120
|
+
test('labels the cluster convergent and lists every distinct signature', () => {
|
|
121
|
+
const clusters = (0, cluster_1.clusterEntries)(threeLenses(), 2);
|
|
122
|
+
expect(clusters[0].kind).toBe('convergent');
|
|
123
|
+
expect(clusters[0].signatures).toEqual([
|
|
124
|
+
'gate-walks-skills-only',
|
|
125
|
+
'validator-scope-skills-only',
|
|
126
|
+
'validator-skips-agents-references',
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
test('does NOT merge two unrelated defects that happen to share a file', () => {
|
|
130
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
131
|
+
entry({
|
|
132
|
+
signature: 'gate-walks-skills-only',
|
|
133
|
+
desc: 'the gate walks skills and nothing else',
|
|
134
|
+
ref: 'scripts/validate-portability.mjs:58',
|
|
135
|
+
}),
|
|
136
|
+
entry({
|
|
137
|
+
signature: 'exit-code-swallowed',
|
|
138
|
+
desc: 'process exits zero after a failed assertion',
|
|
139
|
+
ref: 'scripts/validate-portability.mjs:58',
|
|
140
|
+
}),
|
|
141
|
+
], 2);
|
|
142
|
+
expect(clusters).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
test('does NOT merge a win with a finding on the strength of a shared file', () => {
|
|
145
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
146
|
+
entry({ signature: 'gate-walks-skills-only', desc: 'the gate walks skills only', ref: 'a.mjs:1', polarity: 'finding' }),
|
|
147
|
+
entry({ signature: 'gate-walks-skills-fix', desc: 'the gate walks skills only, now fixed', ref: 'a.mjs:1', polarity: 'win' }),
|
|
148
|
+
], 2);
|
|
149
|
+
expect(clusters).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
test('a non-file ref contributes no clustering signal', () => {
|
|
152
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
153
|
+
entry({ signature: 'alpha-defect', desc: 'alpha slug mismatch', ref: 'PR #16' }),
|
|
154
|
+
entry({ signature: 'beta-defect', desc: 'beta timeout on retry', ref: 'PR #16' }),
|
|
155
|
+
], 2);
|
|
156
|
+
expect(clusters).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe('clusterEntries — lexical convergence without a shared file', () => {
|
|
160
|
+
test('clusters near-identical wording across different files', () => {
|
|
161
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
162
|
+
entry({ signature: 'vacuous-test-asserts-nothing', desc: 'test asserts nothing meaningful', ref: 'tests/a.test.ts:3' }),
|
|
163
|
+
entry({ signature: 'vacuous-test-asserts-nothing-either', desc: 'test asserts nothing meaningful', ref: 'tests/b.test.ts:7' }),
|
|
164
|
+
], 2);
|
|
165
|
+
expect(clusters).toHaveLength(1);
|
|
166
|
+
expect(clusters[0].kind).toBe('convergent');
|
|
167
|
+
});
|
|
168
|
+
test('leaves weakly-related findings on different files apart', () => {
|
|
169
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
170
|
+
entry({ signature: 'validator-scope-skills-only', desc: 'validator scope covers skills', ref: 'src/a.ts:1' }),
|
|
171
|
+
entry({ signature: 'gate-walks-skills-only', desc: 'the gate walks skills', ref: 'src/b.ts:1' }),
|
|
172
|
+
], 2);
|
|
173
|
+
expect(clusters).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
test('merges A and C transitively through B, though A and C alone would not cluster', () => {
|
|
176
|
+
const chain = [
|
|
177
|
+
entry({ signature: 'aaa-marker', desc: 'alpha beta gamma delta', ref: 'src/a.ts:1' }),
|
|
178
|
+
entry({ signature: 'bbb-marker', desc: 'beta gamma delta epsilon', ref: 'src/b.ts:1' }),
|
|
179
|
+
entry({ signature: 'ccc-marker', desc: 'gamma delta epsilon zeta', ref: 'src/c.ts:1' }),
|
|
180
|
+
];
|
|
181
|
+
const clusters = (0, cluster_1.clusterEntries)(chain, 2);
|
|
182
|
+
expect(clusters).toHaveLength(1);
|
|
183
|
+
expect(clusters[0]).toMatchObject({ count: 3, kind: 'convergent' });
|
|
184
|
+
expect(clusters[0].signatures).toEqual(['aaa-marker', 'bbb-marker', 'ccc-marker']);
|
|
185
|
+
// Control: without the bridging entry, A and C do not satisfy the
|
|
186
|
+
// threshold on their own (affinity 0.5 < LEXICAL_AFFINITY_MIN 0.6) —
|
|
187
|
+
// proving the merge above genuinely relies on transitive closure
|
|
188
|
+
// through B, not a coincidence of the threshold being lenient.
|
|
189
|
+
const withoutBridge = (0, cluster_1.clusterEntries)([chain[0], chain[2]], 2);
|
|
190
|
+
expect(withoutBridge).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('clusterEntries — representative and ordering', () => {
|
|
194
|
+
test('representative signature is the most frequent one', () => {
|
|
195
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
196
|
+
entry({ signature: 'zeta-frequent', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
|
|
197
|
+
entry({ signature: 'zeta-frequent', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
|
|
198
|
+
entry({ signature: 'alpha-rare', desc: 'gate walks skills only, second lens', ref: 'a.mjs:1' }),
|
|
199
|
+
], 2);
|
|
200
|
+
expect(clusters).toHaveLength(1);
|
|
201
|
+
expect(clusters[0].signature).toBe('zeta-frequent');
|
|
202
|
+
expect(clusters[0].count).toBe(3);
|
|
203
|
+
});
|
|
204
|
+
test('ties on frequency resolve to the lexicographically first signature', () => {
|
|
205
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
206
|
+
entry({ signature: 'zeta-lens', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
|
|
207
|
+
entry({ signature: 'alpha-lens', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
|
|
208
|
+
], 2);
|
|
209
|
+
expect(clusters[0].signature).toBe('alpha-lens');
|
|
210
|
+
});
|
|
211
|
+
test('sorts by count desc, then convergent before exact, then signature asc', () => {
|
|
212
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
213
|
+
// convergent cluster of 2 on one file
|
|
214
|
+
entry({ signature: 'mid-one', desc: 'gate walks skills only', ref: 'mid.mjs:1' }),
|
|
215
|
+
entry({ signature: 'mid-two', desc: 'gate walks skills only, other lens', ref: 'mid.mjs:1' }),
|
|
216
|
+
// exact cluster of 2, unrelated
|
|
217
|
+
entry({ signature: 'exact-dup', desc: 'alpha slug mismatch', ref: 'exact.ts:1' }),
|
|
218
|
+
entry({ signature: 'exact-dup', desc: 'alpha slug mismatch', ref: 'exact.ts:1' }),
|
|
219
|
+
// exact cluster of 3, unrelated — highest count wins regardless of kind
|
|
220
|
+
entry({ signature: 'top-dup', desc: 'beta timeout on retry', ref: 'top.ts:1' }),
|
|
221
|
+
entry({ signature: 'top-dup', desc: 'beta timeout on retry', ref: 'top.ts:1' }),
|
|
222
|
+
entry({ signature: 'top-dup', desc: 'beta timeout on retry', ref: 'top.ts:1' }),
|
|
223
|
+
], 2);
|
|
224
|
+
expect(clusters.map((c) => [c.signature, c.count, c.kind])).toEqual([
|
|
225
|
+
['top-dup', 3, 'exact'],
|
|
226
|
+
['mid-one', 2, 'convergent'],
|
|
227
|
+
['exact-dup', 2, 'exact'],
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
test('an empty ledger yields no clusters', () => {
|
|
231
|
+
expect((0, cluster_1.clusterEntries)([], 2)).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
test('min <= 0 still returns every group, including size-1 groups', () => {
|
|
234
|
+
const solo = entry({ signature: 'lonely', desc: 'nobody else mentions this', ref: 'z.ts:1' });
|
|
235
|
+
expect((0, cluster_1.clusterEntries)([solo], 0)).toEqual([
|
|
236
|
+
{ signature: 'lonely', count: 1, kind: 'exact', signatures: ['lonely'], entries: [solo] },
|
|
237
|
+
]);
|
|
238
|
+
expect((0, cluster_1.clusterEntries)([solo], -5)).toEqual((0, cluster_1.clusterEntries)([solo], 0));
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -54,6 +54,17 @@ describe('ledger store — add/list', () => {
|
|
|
54
54
|
expect(got).toHaveLength(2);
|
|
55
55
|
expect(got.map(e => e.signature)).toEqual(['public-fn-returns-infinity', 's2']);
|
|
56
56
|
});
|
|
57
|
+
test('listEntries skips a shape-invalid (but syntactically valid) entry without throwing', () => {
|
|
58
|
+
const p = (0, store_1.ledgerPath)(cwd, 'feat-x');
|
|
59
|
+
fs_1.default.mkdirSync(path_1.default.dirname(p), { recursive: true });
|
|
60
|
+
const missingDesc = JSON.stringify({ ts: 't', branch: 'feat-x', phase: 'p', source_skill: 's', polarity: 'finding', class: 'logica', signature: 'missing-desc', severity: 'minor', ref: 'a.ts:1' });
|
|
61
|
+
const nullDesc = JSON.stringify({ ...entry(), desc: null });
|
|
62
|
+
const numericSignature = JSON.stringify({ ...entry(), signature: 42 });
|
|
63
|
+
fs_1.default.writeFileSync(p, [missingDesc, nullDesc, numericSignature, JSON.stringify(entry({ signature: 's2' }))].join('\n') + '\n');
|
|
64
|
+
const got = (0, store_1.listEntries)(cwd, 'feat-x');
|
|
65
|
+
expect(got).toHaveLength(1);
|
|
66
|
+
expect(got[0].signature).toBe('s2');
|
|
67
|
+
});
|
|
57
68
|
});
|
|
58
69
|
describe('ledger store — detectBranch', () => {
|
|
59
70
|
test('falls back to _no-branch outside a git repo', () => {
|
|
@@ -84,10 +95,10 @@ describe('ledger store — recurring', () => {
|
|
|
84
95
|
test('groups by signature and reports clusters with count >= min', () => {
|
|
85
96
|
(0, store_1.addEntry)(cwd, entry({ signature: 'dup' }));
|
|
86
97
|
(0, store_1.addEntry)(cwd, entry({ signature: 'dup' }));
|
|
87
|
-
(0, store_1.addEntry)(cwd, entry({ signature: 'solo' }));
|
|
98
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'solo', ref: 'src/other.ts:3', desc: 'pagination cursor skips a page' }));
|
|
88
99
|
const clusters = (0, store_1.recurring)(cwd, 'feat-x', 2);
|
|
89
100
|
expect(clusters).toHaveLength(1);
|
|
90
|
-
expect(clusters[0]).toMatchObject({ signature: 'dup', count: 2 });
|
|
101
|
+
expect(clusters[0]).toMatchObject({ signature: 'dup', count: 2, kind: 'exact' });
|
|
91
102
|
expect(clusters[0].entries).toHaveLength(2);
|
|
92
103
|
});
|
|
93
104
|
test('respects --min: count 2 is excluded when min is 3', () => {
|
|
@@ -96,14 +107,38 @@ describe('ledger store — recurring', () => {
|
|
|
96
107
|
expect((0, store_1.recurring)(cwd, 'feat-x', 3)).toEqual([]);
|
|
97
108
|
});
|
|
98
109
|
test('sorts clusters by count descending', () => {
|
|
99
|
-
(0, store_1.addEntry)(cwd, entry({ signature: 'a' }));
|
|
100
|
-
(0, store_1.addEntry)(cwd, entry({ signature: 'a' }));
|
|
101
|
-
(0, store_1.addEntry)(cwd, entry({ signature: 'b' }));
|
|
102
|
-
(0, store_1.addEntry)(cwd, entry({ signature: 'b' }));
|
|
103
|
-
(0, store_1.addEntry)(cwd, entry({ signature: 'b' }));
|
|
110
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'a', ref: 'src/a.ts:1', desc: 'alpha slug mismatch' }));
|
|
111
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'a', ref: 'src/a.ts:1', desc: 'alpha slug mismatch' }));
|
|
112
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'b', ref: 'src/b.ts:1', desc: 'beta timeout on retry' }));
|
|
113
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'b', ref: 'src/b.ts:1', desc: 'beta timeout on retry' }));
|
|
114
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'b', ref: 'src/b.ts:1', desc: 'beta timeout on retry' }));
|
|
104
115
|
const clusters = (0, store_1.recurring)(cwd, 'feat-x', 2);
|
|
105
116
|
expect(clusters.map(c => c.signature)).toEqual(['b', 'a']);
|
|
106
117
|
});
|
|
118
|
+
test('reports independent lenses on one file as a single convergent cluster', () => {
|
|
119
|
+
(0, store_1.addEntry)(cwd, entry({
|
|
120
|
+
signature: 'validator-scope-skills-only',
|
|
121
|
+
desc: 'validator scope covers skills only',
|
|
122
|
+
ref: 'scripts/validate-portability.mjs:41',
|
|
123
|
+
}));
|
|
124
|
+
(0, store_1.addEntry)(cwd, entry({
|
|
125
|
+
signature: 'gate-walks-skills-only',
|
|
126
|
+
desc: 'the gate walks skills and nothing else',
|
|
127
|
+
ref: 'scripts/validate-portability.mjs:58',
|
|
128
|
+
}));
|
|
129
|
+
const clusters = (0, store_1.recurring)(cwd, 'feat-x', 2);
|
|
130
|
+
expect(clusters).toHaveLength(1);
|
|
131
|
+
expect(clusters[0]).toMatchObject({ count: 2, kind: 'convergent' });
|
|
132
|
+
expect(clusters[0].signatures).toEqual(['gate-walks-skills-only', 'validator-scope-skills-only']);
|
|
133
|
+
});
|
|
134
|
+
test('recurring does not crash on a shape-invalid entry mixed into an otherwise valid ledger', () => {
|
|
135
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }));
|
|
136
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }));
|
|
137
|
+
const p = (0, store_1.ledgerPath)(cwd, 'feat-x');
|
|
138
|
+
fs_1.default.appendFileSync(p, JSON.stringify({ ts: 't', branch: 'feat-x', phase: 'p', source_skill: 's', polarity: 'finding', class: 'logica', signature: 'no-desc', severity: 'minor', ref: 'b.ts:1' }) + '\n');
|
|
139
|
+
expect(() => (0, store_1.recurring)(cwd, 'feat-x', 2)).not.toThrow();
|
|
140
|
+
expect((0, store_1.recurring)(cwd, 'feat-x', 2)).toEqual([expect.objectContaining({ signature: 'dup', count: 2 })]);
|
|
141
|
+
});
|
|
107
142
|
});
|
|
108
143
|
describe('ledger store — archive', () => {
|
|
109
144
|
let cwd;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Structural regression guard for the recurring class of bug found across
|
|
3
|
+
// R1's durable-controller work (harness-retro, 2026-08-01 — r1-durable-controller
|
|
4
|
+
// plan, Task 20 + post-implementation-qa): execFileSync/spawn/spawnSync calls
|
|
5
|
+
// with no explicit `stdio` option inherit Node's default `inheritStderr`
|
|
6
|
+
// relay, which pipes the SUBPROCESS's stderr onto the CALLING process's own
|
|
7
|
+
// stderr fd. When that fd is a destroyed/broken pipe (a detached wrapper
|
|
8
|
+
// whose parent already tore down its own pipes, a supervisor whose stdout
|
|
9
|
+
// went away), the relay itself raises an async EPIPE with no listener,
|
|
10
|
+
// which Node re-throws — silently crashing the caller before it can finish
|
|
11
|
+
// its work.
|
|
12
|
+
//
|
|
13
|
+
// The bug recurred FIVE separate times in the same session even after the
|
|
14
|
+
// first fix landed: spawnStructured's own detached-child spawn, then 4 more
|
|
15
|
+
// bare execFileSync('git'/'ps'/'pgrep', ...) call sites in DIFFERENT files
|
|
16
|
+
// (process.ts, lock.ts, job/index.ts, watch/index.ts, fingerprint.ts) plus a
|
|
17
|
+
// bonus spawnSync('zip', ...) in pack.ts — because each fix was scoped to
|
|
18
|
+
// the one call site a reviewer happened to be looking at, not the whole
|
|
19
|
+
// class. This test exercises the whole class in one pass: every
|
|
20
|
+
// execFileSync/spawn/spawnSync call site anywhere under cli/src must pass an
|
|
21
|
+
// explicit `stdio` option (or the shared `EXEC_STDIO` constant) — so a
|
|
22
|
+
// future call site added without one fails immediately instead of waiting
|
|
23
|
+
// for the next EPIPE crash to be noticed.
|
|
24
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
25
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
26
|
+
};
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
const fs_1 = __importDefault(require("fs"));
|
|
29
|
+
const path_1 = __importDefault(require("path"));
|
|
30
|
+
const SRC_ROOT = path_1.default.join(__dirname, '../../src');
|
|
31
|
+
const CALLEE_NAMES = ['execFileSync', 'spawnSync', 'spawn'];
|
|
32
|
+
function listTsFiles(dir) {
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
35
|
+
const full = path_1.default.join(dir, entry.name);
|
|
36
|
+
if (entry.isDirectory())
|
|
37
|
+
out.push(...listTsFiles(full));
|
|
38
|
+
else if (entry.isFile() && entry.name.endsWith('.ts'))
|
|
39
|
+
out.push(full);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
// Strips comments before scanning: JSDoc/inline comments in this codebase
|
|
44
|
+
// routinely reference "spawn (...)" or "execFileSync(...)" in prose (e.g.
|
|
45
|
+
// process.ts's own EPIPE-hardening comment), which would otherwise register
|
|
46
|
+
// as phantom call sites. Naive block/line-comment stripping, not a full TS
|
|
47
|
+
// parser — sufficient for a structural guard, and a false positive here
|
|
48
|
+
// fails loud rather than silently missing a real site. The `[^:]` guard on
|
|
49
|
+
// `//` avoids treating a `https://` URL in a string as a line comment.
|
|
50
|
+
function stripComments(source) {
|
|
51
|
+
return source
|
|
52
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
53
|
+
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
54
|
+
}
|
|
55
|
+
// Extracts the paren-balanced argument text of every call to one of
|
|
56
|
+
// CALLEE_NAMES in `source` — e.g. "execFileSync('git', [...], {...})" -> the
|
|
57
|
+
// text between the outer parens. Balanced-paren scanning (not a single-line
|
|
58
|
+
// regex) is required because real call sites in this repo span multiple
|
|
59
|
+
// lines (e.g. process.ts's pgrep call).
|
|
60
|
+
function extractCalls(rawSource) {
|
|
61
|
+
const source = stripComments(rawSource);
|
|
62
|
+
const calls = [];
|
|
63
|
+
const calleeRe = new RegExp(`\\b(${CALLEE_NAMES.join('|')})\\s*\\(`, 'g');
|
|
64
|
+
let m;
|
|
65
|
+
while ((m = calleeRe.exec(source)) !== null) {
|
|
66
|
+
const start = m.index + m[0].length; // just after the opening '('
|
|
67
|
+
let depth = 1;
|
|
68
|
+
let i = start;
|
|
69
|
+
while (i < source.length && depth > 0) {
|
|
70
|
+
if (source[i] === '(')
|
|
71
|
+
depth++;
|
|
72
|
+
else if (source[i] === ')')
|
|
73
|
+
depth--;
|
|
74
|
+
i++;
|
|
75
|
+
}
|
|
76
|
+
calls.push({ callee: m[1], args: source.slice(start, i - 1) });
|
|
77
|
+
}
|
|
78
|
+
return calls;
|
|
79
|
+
}
|
|
80
|
+
describe('execFileSync/spawn/spawnSync invocations — explicit stdio required (structural)', () => {
|
|
81
|
+
it('every call site under cli/src passes an explicit stdio option, never relying on Node defaults', () => {
|
|
82
|
+
const violations = [];
|
|
83
|
+
for (const file of listTsFiles(SRC_ROOT)) {
|
|
84
|
+
const source = fs_1.default.readFileSync(file, 'utf8');
|
|
85
|
+
for (const call of extractCalls(source)) {
|
|
86
|
+
const hasExplicitStdio = /\bstdio\s*:/.test(call.args) || /\bEXEC_STDIO\b/.test(call.args);
|
|
87
|
+
if (!hasExplicitStdio) {
|
|
88
|
+
violations.push(`${path_1.default.relative(SRC_ROOT, file)}: ${call.callee}(...) has no explicit stdio option`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
expect(violations).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|