agentic-workflow-manager 3.5.0 → 3.7.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.
@@ -0,0 +1,114 @@
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 child_process_1 = require("child_process");
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const changed_1 = require("../../../src/commands/sensors/changed");
11
+ /** A throwaway repo. Real git, not a mock: the whole value of this module is that its
12
+ * understanding of "changed" matches git's, which a mock would define into existence. */
13
+ function repo() {
14
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-changed-'));
15
+ const run = (args) => (0, child_process_1.execFileSync)('git', args, { cwd: dir, stdio: 'ignore' });
16
+ run(['init', '-q']);
17
+ run(['config', 'user.email', 'test@example.com']);
18
+ run(['config', 'user.name', 'test']);
19
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'base.ts'), 'export const a = 1;\n');
20
+ run(['add', 'base.ts']);
21
+ run(['commit', '-qm', 'base']);
22
+ return dir;
23
+ }
24
+ describe('changedFiles', () => {
25
+ const dirs = [];
26
+ const make = () => { const d = repo(); dirs.push(d); return d; };
27
+ afterAll(() => dirs.forEach(d => fs_1.default.rmSync(d, { recursive: true, force: true })));
28
+ it('sees unstaged, staged and untracked files, not just committed ones', () => {
29
+ // The gate runs mid-work, before anything is committed. A committed-only diff
30
+ // would scope the run to a stale set and certify files nobody is editing —
31
+ // exactly the files least likely to be broken right now.
32
+ const dir = make();
33
+ fs_1.default.appendFileSync(path_1.default.join(dir, 'base.ts'), 'export const b = 2;\n'); // unstaged
34
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'staged.ts'), 'export const c = 3;\n');
35
+ (0, child_process_1.execFileSync)('git', ['add', 'staged.ts'], { cwd: dir, stdio: 'ignore' }); // staged
36
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'untracked.ts'), 'export const d = 4;\n'); // untracked
37
+ expect((0, changed_1.changedFiles)(dir).files).toEqual(['base.ts', 'staged.ts', 'untracked.ts']);
38
+ });
39
+ it('excludes gitignored files so build output never enters the scope', () => {
40
+ const dir = make();
41
+ fs_1.default.writeFileSync(path_1.default.join(dir, '.gitignore'), 'dist/\n');
42
+ fs_1.default.mkdirSync(path_1.default.join(dir, 'dist'));
43
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'dist', 'bundle.js'), 'noise');
44
+ expect((0, changed_1.changedFiles)(dir).files).not.toContain('dist/bundle.js');
45
+ });
46
+ it('omits deleted files — a sensor cannot read a path that is gone', () => {
47
+ const dir = make();
48
+ fs_1.default.rmSync(path_1.default.join(dir, 'base.ts'));
49
+ expect((0, changed_1.changedFiles)(dir).files).not.toContain('base.ts');
50
+ });
51
+ it('compares against the merge base, not the branch tip', () => {
52
+ // Guards the case where the branch is merely BEHIND its base. Diffing against
53
+ // the tip would report every file the base moved on as "changed by this
54
+ // branch", scoping the run to files this branch never touched.
55
+ const dir = make();
56
+ const run = (args) => (0, child_process_1.execFileSync)('git', args, { cwd: dir, stdio: 'ignore' });
57
+ run(['checkout', '-qb', 'feature']);
58
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'mine.ts'), 'export const mine = 1;\n');
59
+ run(['add', 'mine.ts']);
60
+ run(['commit', '-qm', 'mine']);
61
+ // main moves on independently, so `feature` is behind it.
62
+ run(['checkout', '-q', 'master']);
63
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'theirs.ts'), 'export const theirs = 1;\n');
64
+ run(['add', 'theirs.ts']);
65
+ run(['commit', '-qm', 'theirs']);
66
+ run(['checkout', '-q', 'feature']);
67
+ const files = (0, changed_1.changedFiles)(dir, 'master').files;
68
+ expect(files).toContain('mine.ts');
69
+ expect(files).not.toContain('theirs.ts');
70
+ });
71
+ it('reports an error instead of an empty scope when git cannot answer', () => {
72
+ // An empty file list and a failed lookup are the same value structurally but
73
+ // opposite in meaning: one says "nothing to check", the other "I do not know".
74
+ // Collapsing them would silently scope a run to zero files and report clean.
75
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-nogit-'));
76
+ dirs.push(dir);
77
+ const res = (0, changed_1.changedFiles)(dir);
78
+ expect(res.error).toBeDefined();
79
+ expect(res.files).toEqual([]);
80
+ });
81
+ });
82
+ describe('applyChangedCmd', () => {
83
+ it('substitutes the file list into the template', () => {
84
+ expect((0, changed_1.applyChangedCmd)('eslint --format json {files}', ['a.ts', 'b.ts']))
85
+ .toBe(`eslint --format json 'a.ts' 'b.ts'`);
86
+ });
87
+ it('quotes paths so a space cannot split one argument into two', () => {
88
+ expect((0, changed_1.applyChangedCmd)('eslint {files}', ['my dir/a.ts']))
89
+ .toBe(`eslint 'my dir/a.ts'`);
90
+ });
91
+ it("escapes a single quote in a path instead of ending the quoting", () => {
92
+ // Without the '\'' form the rest of the filename would be read as shell syntax.
93
+ expect((0, changed_1.applyChangedCmd)('eslint {files}', ["it's.ts"]))
94
+ .toBe(`eslint 'it'\\''s.ts'`);
95
+ });
96
+ });
97
+ describe('filterByExtension', () => {
98
+ it('drops files the sensor cannot be handed', () => {
99
+ // Editing a README must not turn the lint gate red: eslint given a .md fails
100
+ // rather than skipping it.
101
+ expect((0, changed_1.filterByExtension)(['src/a.ts', 'README.md', 'logo.png'], ['.ts']))
102
+ .toEqual(['src/a.ts']);
103
+ });
104
+ it('matches extensions case-insensitively', () => {
105
+ // Windows and macOS checkouts carry .TS. A case-sensitive match would drop them
106
+ // from the scope silently — the sensor would report clean over files it never saw.
107
+ expect((0, changed_1.filterByExtension)(['src/A.TS'], ['.ts'])).toEqual(['src/A.TS']);
108
+ });
109
+ it('passes everything through when the sensor declares no filter', () => {
110
+ const files = ['a.ts', 'b.md'];
111
+ expect((0, changed_1.filterByExtension)(files, undefined)).toEqual(files);
112
+ expect((0, changed_1.filterByExtension)(files, [])).toEqual(files);
113
+ });
114
+ });
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.spawnFailed = exports.overflowed = exports.timedOut = exports.exited = exports.ok = void 0;
4
+ /**
5
+ * Builders for the `ExecResult` shape that `runCommand` returns. Sensor tests
6
+ * mock the exec boundary rather than `child_process` directly: `runCommand`
7
+ * never throws, so a mocked run is a value, not an exception.
8
+ */
9
+ const base = { stdout: '', stderr: '', code: null, signal: null, timedOut: false, overflowed: false };
10
+ /** Clean run: exit 0. */
11
+ const ok = (stdout = '') => ({ ...base, stdout, code: 0 });
12
+ exports.ok = ok;
13
+ /** Ran to completion with a non-zero exit code. */
14
+ const exited = (code, stdout = '', stderr = '') => ({ ...base, stdout, stderr, code });
15
+ exports.exited = exited;
16
+ /** Cut short by the deadline. `stdout` is whatever it managed to print first. */
17
+ const timedOut = (stdout = '', stderr = '') => ({ ...base, stdout, stderr, signal: 'SIGKILL', timedOut: true });
18
+ exports.timedOut = timedOut;
19
+ /** Cut short by the output cap. */
20
+ const overflowed = (stdout = '') => ({ ...base, stdout, signal: 'SIGKILL', overflowed: true });
21
+ exports.overflowed = overflowed;
22
+ /** The shell itself never started. */
23
+ const spawnFailed = (message = 'ENOENT') => ({ ...base, spawnError: Object.assign(new Error(message), { code: 'ENOENT' }) });
24
+ exports.spawnFailed = spawnFailed;
@@ -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
+ });
@@ -0,0 +1,136 @@
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 mockChangedFiles = jest.fn();
14
+ jest.mock('../../../src/commands/sensors/changed', () => {
15
+ const actual = jest.requireActual('../../../src/commands/sensors/changed');
16
+ return { ...actual, changedFiles: (...args) => mockChangedFiles(...args) };
17
+ });
18
+ const { ok } = require('./exec-fixtures');
19
+ function project(sensors) {
20
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-changed-run-'));
21
+ fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
22
+ fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), JSON.stringify({ pack: 'js-ts', sensors }));
23
+ return dir;
24
+ }
25
+ /** The two sensors that matter: one that opted into scoping, one that cannot. */
26
+ const LINT = { fast: true, cmd: 'eslint --format json .', changedCmd: 'eslint --format json {files}' };
27
+ const TYPECHECK = { fast: true, cmd: 'tsc --noEmit' };
28
+ describe('runSensors --changed', () => {
29
+ let dir;
30
+ let prevAwmHome;
31
+ let fakeAwmHome;
32
+ beforeEach(() => {
33
+ jest.resetModules();
34
+ mockRunCommand.mockReset();
35
+ mockChangedFiles.mockReset();
36
+ mockRunCommand.mockResolvedValue(ok(''));
37
+ // CLAUDE.md: no test may reach the real ~/.awm.
38
+ fakeAwmHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-home-'));
39
+ prevAwmHome = process.env.AWM_HOME;
40
+ process.env.AWM_HOME = fakeAwmHome;
41
+ });
42
+ afterEach(() => {
43
+ process.env.AWM_HOME = prevAwmHome;
44
+ if (dir)
45
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
46
+ fs_1.default.rmSync(fakeAwmHome, { recursive: true, force: true });
47
+ });
48
+ const load = () => require('../../../src/commands/sensors/run');
49
+ const cmds = () => mockRunCommand.mock.calls.map(c => c[0]);
50
+ it('scopes a sensor that opted in and leaves one that did not at full scope', async () => {
51
+ // The core contract. tsc is whole-program: handed a subset it reports clean
52
+ // while the change breaks a caller it was never shown. Scoping is opt-in
53
+ // precisely so that sensor keeps measuring everything.
54
+ dir = project({ lint: LINT, typecheck: TYPECHECK });
55
+ mockChangedFiles.mockReturnValue({ files: ['src/a.ts'] });
56
+ await load().runSensors({ cwd: dir, changed: true });
57
+ expect(cmds()).toContain(`eslint --format json 'src/a.ts'`);
58
+ expect(cmds()).toContain('tsc --noEmit');
59
+ });
60
+ it('marks the scoped result so a scoped pass is not read as a full one', async () => {
61
+ dir = project({ lint: LINT, typecheck: TYPECHECK });
62
+ mockChangedFiles.mockReturnValue({ files: ['src/a.ts'] });
63
+ const out = await load().runSensors({ cwd: dir, changed: true });
64
+ expect(out.sensors.find((s) => s.name === 'lint').scope).toBe('changed');
65
+ expect(out.sensors.find((s) => s.name === 'typecheck').scope).toBeUndefined();
66
+ expect(out.changedScope).toEqual({ files: 1 });
67
+ });
68
+ it('falls back to the full command when the scope cannot be resolved', async () => {
69
+ // Not a git repo, git absent, bad ref. Running everything is slow; guessing at
70
+ // a narrower set would certify files nobody proved were the only ones touched.
71
+ dir = project({ lint: LINT });
72
+ mockChangedFiles.mockReturnValue({ files: [], error: 'not a git repository' });
73
+ const out = await load().runSensors({ cwd: dir, changed: true });
74
+ expect(cmds()).toContain('eslint --format json .');
75
+ expect(out.sensors[0].scope).toBeUndefined();
76
+ expect(out.changedScope).toEqual({ files: 0, error: 'not a git repository' });
77
+ });
78
+ it('skips an opted-in sensor when nothing changed, without touching the others', async () => {
79
+ dir = project({ lint: LINT, typecheck: TYPECHECK });
80
+ mockChangedFiles.mockReturnValue({ files: [] });
81
+ const out = await load().runSensors({ cwd: dir, changed: true });
82
+ const lint = out.sensors.find((s) => s.name === 'lint');
83
+ expect(lint.status).toBe('skipped');
84
+ expect(lint.skipReason).toBe('no changed files in scope');
85
+ expect(cmds()).toEqual(['tsc --noEmit']);
86
+ });
87
+ it('hands the sensor only the extensions it declared it can take', async () => {
88
+ // Without this, editing a README turns the lint gate red: eslint given a .md
89
+ // fails rather than skipping it.
90
+ dir = project({ lint: { ...LINT, changedExtensions: ['.ts', '.tsx'] } });
91
+ mockChangedFiles.mockReturnValue({ files: ['README.md', 'logo.png', 'src/a.ts'] });
92
+ await load().runSensors({ cwd: dir, changed: true });
93
+ expect(cmds()).toEqual([`eslint --format json 'src/a.ts'`]);
94
+ });
95
+ it('skips the sensor when the filter empties the scope, rather than running repo-wide', async () => {
96
+ // A docs-only commit means the lint sensor has nothing to say. Falling back to
97
+ // the full command here would reintroduce exactly the cost --changed removes.
98
+ dir = project({ lint: { ...LINT, changedExtensions: ['.ts'] }, typecheck: TYPECHECK });
99
+ mockChangedFiles.mockReturnValue({ files: ['README.md'] });
100
+ const out = await load().runSensors({ cwd: dir, changed: true });
101
+ expect(out.sensors.find((s) => s.name === 'lint').status).toBe('skipped');
102
+ expect(cmds()).toEqual(['tsc --noEmit']);
103
+ });
104
+ it('refuses a changedCmd without a {files} placeholder instead of running it repo-wide', async () => {
105
+ // Running the template as-is would cover the whole repo while the result
106
+ // claimed to be scoped. That is a mislabelled verdict, not a slow path.
107
+ dir = project({ lint: { ...LINT, changedCmd: 'eslint --format json .' } });
108
+ mockChangedFiles.mockReturnValue({ files: ['src/a.ts'] });
109
+ const out = await load().runSensors({ cwd: dir, changed: true });
110
+ expect(out.sensors[0].status).toBe('inconclusive');
111
+ expect(out.overall).toBe('not_certified');
112
+ expect(mockRunCommand).not.toHaveBeenCalled();
113
+ });
114
+ it('refuses to combine --changed with a baseline capture', async () => {
115
+ // buildBaseline snapshots the run it is given, so baselining a scoped run
116
+ // would write a baseline covering only the diff and silently drop every
117
+ // accepted finding elsewhere — which then reports as NEW on the next full run.
118
+ dir = project({ lint: LINT });
119
+ mockChangedFiles.mockReturnValue({ files: ['src/a.ts'] });
120
+ await expect(load().runSensors({ cwd: dir, changed: true, ignoreBaseline: true }))
121
+ .rejects.toThrow(/cannot define the accepted set/);
122
+ });
123
+ it('runs everything at full scope when --changed is absent', async () => {
124
+ dir = project({ lint: LINT, typecheck: TYPECHECK });
125
+ const out = await load().runSensors({ cwd: dir });
126
+ expect(cmds()).toEqual(['eslint --format json .', 'tsc --noEmit']);
127
+ expect(mockChangedFiles).not.toHaveBeenCalled();
128
+ expect(out.changedScope).toBeUndefined();
129
+ });
130
+ it('passes the requested base through to the scope resolver', async () => {
131
+ dir = project({ lint: LINT });
132
+ mockChangedFiles.mockReturnValue({ files: ['src/a.ts'] });
133
+ await load().runSensors({ cwd: dir, changed: true, base: 'main' });
134
+ expect(mockChangedFiles).toHaveBeenCalledWith(dir, 'main');
135
+ });
136
+ });
@@ -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