@renseiai/agentfactory 0.8.19 → 0.8.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/src/config/repository-config.d.ts +7 -0
  2. package/dist/src/config/repository-config.d.ts.map +1 -1
  3. package/dist/src/config/repository-config.js +15 -1
  4. package/dist/src/config/repository-config.test.js +1 -1
  5. package/dist/src/governor/decision-engine-adapter.js +5 -10
  6. package/dist/src/governor/decision-engine-adapter.test.js +13 -14
  7. package/dist/src/governor/decision-engine.js +3 -7
  8. package/dist/src/governor/decision-engine.test.js +5 -5
  9. package/dist/src/index.d.ts +1 -0
  10. package/dist/src/index.d.ts.map +1 -1
  11. package/dist/src/index.js +1 -0
  12. package/dist/src/merge-queue/adapters/local.d.ts +68 -0
  13. package/dist/src/merge-queue/adapters/local.d.ts.map +1 -0
  14. package/dist/src/merge-queue/adapters/local.js +136 -0
  15. package/dist/src/merge-queue/adapters/local.test.d.ts +2 -0
  16. package/dist/src/merge-queue/adapters/local.test.d.ts.map +1 -0
  17. package/dist/src/merge-queue/adapters/local.test.js +176 -0
  18. package/dist/src/merge-queue/index.d.ts +13 -5
  19. package/dist/src/merge-queue/index.d.ts.map +1 -1
  20. package/dist/src/merge-queue/index.js +13 -6
  21. package/dist/src/merge-queue/merge-queue.integration.test.js +19 -0
  22. package/dist/src/merge-queue/merge-worker.d.ts.map +1 -1
  23. package/dist/src/merge-queue/merge-worker.js +29 -0
  24. package/dist/src/merge-queue/types.d.ts +1 -1
  25. package/dist/src/merge-queue/types.d.ts.map +1 -1
  26. package/dist/src/orchestrator/index.d.ts +4 -0
  27. package/dist/src/orchestrator/index.d.ts.map +1 -1
  28. package/dist/src/orchestrator/index.js +3 -0
  29. package/dist/src/orchestrator/orchestrator.d.ts +31 -0
  30. package/dist/src/orchestrator/orchestrator.d.ts.map +1 -1
  31. package/dist/src/orchestrator/orchestrator.js +263 -11
  32. package/dist/src/orchestrator/parse-work-result.d.ts.map +1 -1
  33. package/dist/src/orchestrator/parse-work-result.js +3 -1
  34. package/dist/src/orchestrator/parse-work-result.test.js +6 -0
  35. package/dist/src/orchestrator/quality-baseline.d.ts +83 -0
  36. package/dist/src/orchestrator/quality-baseline.d.ts.map +1 -0
  37. package/dist/src/orchestrator/quality-baseline.js +313 -0
  38. package/dist/src/orchestrator/quality-baseline.test.d.ts +2 -0
  39. package/dist/src/orchestrator/quality-baseline.test.d.ts.map +1 -0
  40. package/dist/src/orchestrator/quality-baseline.test.js +448 -0
  41. package/dist/src/orchestrator/quality-ratchet.d.ts +70 -0
  42. package/dist/src/orchestrator/quality-ratchet.d.ts.map +1 -0
  43. package/dist/src/orchestrator/quality-ratchet.js +162 -0
  44. package/dist/src/orchestrator/quality-ratchet.test.d.ts +2 -0
  45. package/dist/src/orchestrator/quality-ratchet.test.d.ts.map +1 -0
  46. package/dist/src/orchestrator/quality-ratchet.test.js +335 -0
  47. package/dist/src/orchestrator/types.d.ts +2 -0
  48. package/dist/src/orchestrator/types.d.ts.map +1 -1
  49. package/dist/src/providers/codex-app-server-provider.d.ts +37 -1
  50. package/dist/src/providers/codex-app-server-provider.d.ts.map +1 -1
  51. package/dist/src/providers/codex-app-server-provider.js +290 -35
  52. package/dist/src/providers/codex-app-server-provider.test.js +72 -12
  53. package/dist/src/providers/codex-approval-bridge.d.ts +49 -0
  54. package/dist/src/providers/codex-approval-bridge.d.ts.map +1 -0
  55. package/dist/src/providers/codex-approval-bridge.js +117 -0
  56. package/dist/src/providers/codex-approval-bridge.test.d.ts +2 -0
  57. package/dist/src/providers/codex-approval-bridge.test.d.ts.map +1 -0
  58. package/dist/src/providers/codex-approval-bridge.test.js +188 -0
  59. package/dist/src/providers/types.d.ts +25 -0
  60. package/dist/src/providers/types.d.ts.map +1 -1
  61. package/dist/src/routing/types.d.ts +1 -1
  62. package/dist/src/templates/adapters.d.ts +25 -0
  63. package/dist/src/templates/adapters.d.ts.map +1 -1
  64. package/dist/src/templates/adapters.js +70 -0
  65. package/dist/src/templates/adapters.test.js +49 -0
  66. package/dist/src/templates/index.d.ts +1 -0
  67. package/dist/src/templates/index.d.ts.map +1 -1
  68. package/dist/src/templates/registry.d.ts +8 -0
  69. package/dist/src/templates/registry.d.ts.map +1 -1
  70. package/dist/src/templates/registry.js +11 -0
  71. package/dist/src/templates/types.d.ts +22 -0
  72. package/dist/src/templates/types.d.ts.map +1 -1
  73. package/dist/src/templates/types.js +12 -0
  74. package/dist/src/tools/index.d.ts +2 -0
  75. package/dist/src/tools/index.d.ts.map +1 -1
  76. package/dist/src/tools/index.js +1 -0
  77. package/dist/src/tools/registry.d.ts +9 -1
  78. package/dist/src/tools/registry.d.ts.map +1 -1
  79. package/dist/src/tools/registry.js +13 -1
  80. package/dist/src/tools/stdio-server-entry.d.ts +25 -0
  81. package/dist/src/tools/stdio-server-entry.d.ts.map +1 -0
  82. package/dist/src/tools/stdio-server-entry.js +205 -0
  83. package/dist/src/tools/stdio-server.d.ts +87 -0
  84. package/dist/src/tools/stdio-server.d.ts.map +1 -0
  85. package/dist/src/tools/stdio-server.js +138 -0
  86. package/dist/src/workflow/workflow-types.d.ts +3 -3
  87. package/package.json +3 -2
