agentic-workflow-manager 3.4.0 → 3.6.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.
Files changed (61) hide show
  1. package/dist/src/commands/job/exec-wrapper.js +136 -0
  2. package/dist/src/commands/job/export.js +94 -0
  3. package/dist/src/commands/job/gate.js +118 -0
  4. package/dist/src/commands/job/heartbeat.js +15 -0
  5. package/dist/src/commands/job/index.js +246 -0
  6. package/dist/src/commands/job/query.js +37 -0
  7. package/dist/src/commands/job/reap.js +24 -0
  8. package/dist/src/commands/job/reconcile.js +112 -0
  9. package/dist/src/commands/job/request.js +27 -0
  10. package/dist/src/commands/sensors/exec.js +121 -0
  11. package/dist/src/commands/sensors/index.js +4 -4
  12. package/dist/src/commands/sensors/run.js +124 -71
  13. package/dist/src/commands/watch/apply.js +352 -0
  14. package/dist/src/commands/watch/generations.js +249 -0
  15. package/dist/src/commands/watch/index.js +49 -0
  16. package/dist/src/commands/watch/init.js +72 -0
  17. package/dist/src/commands/watch/lock.js +89 -0
  18. package/dist/src/commands/watch/runner.js +191 -0
  19. package/dist/src/commands/watch/supervisor.js +266 -0
  20. package/dist/src/core/atomic-file.js +31 -0
  21. package/dist/src/core/export/pack.js +7 -1
  22. package/dist/src/core/journal/adapter.js +27 -0
  23. package/dist/src/core/journal/fingerprint.js +80 -0
  24. package/dist/src/core/journal/paths.js +56 -0
  25. package/dist/src/core/journal/process.js +284 -0
  26. package/dist/src/core/journal/redact.js +142 -0
  27. package/dist/src/core/journal/requests.js +132 -0
  28. package/dist/src/core/journal/store.js +107 -0
  29. package/dist/src/core/journal/types.js +165 -0
  30. package/dist/src/index.js +4 -0
  31. package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
  32. package/dist/tests/commands/job/export.test.js +76 -0
  33. package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
  34. package/dist/tests/commands/job/reap-cli.test.js +101 -0
  35. package/dist/tests/commands/job/verbs.test.js +56 -0
  36. package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
  37. package/dist/tests/commands/sensors/exec-fixtures.js +24 -0
  38. package/dist/tests/commands/sensors/exec.test.js +91 -0
  39. package/dist/tests/commands/sensors/run-inconclusive.test.js +55 -66
  40. package/dist/tests/commands/sensors/run-partial.test.js +225 -0
  41. package/dist/tests/commands/sensors/run-tool-missing.test.js +6 -6
  42. package/dist/tests/commands/sensors/run.test.js +64 -81
  43. package/dist/tests/commands/watch/apply.test.js +397 -0
  44. package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
  45. package/dist/tests/commands/watch/generations.test.js +115 -0
  46. package/dist/tests/commands/watch/integration.test.js +124 -0
  47. package/dist/tests/commands/watch/lock.test.js +60 -0
  48. package/dist/tests/commands/watch/runner.test.js +239 -0
  49. package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
  50. package/dist/tests/commands/watch/watch-init.test.js +43 -0
  51. package/dist/tests/core/atomic-file-durable.test.js +42 -0
  52. package/dist/tests/core/journal/adapter.test.js +27 -0
  53. package/dist/tests/core/journal/fingerprint.test.js +164 -0
  54. package/dist/tests/core/journal/paths.test.js +35 -0
  55. package/dist/tests/core/journal/process.test.js +213 -0
  56. package/dist/tests/core/journal/redact.test.js +59 -0
  57. package/dist/tests/core/journal/requests.test.js +134 -0
  58. package/dist/tests/core/journal/store.test.js +88 -0
  59. package/dist/tests/core/journal/types.test.js +78 -0
  60. package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
  61. package/package.json +1 -1
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const exec_1 = require("../../../src/commands/sensors/exec");
10
+ const onPosix = process.platform !== 'win32' ? describe : describe.skip;
11
+ /** Poll until `fn()` is true or the budget runs out. Avoids fixed sleeps. */
12
+ async function until(fn, budgetMs = 4000) {
13
+ const deadline = Date.now() + budgetMs;
14
+ while (Date.now() < deadline) {
15
+ if (fn())
16
+ return true;
17
+ await new Promise(r => setTimeout(r, 25));
18
+ }
19
+ return fn();
20
+ }
21
+ describe('runCommand — exit codes and output', () => {
22
+ it('returns stdout and code 0 for a clean command', async () => {
23
+ const r = await (0, exec_1.runCommand)('echo hello', { timeout: 5000, cwd: process.cwd() });
24
+ expect(r.code).toBe(0);
25
+ expect(r.stdout.trim()).toBe('hello');
26
+ expect(r.timedOut).toBe(false);
27
+ expect(r.overflowed).toBe(false);
28
+ });
29
+ it('captures stderr and a non-zero exit code without throwing', async () => {
30
+ const r = await (0, exec_1.runCommand)('echo oops 1>&2; exit 3', { timeout: 5000, cwd: process.cwd() });
31
+ expect(r.code).toBe(3);
32
+ expect(r.stderr).toMatch(/oops/);
33
+ expect(r.timedOut).toBe(false);
34
+ });
35
+ it('reports 127 for a command that does not exist', async () => {
36
+ const r = await (0, exec_1.runCommand)('awm-definitely-not-a-real-binary-xyz', { timeout: 5000, cwd: process.cwd() });
37
+ expect(r.code).toBe(127);
38
+ });
39
+ });
40
+ describe('runCommand — output cap', () => {
41
+ it('stops at maxBuffer, flags overflow, and keeps what it read', async () => {
42
+ // 200 lines of ~50 bytes each, capped at 1KB.
43
+ const r = await (0, exec_1.runCommand)(`for i in $(seq 1 200); do echo "line-$i-padding-padding-padding-padding"; done`, {
44
+ timeout: 10_000, cwd: process.cwd(), maxBuffer: 1024,
45
+ });
46
+ expect(r.overflowed).toBe(true);
47
+ expect(r.stdout.length).toBeLessThanOrEqual(1024);
48
+ // The point of the cap change: what was read is still usable, not discarded.
49
+ expect(r.stdout).toMatch(/line-1-/);
50
+ });
51
+ });
52
+ onPosix('runCommand — timeout', () => {
53
+ it('flags the timeout and returns the output produced before the deadline', async () => {
54
+ const r = await (0, exec_1.runCommand)('echo partial-finding; sleep 30', { timeout: 700, cwd: process.cwd() });
55
+ expect(r.timedOut).toBe(true);
56
+ // This is the whole point of dropping execSync: 700ms of work is not thrown away.
57
+ expect(r.stdout).toMatch(/partial-finding/);
58
+ });
59
+ it('kills the grandchild process, not just the shell it spawned', async () => {
60
+ // Models `npx tsc --noEmit`: the sensor command is a wrapper that spawns the
61
+ // real tool. execSync SIGTERMs only the shell it started, leaving the tool
62
+ // running and reparented to init — the leak that compounds across retries.
63
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-exec-group-'));
64
+ const beat = path_1.default.join(dir, 'beat');
65
+ const worker = path_1.default.join(dir, 'worker.js');
66
+ fs_1.default.writeFileSync(worker, `
67
+ const fs = require('fs');
68
+ setInterval(() => fs.writeFileSync(${JSON.stringify(beat)}, String(Date.now())), 30);
69
+ setTimeout(() => {}, 60000);
70
+ `);
71
+ try {
72
+ const r = await (0, exec_1.runCommand)(`sh -c "node ${worker} & wait"`, { timeout: 800, cwd: dir });
73
+ expect(r.timedOut).toBe(true);
74
+ // The worker must have been alive before the kill, or the test proves nothing.
75
+ expect(await until(() => fs_1.default.existsSync(beat))).toBe(true);
76
+ const atKill = fs_1.default.readFileSync(beat, 'utf-8');
77
+ const stillBeating = await until(() => fs_1.default.readFileSync(beat, 'utf-8') !== atKill, 1000);
78
+ expect(stillBeating).toBe(false);
79
+ }
80
+ finally {
81
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ }, 15_000);
84
+ });
85
+ describe('runCommand — spawn failure', () => {
86
+ it('surfaces a spawn error instead of hanging', async () => {
87
+ const r = await (0, exec_1.runCommand)('echo hi', { timeout: 5000, cwd: path_1.default.join(os_1.default.tmpdir(), 'awm-no-such-dir-xyz') });
88
+ expect(r.spawnError).toBeDefined();
89
+ expect(r.code).not.toBe(0);
90
+ });
91
+ });
@@ -6,10 +6,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const fs_1 = __importDefault(require("fs"));
7
7
  const os_1 = __importDefault(require("os"));
8
8
  const path_1 = __importDefault(require("path"));
9
- const mockExecSyncFn = jest.fn();
10
- jest.mock('child_process', () => ({
11
- execSync: (...args) => mockExecSyncFn(...args),
9
+ const mockRunCommand = jest.fn();
10
+ jest.mock('../../../src/commands/sensors/exec', () => ({
11
+ runCommand: (...args) => mockRunCommand(...args),
12
12
  }));
13
+ const { ok, exited, timedOut, overflowed } = require('./exec-fixtures');
13
14
  /** Sensors run in manifest insertion order, so mocks are queued in that order. */
14
15
  const MANIFEST = {
15
16
  pack: 'js-ts',
@@ -18,14 +19,14 @@ const MANIFEST = {
18
19
  security: { cmd: 'semgrep .', fast: false },
19
20
  },
20
21
  };
21
- const timeoutError = () => { throw Object.assign(new Error('killed'), { code: 'ETIMEDOUT' }); };
22
+ const timeoutError = () => timedOut();
22
23
  describe('runSensors — inconclusive: a sensor that could not certify is never green', () => {
23
24
  let root;
24
25
  let fakeAwmHome;
25
26
  let prevAwmHome;
26
27
  beforeEach(() => {
27
28
  jest.resetModules();
28
- mockExecSyncFn.mockReset();
29
+ mockRunCommand.mockReset();
29
30
  root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-inconclusive-'));
30
31
  fs_1.default.mkdirSync(path_1.default.join(root, '.awm'), { recursive: true });
31
32
  fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify(MANIFEST));
@@ -40,55 +41,51 @@ describe('runSensors — inconclusive: a sensor that could not certify is never
40
41
  fs_1.default.rmSync(fakeAwmHome, { recursive: true, force: true });
41
42
  });
42
43
  const load = () => require('../../../src/commands/sensors/run');
43
- it('reports a timed-out sensor as inconclusive, keeping its reason', () => {
44
- mockExecSyncFn
45
- .mockReturnValueOnce('') // typecheck: clean
46
- .mockImplementationOnce(timeoutError); // security: times out
44
+ it('reports a timed-out sensor as inconclusive, keeping its reason', async () => {
45
+ mockRunCommand
46
+ .mockResolvedValueOnce(ok()) // typecheck: clean
47
+ .mockResolvedValueOnce(timeoutError()); // security: times out
47
48
  const { runSensors } = load();
48
- const out = runSensors({ cwd: root });
49
+ const out = await runSensors({ cwd: root });
49
50
  const security = out.sensors.find((s) => s.name === 'security');
50
51
  expect(security.status).toBe('inconclusive');
51
52
  expect(security.skipReason).toMatch(/timeout/);
52
53
  });
53
- it('does not let a healthy sensor carry the run to pass while another could not certify', () => {
54
- mockExecSyncFn
55
- .mockReturnValueOnce('')
56
- .mockImplementationOnce(timeoutError);
54
+ it('does not let a healthy sensor carry the run to pass while another could not certify', async () => {
55
+ mockRunCommand
56
+ .mockResolvedValueOnce(ok())
57
+ .mockResolvedValueOnce(timeoutError());
57
58
  const { runSensors } = load();
58
- const out = runSensors({ cwd: root });
59
+ const out = await runSensors({ cwd: root });
59
60
  expect(out.sensors.find((s) => s.name === 'typecheck').status).toBe('pass');
60
61
  expect(out.overall).toBe('not_certified');
61
62
  });
62
- it('reports a sensor whose output was truncated as inconclusive', () => {
63
- mockExecSyncFn
64
- .mockReturnValueOnce('')
65
- .mockImplementationOnce(() => { throw Object.assign(new Error('too big'), { code: 'ENOBUFS' }); });
63
+ it('reports a sensor whose output was truncated as inconclusive', async () => {
64
+ mockRunCommand
65
+ .mockResolvedValueOnce(ok())
66
+ .mockResolvedValueOnce(overflowed());
66
67
  const { runSensors } = load();
67
- const out = runSensors({ cwd: root });
68
+ const out = await runSensors({ cwd: root });
68
69
  const security = out.sensors.find((s) => s.name === 'security');
69
70
  expect(security.status).toBe('inconclusive');
70
71
  expect(security.skipReason).toMatch(/exceeded/);
71
72
  expect(out.overall).toBe('not_certified');
72
73
  });
73
- it('reports an uninterpretable non-zero exit as inconclusive', () => {
74
- mockExecSyncFn
75
- .mockReturnValueOnce('')
76
- .mockImplementationOnce(() => {
74
+ it('reports an uninterpretable non-zero exit as inconclusive', async () => {
75
+ mockRunCommand
76
+ .mockResolvedValueOnce(ok())
77
77
  // semgrep formatter yields no findings for non-JSON output, the
78
78
  // tool is present (exit 2, not 127), and `security` is not an
79
79
  // exit-code sensor — the residual "I don't know" case.
80
- throw Object.assign(new Error('failed'), {
81
- stdout: '', stderr: 'internal error: rule engine crashed\n', status: 2,
82
- });
83
- });
80
+ .mockResolvedValueOnce(exited(2, '', 'internal error: rule engine crashed\n'));
84
81
  const { runSensors } = load();
85
- const out = runSensors({ cwd: root });
82
+ const out = await runSensors({ cwd: root });
86
83
  const security = out.sensors.find((s) => s.name === 'security');
87
84
  expect(security.status).toBe('inconclusive');
88
85
  expect(security.skipReason).toMatch(/exit 2/);
89
86
  expect(out.overall).toBe('not_certified');
90
87
  });
91
- it('reports an enabled sensor with no cmd as inconclusive', () => {
88
+ it('reports an enabled sensor with no cmd as inconclusive', async () => {
92
89
  fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
93
90
  pack: 'js-ts',
94
91
  sensors: {
@@ -96,29 +93,25 @@ describe('runSensors — inconclusive: a sensor that could not certify is never
96
93
  depcheck: { fast: false }, // enabled, but nothing to run
97
94
  },
98
95
  }));
99
- mockExecSyncFn.mockReturnValueOnce(''); // typecheck: clean
96
+ mockRunCommand.mockResolvedValueOnce(ok()); // typecheck: clean
100
97
  const { runSensors } = load();
101
- const out = runSensors({ cwd: root });
98
+ const out = await runSensors({ cwd: root });
102
99
  const depcheck = out.sensors.find((s) => s.name === 'depcheck');
103
100
  expect(depcheck.status).toBe('inconclusive');
104
101
  expect(depcheck.skipReason).toBe('no cmd configured');
105
102
  expect(out.overall).toBe('not_certified');
106
103
  });
107
- it('reports fail, not not_certified, when something is broken and something could not run', () => {
108
- mockExecSyncFn
109
- .mockImplementationOnce(() => {
110
- throw Object.assign(new Error(), {
111
- stdout: 'src/a.ts(1,1): error TS0001: Bad type.', stderr: '', status: 1,
112
- });
113
- })
114
- .mockImplementationOnce(timeoutError); // security: times out
104
+ it('reports fail, not not_certified, when something is broken and something could not run', async () => {
105
+ mockRunCommand
106
+ .mockResolvedValueOnce(exited(1, 'src/a.ts(1,1): error TS0001: Bad type.')) // typecheck: real findings
107
+ .mockResolvedValueOnce(timeoutError()); // security: times out
115
108
  const { runSensors } = load();
116
- const out = runSensors({ cwd: root });
109
+ const out = await runSensors({ cwd: root });
117
110
  expect(out.sensors.find((s) => s.name === 'typecheck').status).toBe('fail');
118
111
  expect(out.sensors.find((s) => s.name === 'security').status).toBe('inconclusive');
119
112
  expect(out.overall).toBe('fail');
120
113
  });
121
- it('never emits an overall value outside the published domain', () => {
114
+ it('never emits an overall value outside the published domain', async () => {
122
115
  // `inconclusive` is a per-sensor status only. External consumers (the
123
116
  // registry skills) read `overall`, whose domain must not grow — this
124
117
  // pins that invariant at runtime on a three-sensor pass+fail+inconclusive
@@ -137,20 +130,16 @@ describe('runSensors — inconclusive: a sensor that could not certify is never
137
130
  },
138
131
  }));
139
132
  const DOMAIN = ['pass', 'fail', 'skipped', 'not_certified'];
140
- mockExecSyncFn
141
- .mockImplementationOnce(() => {
142
- throw Object.assign(new Error(), {
143
- stdout: 'src/a.ts(1,1): error TS0001: Bad type.', stderr: '', status: 1,
144
- });
145
- })
146
- .mockReturnValueOnce('') // lint: clean → pass
147
- .mockImplementationOnce(timeoutError); // security: times out → inconclusive
133
+ mockRunCommand
134
+ .mockResolvedValueOnce(exited(1, 'src/a.ts(1,1): error TS0001: Bad type.')) // typecheck: real findings → fail
135
+ .mockResolvedValueOnce(ok()) // lint: clean → pass
136
+ .mockResolvedValueOnce(timeoutError()); // security: times out inconclusive
148
137
  const { runSensors } = load();
149
- const out = runSensors({ cwd: root });
138
+ const out = await runSensors({ cwd: root });
150
139
  expect(DOMAIN).toContain(out.overall);
151
140
  expect(out.overall).not.toBe('inconclusive');
152
141
  });
153
- it('keeps a deliberately disabled sensor apart from one that could not certify', () => {
142
+ it('keeps a deliberately disabled sensor apart from one that could not certify', async () => {
154
143
  fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
155
144
  pack: 'js-ts',
156
145
  sensors: {
@@ -158,17 +147,17 @@ describe('runSensors — inconclusive: a sensor that could not certify is never
158
147
  mutation: { cmd: 'npx stryker run', enabled: false },
159
148
  },
160
149
  }));
161
- mockExecSyncFn.mockImplementationOnce(timeoutError); // security: times out
150
+ mockRunCommand.mockResolvedValueOnce(timeoutError()); // security: times out
162
151
  // mutation: never invoked
163
152
  const { runSensors } = load();
164
- const out = runSensors({ cwd: root });
153
+ const out = await runSensors({ cwd: root });
165
154
  // Same run, two different meanings — the whole point of the split.
166
155
  expect(out.sensors.find((s) => s.name === 'mutation').status).toBe('skipped');
167
156
  expect(out.sensors.find((s) => s.name === 'mutation').skipReason).toBe('disabled');
168
157
  expect(out.sensors.find((s) => s.name === 'security').status).toBe('inconclusive');
169
158
  expect(out.overall).toBe('not_certified');
170
159
  });
171
- it('does not degrade the verdict for a disabled sensor alongside healthy ones', () => {
160
+ it('does not degrade the verdict for a disabled sensor alongside healthy ones', async () => {
172
161
  fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
173
162
  pack: 'js-ts',
174
163
  sensors: {
@@ -176,12 +165,12 @@ describe('runSensors — inconclusive: a sensor that could not certify is never
176
165
  mutation: { cmd: 'npx stryker run', enabled: false },
177
166
  },
178
167
  }));
179
- mockExecSyncFn.mockReturnValueOnce('');
168
+ mockRunCommand.mockResolvedValueOnce(ok());
180
169
  const { runSensors } = load();
181
- const out = runSensors({ cwd: root });
170
+ const out = await runSensors({ cwd: root });
182
171
  expect(out.overall).toBe('pass');
183
172
  });
184
- it('still refuses to certify a tree whose sensors are all disabled', () => {
173
+ it('still refuses to certify a tree whose sensors are all disabled', async () => {
185
174
  fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
186
175
  pack: 'js-ts',
187
176
  sensors: {
@@ -191,25 +180,25 @@ describe('runSensors — inconclusive: a sensor that could not certify is never
191
180
  }));
192
181
  fs_1.default.writeFileSync(path_1.default.join(root, 'package.json'), '{}'); // real stack indicator
193
182
  const { runSensors } = load();
194
- const out = runSensors({ cwd: root });
183
+ const out = await runSensors({ cwd: root });
195
184
  expect(out.sensors.every((s) => s.status === 'skipped')).toBe(true);
196
185
  expect(out.overall).toBe('not_certified');
197
- expect(mockExecSyncFn).not.toHaveBeenCalled();
186
+ expect(mockRunCommand).not.toHaveBeenCalled();
198
187
  });
199
- it('leaves an inconclusive result untouched when a baseline is applied', () => {
188
+ it('leaves an inconclusive result untouched when a baseline is applied', async () => {
200
189
  const { writeBaseline } = require('../../../src/commands/sensors/baseline');
201
190
  writeBaseline(root, { security: ['some-accepted-fingerprint'] });
202
- mockExecSyncFn
203
- .mockReturnValueOnce('')
204
- .mockImplementationOnce(timeoutError);
191
+ mockRunCommand
192
+ .mockResolvedValueOnce(ok())
193
+ .mockResolvedValueOnce(timeoutError());
205
194
  const { runSensors } = load();
206
- const out = runSensors({ cwd: root });
195
+ const out = await runSensors({ cwd: root });
207
196
  const security = out.sensors.find((s) => s.name === 'security');
208
197
  expect(security.status).toBe('inconclusive');
209
198
  expect(security.baselineCount).toBeUndefined();
210
199
  expect(out.overall).toBe('not_certified');
211
200
  });
212
- it('applyBaseline leaves an inconclusive result untouched even if it somehow carried findings', () => {
201
+ it('applyBaseline leaves an inconclusive result untouched even if it somehow carried findings', async () => {
213
202
  // Every current `inconclusive` producer sets `errors: []`, so a test built
214
203
  // on the public `runSensors()` API can't tell "the explicit guard fired"
215
204
  // apart from "fell through to partition() and incidentally suppressed 0
@@ -0,0 +1,225 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const mockRunCommand = jest.fn();
10
+ jest.mock('../../../src/commands/sensors/exec', () => ({
11
+ runCommand: (...args) => mockRunCommand(...args),
12
+ }));
13
+ const { ok, timedOut, overflowed } = require('./exec-fixtures');
14
+ const TS_FINDING = 'src/a.ts(1,1): error TS0001: Bad type.';
15
+ function project(sensors) {
16
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-partial-'));
17
+ fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
18
+ fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), JSON.stringify({ pack: 'js-ts', sensors }));
19
+ return dir;
20
+ }
21
+ describe('runSensors — a cut-short run keeps the findings it did produce', () => {
22
+ let dir;
23
+ let prevAwmHome;
24
+ let fakeAwmHome;
25
+ beforeEach(() => {
26
+ jest.resetModules();
27
+ mockRunCommand.mockReset();
28
+ // CLAUDE.md: no test may reach the real ~/.awm.
29
+ fakeAwmHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-home-'));
30
+ prevAwmHome = process.env.AWM_HOME;
31
+ process.env.AWM_HOME = fakeAwmHome;
32
+ });
33
+ afterEach(() => {
34
+ process.env.AWM_HOME = prevAwmHome;
35
+ if (dir)
36
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
37
+ fs_1.default.rmSync(fakeAwmHome, { recursive: true, force: true });
38
+ });
39
+ const load = () => require('../../../src/commands/sensors/run');
40
+ it('reports findings from partial output as fail instead of discarding them', async () => {
41
+ // The regression this guards: the old runner threw away everything a
42
+ // timed-out sensor had printed, so a 60s lint run that had already found
43
+ // real errors reported zero — and the caller re-ran it by hand to learn
44
+ // what it had just paid for.
45
+ dir = project({ typecheck: { cmd: 'npx tsc --noEmit', fast: true } });
46
+ mockRunCommand.mockResolvedValueOnce(timedOut(TS_FINDING));
47
+ const { runSensors } = load();
48
+ const out = await runSensors({ cwd: dir });
49
+ const tc = out.sensors.find((s) => s.name === 'typecheck');
50
+ expect(tc.status).toBe('fail');
51
+ expect(tc.errors).toHaveLength(1);
52
+ expect(tc.errors[0].message).toMatch(/Bad type/);
53
+ expect(out.overall).toBe('fail');
54
+ });
55
+ it('marks the partial fail as incomplete so absence of findings is not read as coverage', async () => {
56
+ dir = project({ typecheck: { cmd: 'npx tsc --noEmit', fast: true, timeout: 30000 } });
57
+ mockRunCommand.mockResolvedValueOnce(timedOut(TS_FINDING));
58
+ const { runSensors } = load();
59
+ const out = await runSensors({ cwd: dir });
60
+ const tc = out.sensors.find((s) => s.name === 'typecheck');
61
+ expect(tc.incomplete).toMatch(/timeout after 30000ms/);
62
+ expect(tc.incomplete).toMatch(/did not finish/);
63
+ });
64
+ it('still refuses to certify when the partial output is clean', async () => {
65
+ // A clean partial proves nothing — the findings could all be in the part
66
+ // that never ran. This must stay inconclusive, never pass.
67
+ dir = project({ typecheck: { cmd: 'npx tsc --noEmit', fast: true } });
68
+ mockRunCommand.mockResolvedValueOnce(timedOut('Checking 400 files...\n'));
69
+ const { runSensors } = load();
70
+ const out = await runSensors({ cwd: dir });
71
+ const tc = out.sensors.find((s) => s.name === 'typecheck');
72
+ expect(tc.status).toBe('inconclusive');
73
+ expect(tc.skipReason).toMatch(/timeout/);
74
+ expect(tc.incomplete).toBeUndefined();
75
+ expect(out.overall).toBe('not_certified');
76
+ });
77
+ it('applies the same rule to output-cap overflow', async () => {
78
+ dir = project({ typecheck: { cmd: 'npx tsc --noEmit', fast: true } });
79
+ mockRunCommand.mockResolvedValueOnce(overflowed(TS_FINDING));
80
+ const { runSensors } = load();
81
+ const out = await runSensors({ cwd: dir });
82
+ const tc = out.sensors.find((s) => s.name === 'typecheck');
83
+ expect(tc.status).toBe('fail');
84
+ expect(tc.incomplete).toMatch(/exceeded/);
85
+ });
86
+ it('fails the sensor when the shell could not be started', async () => {
87
+ const { spawnFailed } = require('./exec-fixtures');
88
+ dir = project({ typecheck: { cmd: 'npx tsc --noEmit', fast: true } });
89
+ mockRunCommand.mockResolvedValueOnce(spawnFailed('spawn /bin/sh ENOENT'));
90
+ const { runSensors } = load();
91
+ const out = await runSensors({ cwd: dir });
92
+ const tc = out.sensors.find((s) => s.name === 'typecheck');
93
+ expect(tc.status).toBe('fail');
94
+ expect(tc.errors[0].message).toMatch(/could not be started/);
95
+ expect(out.overall).toBe('fail');
96
+ });
97
+ it('lets the baseline suppress a finding that came from partial output', async () => {
98
+ dir = project({ typecheck: { cmd: 'npx tsc --noEmit', fast: true } });
99
+ const { buildBaseline, writeBaseline } = require('../../../src/commands/sensors/baseline');
100
+ const { parseTscOutput } = require('../../../src/commands/sensors/formatters/tsc');
101
+ writeBaseline(dir, buildBaseline([{ name: 'typecheck', errors: parseTscOutput(TS_FINDING) }]));
102
+ mockRunCommand.mockResolvedValueOnce(timedOut(TS_FINDING));
103
+ const { runSensors } = load();
104
+ const out = await runSensors({ cwd: dir });
105
+ const tc = out.sensors.find((s) => s.name === 'typecheck');
106
+ expect(tc.status).toBe('pass');
107
+ expect(tc.baselineCount).toBe(1);
108
+ });
109
+ });
110
+ describe('runSensors — sensors run concurrently', () => {
111
+ let dir;
112
+ let prevAwmHome;
113
+ let prevConcurrency;
114
+ let fakeAwmHome;
115
+ beforeEach(() => {
116
+ jest.resetModules();
117
+ mockRunCommand.mockReset();
118
+ fakeAwmHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-home-'));
119
+ prevAwmHome = process.env.AWM_HOME;
120
+ prevConcurrency = process.env.AWM_SENSORS_CONCURRENCY;
121
+ process.env.AWM_HOME = fakeAwmHome;
122
+ });
123
+ afterEach(() => {
124
+ process.env.AWM_HOME = prevAwmHome;
125
+ if (prevConcurrency === undefined)
126
+ delete process.env.AWM_SENSORS_CONCURRENCY;
127
+ else
128
+ process.env.AWM_SENSORS_CONCURRENCY = prevConcurrency;
129
+ if (dir)
130
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
131
+ fs_1.default.rmSync(fakeAwmHome, { recursive: true, force: true });
132
+ });
133
+ const load = () => require('../../../src/commands/sensors/run');
134
+ /** Resolves after `ms`, recording when it started and finished. */
135
+ const timed = (log, label, ms) => () => new Promise((resolve) => {
136
+ log.push([`${label}:start`, Date.now()]);
137
+ setTimeout(() => { log.push([`${label}:end`, Date.now()]); resolve(ok()); }, ms);
138
+ });
139
+ const THREE = {
140
+ typecheck: { cmd: 'a', fast: true },
141
+ lint: { cmd: 'b', fast: true },
142
+ security: { cmd: 'c', fast: true },
143
+ };
144
+ it('starts every sensor before the first one finishes', async () => {
145
+ process.env.AWM_SENSORS_CONCURRENCY = '3';
146
+ dir = project(THREE);
147
+ const log = [];
148
+ mockRunCommand
149
+ .mockImplementationOnce(timed(log, 'typecheck', 120))
150
+ .mockImplementationOnce(timed(log, 'lint', 120))
151
+ .mockImplementationOnce(timed(log, 'security', 120));
152
+ const { runSensors } = load();
153
+ await runSensors({ cwd: dir });
154
+ const order = log.map(([label]) => label);
155
+ // All three starts precede the first end — that is what serial execution
156
+ // could not do, and the reason wall clock stops being the sum.
157
+ expect(order.slice(0, 3)).toEqual(['typecheck:start', 'lint:start', 'security:start']);
158
+ expect(order[3]).toMatch(/:end$/);
159
+ });
160
+ it('honours a concurrency of 1 by running them strictly one at a time', async () => {
161
+ process.env.AWM_SENSORS_CONCURRENCY = '1';
162
+ dir = project(THREE);
163
+ const log = [];
164
+ mockRunCommand
165
+ .mockImplementationOnce(timed(log, 'typecheck', 30))
166
+ .mockImplementationOnce(timed(log, 'lint', 30))
167
+ .mockImplementationOnce(timed(log, 'security', 30));
168
+ const { runSensors } = load();
169
+ await runSensors({ cwd: dir });
170
+ expect(log.map(([label]) => label)).toEqual([
171
+ 'typecheck:start', 'typecheck:end',
172
+ 'lint:start', 'lint:end',
173
+ 'security:start', 'security:end',
174
+ ]);
175
+ });
176
+ it('reports results in manifest order regardless of which sensor finishes first', async () => {
177
+ process.env.AWM_SENSORS_CONCURRENCY = '3';
178
+ dir = project(THREE);
179
+ const log = [];
180
+ // Deliberately inverted durations: security finishes first, typecheck last.
181
+ mockRunCommand
182
+ .mockImplementationOnce(timed(log, 'typecheck', 90))
183
+ .mockImplementationOnce(timed(log, 'lint', 50))
184
+ .mockImplementationOnce(timed(log, 'security', 10));
185
+ const { runSensors } = load();
186
+ const out = await runSensors({ cwd: dir });
187
+ expect(out.sensors.map((s) => s.name)).toEqual(['typecheck', 'lint', 'security']);
188
+ });
189
+ });
190
+ describe('resolveConcurrency', () => {
191
+ const load = () => require('../../../src/commands/sensors/run');
192
+ let prev;
193
+ beforeEach(() => { jest.resetModules(); prev = process.env.AWM_SENSORS_CONCURRENCY; });
194
+ afterEach(() => {
195
+ if (prev === undefined)
196
+ delete process.env.AWM_SENSORS_CONCURRENCY;
197
+ else
198
+ process.env.AWM_SENSORS_CONCURRENCY = prev;
199
+ });
200
+ it('never exceeds the number of sensors to run', () => {
201
+ delete process.env.AWM_SENSORS_CONCURRENCY;
202
+ const { resolveConcurrency } = load();
203
+ expect(resolveConcurrency({ pack: 'js-ts', sensors: {} }, 1)).toBe(1);
204
+ });
205
+ it('caps at 4 even on a large box', () => {
206
+ delete process.env.AWM_SENSORS_CONCURRENCY;
207
+ const { resolveConcurrency } = load();
208
+ expect(resolveConcurrency({ pack: 'js-ts', sensors: {} }, 32)).toBeLessThanOrEqual(4);
209
+ });
210
+ it('lets the manifest pin it', () => {
211
+ delete process.env.AWM_SENSORS_CONCURRENCY;
212
+ const { resolveConcurrency } = load();
213
+ expect(resolveConcurrency({ pack: 'js-ts', sensors: {}, concurrency: 2 }, 8)).toBe(2);
214
+ });
215
+ it('lets the environment override the manifest', () => {
216
+ process.env.AWM_SENSORS_CONCURRENCY = '1';
217
+ const { resolveConcurrency } = load();
218
+ expect(resolveConcurrency({ pack: 'js-ts', sensors: {}, concurrency: 4 }, 8)).toBe(1);
219
+ });
220
+ it('ignores nonsense and falls back to the derived cap', () => {
221
+ process.env.AWM_SENSORS_CONCURRENCY = 'banana';
222
+ const { resolveConcurrency } = load();
223
+ expect(resolveConcurrency({ pack: 'js-ts', sensors: {} }, 8)).toBeGreaterThanOrEqual(1);
224
+ });
225
+ });
@@ -36,23 +36,23 @@ describe('runSensors — an absent tool never reads as green (real /bin/sh)', ()
36
36
  roots.push(root);
37
37
  return root;
38
38
  };
39
- it('marks a sensor whose binary is absent as fail, not skipped', () => {
39
+ it('marks a sensor whose binary is absent as fail, not skipped', async () => {
40
40
  const root = project({ security: { cmd: `${MISSING_BIN} .`, fast: true } });
41
- const out = (0, run_1.runSensors)({ cwd: root });
41
+ const out = await (0, run_1.runSensors)({ cwd: root });
42
42
  const security = out.sensors.find(s => s.name === 'security');
43
43
  expect(security.status).toBe('fail');
44
44
  expect(security.errors[0].message).toMatch(/not available/i);
45
45
  });
46
- it('does not let a healthy sensor carry the run to pass while another tool is absent', () => {
46
+ it('does not let a healthy sensor carry the run to pass while another tool is absent', async () => {
47
47
  const root = project({
48
48
  typecheck: { cmd: 'node -e ""', fast: true },
49
49
  security: { cmd: `${MISSING_BIN} .`, fast: true },
50
50
  });
51
- const out = (0, run_1.runSensors)({ cwd: root });
51
+ const out = await (0, run_1.runSensors)({ cwd: root });
52
52
  expect(out.sensors.find(s => s.name === 'typecheck').status).toBe('pass');
53
53
  expect(out.overall).toBe('fail');
54
54
  });
55
- it('does not misread a tool that ran and merely printed "not found" as an absent tool', () => {
55
+ it('does not misread a tool that ran and merely printed "not found" as an absent tool', async () => {
56
56
  // Exits 1, not 127: the binary existed and reported something of its own.
57
57
  // Classifying this as a missing tool would be a false accusation. It also
58
58
  // must not read as a benign 'skipped': the formatter parsed no findings
@@ -61,7 +61,7 @@ describe('runSensors — an absent tool never reads as green (real /bin/sh)', ()
61
61
  const root = project({
62
62
  security: { cmd: `node -e "console.error('rule pack not found'); process.exit(1)"`, fast: true },
63
63
  });
64
- const out = (0, run_1.runSensors)({ cwd: root });
64
+ const out = await (0, run_1.runSensors)({ cwd: root });
65
65
  const security = out.sensors.find(s => s.name === 'security');
66
66
  expect(security.status).toBe('inconclusive');
67
67
  expect(security.errors).toEqual([]);