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.
- package/dist/src/commands/sensors/changed.js +95 -0
- package/dist/src/commands/sensors/exec.js +121 -0
- package/dist/src/commands/sensors/index.js +9 -4
- package/dist/src/commands/sensors/init.js +8 -0
- package/dist/src/commands/sensors/run.js +173 -71
- package/dist/tests/commands/sensors/changed.test.js +114 -0
- package/dist/tests/commands/sensors/exec-fixtures.js +24 -0
- package/dist/tests/commands/sensors/exec.test.js +91 -0
- package/dist/tests/commands/sensors/run-changed.test.js +136 -0
- package/dist/tests/commands/sensors/run-inconclusive.test.js +55 -66
- package/dist/tests/commands/sensors/run-partial.test.js +225 -0
- package/dist/tests/commands/sensors/run-tool-missing.test.js +6 -6
- package/dist/tests/commands/sensors/run.test.js +64 -81
- package/package.json +1 -1
|
@@ -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
|
|
10
|
-
jest.mock('
|
|
11
|
-
|
|
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 = () =>
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
.
|
|
46
|
-
.
|
|
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
|
-
|
|
55
|
-
.
|
|
56
|
-
.
|
|
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
|
-
|
|
64
|
-
.
|
|
65
|
-
.
|
|
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
|
-
|
|
75
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
.
|
|
110
|
-
|
|
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
|
-
|
|
141
|
-
.
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
203
|
-
.
|
|
204
|
-
.
|
|
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
|