@@ -0,0 +1,448 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { captureQualityBaseline, computeQualityDelta, formatQualityReport, parseVitestJson, countTypescriptErrors, loadBaseline, saveBaseline, } from './quality-baseline.js';
3
+ // Mock child_process and fs
4
+ vi.mock('node:child_process', () => ({
5
+ execSync: vi.fn(),
6
+ }));
7
+ vi.mock('node:fs', () => ({
8
+ readFileSync: vi.fn(),
9
+ writeFileSync: vi.fn(),
10
+ existsSync: vi.fn(),
11
+ }));
12
+ import { execSync } from 'node:child_process';
13
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
14
+ const mockExecSync = vi.mocked(execSync);
15
+ const mockReadFileSync = vi.mocked(readFileSync);
16
+ const mockWriteFileSync = vi.mocked(writeFileSync);
17
+ const mockExistsSync = vi.mocked(existsSync);
18
+ beforeEach(() => {
19
+ vi.resetAllMocks();
20
+ });
21
+ // ---------------------------------------------------------------------------
22
+ // captureQualityBaseline
23
+ // ---------------------------------------------------------------------------
24
+ describe('captureQualityBaseline', () => {
25
+ it('captures test counts from vitest JSON output', () => {
26
+ // git rev-parse HEAD
27
+ mockExecSync.mockReturnValueOnce('abc123\n');
28
+ // test command with --reporter=json
29
+ mockExecSync.mockReturnValueOnce(JSON.stringify({
30
+ numTotalTests: 100,
31
+ numPassedTests: 98,
32
+ numFailedTests: 2,
33
+ }));
34
+ // typecheck command
35
+ mockExecSync.mockReturnValueOnce('');
36
+ // no lint command configured → skipped
37
+ const baseline = captureQualityBaseline('/work', {
38
+ testCommand: 'pnpm test',
39
+ validateCommand: 'pnpm typecheck',
40
+ });
41
+ expect(baseline.commitSha).toBe('abc123');
42
+ expect(baseline.tests.total).toBe(100);
43
+ expect(baseline.tests.passed).toBe(98);
44
+ expect(baseline.tests.failed).toBe(2);
45
+ expect(baseline.typecheck.errorCount).toBe(0);
46
+ expect(baseline.typecheck.exitCode).toBe(0);
47
+ });
48
+ it('falls back to text parsing when JSON reporter fails', () => {
49
+ // git rev-parse HEAD
50
+ mockExecSync.mockReturnValueOnce('abc123\n');
51
+ // JSON reporter fails
52
+ mockExecSync.mockImplementationOnce(() => { throw new Error('json reporter not found'); });
53
+ // text output fallback
54
+ mockExecSync.mockReturnValueOnce('Tests 42 passed | 3 failed | 45 total');
55
+ // typecheck
56
+ mockExecSync.mockReturnValueOnce('');
57
+ const baseline = captureQualityBaseline('/work', {});
58
+ expect(baseline.tests.total).toBe(45);
59
+ expect(baseline.tests.passed).toBe(42);
60
+ expect(baseline.tests.failed).toBe(3);
61
+ });
62
+ it('captures typecheck errors from stderr', () => {
63
+ // git rev-parse HEAD
64
+ mockExecSync.mockReturnValueOnce('abc123\n');
65
+ // test JSON reporter
66
+ mockExecSync.mockReturnValueOnce(JSON.stringify({ numTotalTests: 10, numPassedTests: 10, numFailedTests: 0 }));
67
+ // typecheck fails
68
+ const tscError = new Error('tsc failed');
69
+ tscError.stdout = '';
70
+ tscError.stderr = 'src/a.ts(1,1): error TS2304: Cannot find name\nsrc/b.ts(5,3): error TS2345: Argument of type';
71
+ tscError.status = 2;
72
+ mockExecSync.mockImplementationOnce(() => { throw tscError; });
73
+ const baseline = captureQualityBaseline('/work', {});
74
+ expect(baseline.typecheck.errorCount).toBe(2);
75
+ expect(baseline.typecheck.exitCode).toBe(2);
76
+ });
77
+ it('handles complete test failure gracefully', () => {
78
+ // git rev-parse HEAD
79
+ mockExecSync.mockReturnValueOnce('abc123\n');
80
+ // JSON reporter fails
81
+ mockExecSync.mockImplementationOnce(() => { throw new Error('crash'); });
82
+ // text fallback also fails
83
+ mockExecSync.mockImplementationOnce(() => { throw new Error('crash'); });
84
+ // typecheck passes
85
+ mockExecSync.mockReturnValueOnce('');
86
+ const baseline = captureQualityBaseline('/work', {});
87
+ // Should record at least 1 failure, not throw
88
+ expect(baseline.tests.failed).toBeGreaterThanOrEqual(1);
89
+ });
90
+ it('returns unknown commit SHA when git fails', () => {
91
+ mockExecSync.mockImplementationOnce(() => { throw new Error('not a git repo'); });
92
+ // JSON reporter
93
+ mockExecSync.mockReturnValueOnce(JSON.stringify({ numTotalTests: 1, numPassedTests: 1, numFailedTests: 0 }));
94
+ // typecheck
95
+ mockExecSync.mockReturnValueOnce('');
96
+ const baseline = captureQualityBaseline('/work', {});
97
+ expect(baseline.commitSha).toBe('unknown');
98
+ });
99
+ it('captures lint metrics when lintCommand is provided', () => {
100
+ mockExecSync.mockReturnValueOnce('abc123\n');
101
+ mockExecSync.mockReturnValueOnce(JSON.stringify({ numTotalTests: 5, numPassedTests: 5, numFailedTests: 0 }));
102
+ mockExecSync.mockReturnValueOnce('');
103
+ // lint command output
104
+ mockExecSync.mockReturnValueOnce('\n✖ 10 problems (6 errors, 4 warnings)\n');
105
+ const baseline = captureQualityBaseline('/work', {
106
+ lintCommand: 'pnpm lint',
107
+ });
108
+ expect(baseline.lint.errorCount).toBe(6);
109
+ expect(baseline.lint.warningCount).toBe(4);
110
+ });
111
+ it('returns zero lint counts when no lintCommand is configured', () => {
112
+ mockExecSync.mockReturnValueOnce('abc123\n');
113
+ mockExecSync.mockReturnValueOnce(JSON.stringify({ numTotalTests: 1, numPassedTests: 1, numFailedTests: 0 }));
114
+ mockExecSync.mockReturnValueOnce('');
115
+ const baseline = captureQualityBaseline('/work', {});
116
+ expect(baseline.lint.errorCount).toBe(0);
117
+ expect(baseline.lint.warningCount).toBe(0);
118
+ });
119
+ it('returns errorCount=1 when typecheck exits non-zero but has no parseable errors', () => {
120
+ mockExecSync.mockReturnValueOnce('abc123\n');
121
+ mockExecSync.mockReturnValueOnce(JSON.stringify({ numTotalTests: 1, numPassedTests: 1, numFailedTests: 0 }));
122
+ const tscError = new Error('tsc failed');
123
+ tscError.stdout = '';
124
+ tscError.stderr = 'Some unparseable error output';
125
+ tscError.status = 1;
126
+ mockExecSync.mockImplementationOnce(() => { throw tscError; });
127
+ const baseline = captureQualityBaseline('/work', {});
128
+ expect(baseline.typecheck.errorCount).toBe(1);
129
+ expect(baseline.typecheck.exitCode).toBe(1);
130
+ });
131
+ it('uses custom packageManager for default commands', () => {
132
+ mockExecSync.mockReturnValueOnce('abc123\n');
133
+ // JSON reporter with npm
134
+ mockExecSync.mockReturnValueOnce(JSON.stringify({ numTotalTests: 1, numPassedTests: 1, numFailedTests: 0 }));
135
+ // typecheck
136
+ mockExecSync.mockReturnValueOnce('');
137
+ captureQualityBaseline('/work', { packageManager: 'npm' });
138
+ // First call after git rev-parse should use npm for test command
139
+ const testCall = mockExecSync.mock.calls[1];
140
+ expect(testCall[0]).toContain('npm test');
141
+ });
142
+ it('captures lint errors from failing lint command', () => {
143
+ mockExecSync.mockReturnValueOnce('abc123\n');
144
+ mockExecSync.mockReturnValueOnce(JSON.stringify({ numTotalTests: 1, numPassedTests: 1, numFailedTests: 0 }));
145
+ mockExecSync.mockReturnValueOnce('');
146
+ // lint command fails
147
+ const lintError = new Error('lint failed');
148
+ lintError.stdout = '\n✖ 3 problems (3 errors, 0 warnings)\n';
149
+ lintError.stderr = '';
150
+ mockExecSync.mockImplementationOnce(() => { throw lintError; });
151
+ const baseline = captureQualityBaseline('/work', { lintCommand: 'pnpm lint' });
152
+ expect(baseline.lint.errorCount).toBe(3);
153
+ expect(baseline.lint.warningCount).toBe(0);
154
+ });
155
+ });
156
+ // ---------------------------------------------------------------------------
157
+ // computeQualityDelta
158
+ // ---------------------------------------------------------------------------
159
+ describe('computeQualityDelta', () => {
160
+ const makeBaseline = (overrides) => ({
161
+ timestamp: '2026-01-01T00:00:00Z',
162
+ commitSha: 'base',
163
+ tests: { total: 100, passed: 95, failed: 5, skipped: 0 },
164
+ typecheck: { errorCount: 3, exitCode: 0 },
165
+ lint: { errorCount: 2, warningCount: 10 },
166
+ ...overrides,
167
+ });
168
+ it('passes when agent improves all metrics', () => {
169
+ const baseline = makeBaseline();
170
+ const current = makeBaseline({
171
+ tests: { total: 105, passed: 102, failed: 3, skipped: 0 },
172
+ typecheck: { errorCount: 1, exitCode: 0 },
173
+ lint: { errorCount: 0, warningCount: 5 },
174
+ });
175
+ const delta = computeQualityDelta(baseline, current);
176
+ expect(delta.passed).toBe(true);
177
+ expect(delta.testFailuresDelta).toBe(-2);
178
+ expect(delta.typeErrorsDelta).toBe(-2);
179
+ expect(delta.lintErrorsDelta).toBe(-2);
180
+ expect(delta.testCountDelta).toBe(5);
181
+ });
182
+ it('fails when agent introduces new test failures', () => {
183
+ const baseline = makeBaseline();
184
+ const current = makeBaseline({
185
+ tests: { total: 100, passed: 92, failed: 8, skipped: 0 },
186
+ });
187
+ const delta = computeQualityDelta(baseline, current);
188
+ expect(delta.passed).toBe(false);
189
+ expect(delta.testFailuresDelta).toBe(3);
190
+ });
191
+ it('fails when agent introduces new typecheck errors', () => {
192
+ const baseline = makeBaseline();
193
+ const current = makeBaseline({
194
+ typecheck: { errorCount: 5, exitCode: 1 },
195
+ });
196
+ const delta = computeQualityDelta(baseline, current);
197
+ expect(delta.passed).toBe(false);
198
+ expect(delta.typeErrorsDelta).toBe(2);
199
+ });
200
+ it('fails when agent introduces new lint errors', () => {
201
+ const baseline = makeBaseline();
202
+ const current = makeBaseline({
203
+ lint: { errorCount: 4, warningCount: 10 },
204
+ });
205
+ const delta = computeQualityDelta(baseline, current);
206
+ expect(delta.passed).toBe(false);
207
+ expect(delta.lintErrorsDelta).toBe(2);
208
+ });
209
+ it('passes when baseline and current are identical', () => {
210
+ const baseline = makeBaseline();
211
+ const current = makeBaseline();
212
+ const delta = computeQualityDelta(baseline, current);
213
+ expect(delta.passed).toBe(true);
214
+ expect(delta.testFailuresDelta).toBe(0);
215
+ expect(delta.typeErrorsDelta).toBe(0);
216
+ expect(delta.lintErrorsDelta).toBe(0);
217
+ expect(delta.testCountDelta).toBe(0);
218
+ });
219
+ it('tracks test removal as negative testCountDelta', () => {
220
+ const baseline = makeBaseline();
221
+ const current = makeBaseline({
222
+ tests: { total: 90, passed: 90, failed: 0, skipped: 0 },
223
+ });
224
+ const delta = computeQualityDelta(baseline, current);
225
+ expect(delta.passed).toBe(true); // fewer failures is good
226
+ expect(delta.testCountDelta).toBe(-10); // but removing tests is a warning
227
+ });
228
+ });
229
+ // ---------------------------------------------------------------------------
230
+ // formatQualityReport
231
+ // ---------------------------------------------------------------------------
232
+ describe('formatQualityReport', () => {
233
+ const makeBaseline = () => ({
234
+ timestamp: '2026-01-01T00:00:00Z',
235
+ commitSha: 'base',
236
+ tests: { total: 100, passed: 95, failed: 5, skipped: 0 },
237
+ typecheck: { errorCount: 3, exitCode: 0 },
238
+ lint: { errorCount: 2, warningCount: 10 },
239
+ });
240
+ it('formats a passing report', () => {
241
+ const baseline = makeBaseline();
242
+ const current = { ...makeBaseline(), tests: { total: 100, passed: 98, failed: 2, skipped: 0 } };
243
+ const delta = computeQualityDelta(baseline, current);
244
+ const report = formatQualityReport(baseline, current, delta);
245
+ expect(report).toContain('**PASSED**');
246
+ expect(report).toContain('Test failures');
247
+ expect(report).toContain('Typecheck errors');
248
+ });
249
+ it('formats a failing report', () => {
250
+ const baseline = makeBaseline();
251
+ const current = { ...makeBaseline(), tests: { total: 100, passed: 90, failed: 10, skipped: 0 } };
252
+ const delta = computeQualityDelta(baseline, current);
253
+ const report = formatQualityReport(baseline, current, delta);
254
+ expect(report).toContain('**FAILED**');
255
+ expect(report).toContain('+5');
256
+ });
257
+ it('warns about removed tests', () => {
258
+ const baseline = makeBaseline();
259
+ const current = { ...makeBaseline(), tests: { total: 80, passed: 80, failed: 0, skipped: 0 } };
260
+ const delta = computeQualityDelta(baseline, current);
261
+ const report = formatQualityReport(baseline, current, delta);
262
+ expect(report).toContain('20 test(s) were removed');
263
+ });
264
+ });
265
+ // ---------------------------------------------------------------------------
266
+ // parseVitestJson
267
+ // ---------------------------------------------------------------------------
268
+ describe('parseVitestJson', () => {
269
+ it('parses standard vitest JSON output', () => {
270
+ const json = JSON.stringify({
271
+ numTotalTests: 50,
272
+ numPassedTests: 48,
273
+ numFailedTests: 2,
274
+ });
275
+ const result = parseVitestJson(json);
276
+ expect(result).toEqual({ total: 50, passed: 48, failed: 2, skipped: 0 });
277
+ });
278
+ it('handles JSON with non-JSON prefix', () => {
279
+ const output = 'Some vitest output\n' + JSON.stringify({
280
+ numTotalTests: 10,
281
+ numPassedTests: 10,
282
+ numFailedTests: 0,
283
+ });
284
+ const result = parseVitestJson(output);
285
+ expect(result?.total).toBe(10);
286
+ });
287
+ it('returns null for non-JSON output', () => {
288
+ expect(parseVitestJson('not json at all')).toBeNull();
289
+ });
290
+ it('returns null for empty string', () => {
291
+ expect(parseVitestJson('')).toBeNull();
292
+ });
293
+ it('parses vitest v2+ format with testResults array', () => {
294
+ const output = JSON.stringify({
295
+ testResults: [
296
+ {
297
+ assertionResults: [
298
+ { status: 'passed' },
299
+ { status: 'passed' },
300
+ { status: 'failed' },
301
+ ],
302
+ },
303
+ {
304
+ assertionResults: [
305
+ { status: 'passed' },
306
+ ],
307
+ },
308
+ ],
309
+ });
310
+ const result = parseVitestJson(output);
311
+ expect(result).toEqual({ total: 4, passed: 3, failed: 1, skipped: 0 });
312
+ });
313
+ it('returns null for JSON without recognized fields', () => {
314
+ const output = JSON.stringify({ unrelated: 'data' });
315
+ expect(parseVitestJson(output)).toBeNull();
316
+ });
317
+ it('computes skipped count correctly', () => {
318
+ const json = JSON.stringify({
319
+ numTotalTests: 20,
320
+ numPassedTests: 15,
321
+ numFailedTests: 2,
322
+ });
323
+ const result = parseVitestJson(json);
324
+ expect(result?.skipped).toBe(3); // 20 - 15 - 2
325
+ });
326
+ });
327
+ // ---------------------------------------------------------------------------
328
+ // countTypescriptErrors
329
+ // ---------------------------------------------------------------------------
330
+ describe('countTypescriptErrors', () => {
331
+ it('counts TypeScript errors in tsc output', () => {
332
+ const output = [
333
+ 'src/a.ts(1,1): error TS2304: Cannot find name',
334
+ 'src/b.ts(5,3): error TS2345: Argument of type',
335
+ 'src/c.ts(10,1): error TS2322: Type is not assignable',
336
+ ].join('\n');
337
+ expect(countTypescriptErrors(output)).toBe(3);
338
+ });
339
+ it('returns 0 for clean output', () => {
340
+ expect(countTypescriptErrors('')).toBe(0);
341
+ expect(countTypescriptErrors('All good')).toBe(0);
342
+ });
343
+ it('handles mixed output with errors and warnings', () => {
344
+ const output = [
345
+ 'warning TS6059: File not under rootDir',
346
+ 'src/a.ts(1,1): error TS2304: Cannot find name',
347
+ 'Found 1 error.',
348
+ ].join('\n');
349
+ expect(countTypescriptErrors(output)).toBe(1); // only counts error TS, not warnings
350
+ });
351
+ });
352
+ // ---------------------------------------------------------------------------
353
+ // Text output parsing (tested via captureQualityBaseline fallback)
354
+ // ---------------------------------------------------------------------------
355
+ describe('test text output parsing', () => {
356
+ it('parses vitest text with skipped tests', () => {
357
+ mockExecSync.mockReturnValueOnce('abc\n');
358
+ mockExecSync.mockImplementationOnce(() => { throw new Error('no json'); });
359
+ mockExecSync.mockReturnValueOnce('Tests 10 passed | 2 failed | 1 skipped | 13 total');
360
+ mockExecSync.mockReturnValueOnce('');
361
+ const baseline = captureQualityBaseline('/work', {});
362
+ expect(baseline.tests.total).toBe(13);
363
+ expect(baseline.tests.passed).toBe(10);
364
+ expect(baseline.tests.failed).toBe(2);
365
+ expect(baseline.tests.skipped).toBe(1);
366
+ });
367
+ it('parses jest text format', () => {
368
+ mockExecSync.mockReturnValueOnce('abc\n');
369
+ mockExecSync.mockImplementationOnce(() => { throw new Error('no json'); });
370
+ mockExecSync.mockReturnValueOnce('Tests: 2 failed, 42 passed, 44 total');
371
+ mockExecSync.mockReturnValueOnce('');
372
+ const baseline = captureQualityBaseline('/work', {});
373
+ expect(baseline.tests.total).toBe(44);
374
+ expect(baseline.tests.passed).toBe(42);
375
+ expect(baseline.tests.failed).toBe(2);
376
+ });
377
+ it('parses jest text format with no failures', () => {
378
+ mockExecSync.mockReturnValueOnce('abc\n');
379
+ mockExecSync.mockImplementationOnce(() => { throw new Error('no json'); });
380
+ mockExecSync.mockReturnValueOnce('Tests: 42 passed, 42 total');
381
+ mockExecSync.mockReturnValueOnce('');
382
+ const baseline = captureQualityBaseline('/work', {});
383
+ expect(baseline.tests.total).toBe(42);
384
+ expect(baseline.tests.passed).toBe(42);
385
+ expect(baseline.tests.failed).toBe(0);
386
+ });
387
+ it('parses vitest compact format "Tests 42 passed (44)"', () => {
388
+ mockExecSync.mockReturnValueOnce('abc\n');
389
+ mockExecSync.mockImplementationOnce(() => { throw new Error('no json'); });
390
+ mockExecSync.mockReturnValueOnce('Tests 42 passed (44)');
391
+ mockExecSync.mockReturnValueOnce('');
392
+ const baseline = captureQualityBaseline('/work', {});
393
+ expect(baseline.tests.total).toBe(44);
394
+ expect(baseline.tests.passed).toBe(42);
395
+ });
396
+ it('parses test counts from error output when command fails', () => {
397
+ mockExecSync.mockReturnValueOnce('abc\n');
398
+ mockExecSync.mockImplementationOnce(() => { throw new Error('no json'); });
399
+ const testError = new Error('tests failed');
400
+ testError.stdout = 'Tests 8 passed | 2 failed | 10 total';
401
+ testError.stderr = '';
402
+ mockExecSync.mockImplementationOnce(() => { throw testError; });
403
+ mockExecSync.mockReturnValueOnce('');
404
+ const baseline = captureQualityBaseline('/work', {});
405
+ expect(baseline.tests.total).toBe(10);
406
+ expect(baseline.tests.failed).toBe(2);
407
+ });
408
+ });
409
+ // ---------------------------------------------------------------------------
410
+ // saveBaseline / loadBaseline
411
+ // ---------------------------------------------------------------------------
412
+ describe('saveBaseline', () => {
413
+ it('writes baseline JSON to .agent/ directory', () => {
414
+ const baseline = {
415
+ timestamp: '2026-01-01T00:00:00Z',
416
+ commitSha: 'abc',
417
+ tests: { total: 10, passed: 10, failed: 0, skipped: 0 },
418
+ typecheck: { errorCount: 0, exitCode: 0 },
419
+ lint: { errorCount: 0, warningCount: 0 },
420
+ };
421
+ saveBaseline('/work', baseline);
422
+ expect(mockWriteFileSync).toHaveBeenCalledWith(expect.stringContaining('quality-baseline.json'), expect.stringContaining('"commitSha": "abc"'));
423
+ });
424
+ });
425
+ describe('loadBaseline', () => {
426
+ it('loads baseline from .agent/ directory', () => {
427
+ const baseline = {
428
+ timestamp: '2026-01-01T00:00:00Z',
429
+ commitSha: 'abc',
430
+ tests: { total: 10, passed: 10, failed: 0, skipped: 0 },
431
+ typecheck: { errorCount: 0, exitCode: 0 },
432
+ lint: { errorCount: 0, warningCount: 0 },
433
+ };
434
+ mockExistsSync.mockReturnValue(true);
435
+ mockReadFileSync.mockReturnValue(JSON.stringify(baseline));
436
+ const loaded = loadBaseline('/work');
437
+ expect(loaded).toEqual(baseline);
438
+ });
439
+ it('returns null when no baseline exists', () => {
440
+ mockExistsSync.mockReturnValue(false);
441
+ expect(loadBaseline('/work')).toBeNull();
442
+ });
443
+ it('returns null on parse error', () => {
444
+ mockExistsSync.mockReturnValue(true);
445
+ mockReadFileSync.mockReturnValue('not json');
446
+ expect(loadBaseline('/work')).toBeNull();
447
+ });
448
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Quality Ratchet — Monotonic Quality Thresholds
3
+ *
4
+ * A quality ratchet is a committed JSON file that stores the best-known quality
5
+ * thresholds for the repository. Thresholds can only tighten (improve), never
6
+ * loosen. This prevents cumulative quality drift across many agent sessions.
7
+ *
8
+ * File location: .agentfactory/quality-ratchet.json (committed to repo)
9
+ *
10
+ * The ratchet is enforced at two points:
11
+ * 1. Merge queue — blocks merge if ratchet thresholds are violated
12
+ * 2. CI — runs as a required status check on every PR
13
+ */
14
+ import type { QualityBaseline } from './quality-baseline.js';
15
+ export interface QualityRatchet {
16
+ version: 1;
17
+ updatedAt: string;
18
+ updatedBy: string;
19
+ thresholds: {
20
+ testCount: {
21
+ min: number;
22
+ };
23
+ testFailures: {
24
+ max: number;
25
+ };
26
+ typecheckErrors: {
27
+ max: number;
28
+ };
29
+ lintErrors: {
30
+ max: number;
31
+ };
32
+ };
33
+ }
34
+ export interface RatchetCheckResult {
35
+ passed: boolean;
36
+ violations: Array<{
37
+ metric: string;
38
+ threshold: number;
39
+ actual: number;
40
+ direction: 'above-max' | 'below-min';
41
+ }>;
42
+ }
43
+ /**
44
+ * Load the quality ratchet from disk.
45
+ * Returns null if the ratchet file does not exist.
46
+ * Throws if the file exists but is invalid.
47
+ */
48
+ export declare function loadQualityRatchet(repoRoot: string): QualityRatchet | null;
49
+ /**
50
+ * Check current quality metrics against ratchet thresholds.
51
+ * Returns a result with pass/fail and any violations.
52
+ */
53
+ export declare function checkQualityRatchet(ratchet: QualityRatchet, current: QualityBaseline): RatchetCheckResult;
54
+ /**
55
+ * Tighten the quality ratchet if current metrics are better than thresholds.
56
+ * The ratchet only moves in the direction of improvement (monotonic).
57
+ *
58
+ * Returns true if the ratchet was updated, false if no improvement was found.
59
+ */
60
+ export declare function updateQualityRatchet(repoRoot: string, current: QualityBaseline, identifier: string): boolean;
61
+ /**
62
+ * Initialize a new quality ratchet file from a baseline snapshot.
63
+ * Use this when setting up quality gates for the first time.
64
+ */
65
+ export declare function initializeQualityRatchet(repoRoot: string, baseline: QualityBaseline): QualityRatchet;
66
+ /**
67
+ * Format a ratchet check result into a human-readable string.
68
+ */
69
+ export declare function formatRatchetResult(result: RatchetCheckResult): string;
70
+ //# sourceMappingURL=quality-ratchet.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quality-ratchet.d.ts","sourceRoot":"","sources":["../../../src/orchestrator/quality-ratchet.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AAM5D,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,CAAC,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE;QACV,SAAS,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;QAC1B,YAAY,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;QAC7B,eAAe,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;QAChC,UAAU,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;KAC5B,CAAA;CACF;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,OAAO,CAAA;IACf,UAAU,EAAE,KAAK,CAAC;QAChB,MAAM,EAAE,MAAM,CAAA;QACd,SAAS,EAAE,MAAM,CAAA;QACjB,MAAM,EAAE,MAAM,CAAA;QACd,SAAS,EAAE,WAAW,GAAG,WAAW,CAAA;KACrC,CAAC,CAAA;CACH;AAiBD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAa1E;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,eAAe,GACvB,kBAAkB,CA6CpB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,eAAe,EACxB,UAAU,EAAE,MAAM,GACjB,OAAO,CAsCT;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,eAAe,GACxB,cAAc,CAehB;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,CAYtE"}
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Quality Ratchet — Monotonic Quality Thresholds
3
+ *
4
+ * A quality ratchet is a committed JSON file that stores the best-known quality
5
+ * thresholds for the repository. Thresholds can only tighten (improve), never
6
+ * loosen. This prevents cumulative quality drift across many agent sessions.
7
+ *
8
+ * File location: .agentfactory/quality-ratchet.json (committed to repo)
9
+ *
10
+ * The ratchet is enforced at two points:
11
+ * 1. Merge queue — blocks merge if ratchet thresholds are violated
12
+ * 2. CI — runs as a required status check on every PR
13
+ */
14
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
15
+ import { resolve } from 'node:path';
16
+ // ---------------------------------------------------------------------------
17
+ // File path
18
+ // ---------------------------------------------------------------------------
19
+ const RATCHET_FILENAME = 'quality-ratchet.json';
20
+ const RATCHET_DIR = '.agentfactory';
21
+ function ratchetPath(repoRoot) {
22
+ return resolve(repoRoot, RATCHET_DIR, RATCHET_FILENAME);
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // Public API
26
+ // ---------------------------------------------------------------------------
27
+ /**
28
+ * Load the quality ratchet from disk.
29
+ * Returns null if the ratchet file does not exist.
30
+ * Throws if the file exists but is invalid.
31
+ */
32
+ export function loadQualityRatchet(repoRoot) {
33
+ const filePath = ratchetPath(repoRoot);
34
+ if (!existsSync(filePath))
35
+ return null;
36
+ const content = readFileSync(filePath, 'utf-8');
37
+ const parsed = JSON.parse(content);
38
+ // Basic validation
39
+ if (parsed.version !== 1 || !parsed.thresholds) {
40
+ throw new Error(`Invalid quality ratchet file: missing version or thresholds`);
41
+ }
42
+ return parsed;
43
+ }
44
+ /**
45
+ * Check current quality metrics against ratchet thresholds.
46
+ * Returns a result with pass/fail and any violations.
47
+ */
48
+ export function checkQualityRatchet(ratchet, current) {
49
+ const violations = [];
50
+ const { thresholds } = ratchet;
51
+ if (current.tests.total < thresholds.testCount.min) {
52
+ violations.push({
53
+ metric: 'testCount',
54
+ threshold: thresholds.testCount.min,
55
+ actual: current.tests.total,
56
+ direction: 'below-min',
57
+ });
58
+ }
59
+ if (current.tests.failed > thresholds.testFailures.max) {
60
+ violations.push({
61
+ metric: 'testFailures',
62
+ threshold: thresholds.testFailures.max,
63
+ actual: current.tests.failed,
64
+ direction: 'above-max',
65
+ });
66
+ }
67
+ if (current.typecheck.errorCount > thresholds.typecheckErrors.max) {
68
+ violations.push({
69
+ metric: 'typecheckErrors',
70
+ threshold: thresholds.typecheckErrors.max,
71
+ actual: current.typecheck.errorCount,
72
+ direction: 'above-max',
73
+ });
74
+ }
75
+ if (current.lint.errorCount > thresholds.lintErrors.max) {
76
+ violations.push({
77
+ metric: 'lintErrors',
78
+ threshold: thresholds.lintErrors.max,
79
+ actual: current.lint.errorCount,
80
+ direction: 'above-max',
81
+ });
82
+ }
83
+ return {
84
+ passed: violations.length === 0,
85
+ violations,
86
+ };
87
+ }
88
+ /**
89
+ * Tighten the quality ratchet if current metrics are better than thresholds.
90
+ * The ratchet only moves in the direction of improvement (monotonic).
91
+ *
92
+ * Returns true if the ratchet was updated, false if no improvement was found.
93
+ */
94
+ export function updateQualityRatchet(repoRoot, current, identifier) {
95
+ const existing = loadQualityRatchet(repoRoot);
96
+ if (!existing)
97
+ return false;
98
+ const updated = { ...existing, thresholds: { ...existing.thresholds } };
99
+ let changed = false;
100
+ // Test count: min can only go up
101
+ if (current.tests.total > existing.thresholds.testCount.min) {
102
+ updated.thresholds.testCount = { min: current.tests.total };
103
+ changed = true;
104
+ }
105
+ // Test failures: max can only go down
106
+ if (current.tests.failed < existing.thresholds.testFailures.max) {
107
+ updated.thresholds.testFailures = { max: current.tests.failed };
108
+ changed = true;
109
+ }
110
+ // Typecheck errors: max can only go down
111
+ if (current.typecheck.errorCount < existing.thresholds.typecheckErrors.max) {
112
+ updated.thresholds.typecheckErrors = { max: current.typecheck.errorCount };
113
+ changed = true;
114
+ }
115
+ // Lint errors: max can only go down
116
+ if (current.lint.errorCount < existing.thresholds.lintErrors.max) {
117
+ updated.thresholds.lintErrors = { max: current.lint.errorCount };
118
+ changed = true;
119
+ }
120
+ if (changed) {
121
+ updated.updatedAt = new Date().toISOString();
122
+ updated.updatedBy = identifier;
123
+ writeFileSync(ratchetPath(repoRoot), JSON.stringify(updated, null, 2) + '\n');
124
+ }
125
+ return changed;
126
+ }
127
+ /**
128
+ * Initialize a new quality ratchet file from a baseline snapshot.
129
+ * Use this when setting up quality gates for the first time.
130
+ */
131
+ export function initializeQualityRatchet(repoRoot, baseline) {
132
+ const ratchet = {
133
+ version: 1,
134
+ updatedAt: new Date().toISOString(),
135
+ updatedBy: 'manual',
136
+ thresholds: {
137
+ testCount: { min: baseline.tests.total },
138
+ testFailures: { max: baseline.tests.failed },
139
+ typecheckErrors: { max: baseline.typecheck.errorCount },
140
+ lintErrors: { max: baseline.lint.errorCount },
141
+ },
142
+ };
143
+ writeFileSync(ratchetPath(repoRoot), JSON.stringify(ratchet, null, 2) + '\n');
144
+ return ratchet;
145
+ }
146
+ /**
147
+ * Format a ratchet check result into a human-readable string.
148
+ */
149
+ export function formatRatchetResult(result) {
150
+ if (result.passed)
151
+ return 'Quality ratchet check passed.';
152
+ const lines = ['Quality ratchet check **FAILED**:', ''];
153
+ for (const v of result.violations) {
154
+ if (v.direction === 'above-max') {
155
+ lines.push(`- ${v.metric}: ${v.actual} exceeds maximum threshold of ${v.threshold}`);
156
+ }
157
+ else {
158
+ lines.push(`- ${v.metric}: ${v.actual} is below minimum threshold of ${v.threshold}`);
159
+ }
160
+ }
161
+ return lines.join('\n');
162
+ }