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,95 @@
|
|
|
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
|
+
exports.changedFiles = changedFiles;
|
|
7
|
+
exports.applyChangedCmd = applyChangedCmd;
|
|
8
|
+
exports.filterByExtension = filterByExtension;
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
function git(args, cwd) {
|
|
12
|
+
return (0, child_process_1.execFileSync)('git', args, {
|
|
13
|
+
cwd,
|
|
14
|
+
encoding: 'utf-8',
|
|
15
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The default comparison point. `merge-base HEAD <base>` — not `base` directly —
|
|
20
|
+
* so a branch that is merely *behind* its base does not report every file the base
|
|
21
|
+
* moved on as "changed" by this branch.
|
|
22
|
+
*/
|
|
23
|
+
function mergeBase(base, cwd) {
|
|
24
|
+
return git(['merge-base', 'HEAD', base], cwd).trim();
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Files this working tree changed relative to `base`.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately a union of four sources: the committed diff since the merge base,
|
|
30
|
+
* plus staged, unstaged and untracked files. A sensor gate runs mid-work, where the
|
|
31
|
+
* interesting edits are usually not committed yet — a committed-only diff would scope
|
|
32
|
+
* the run to a stale set and certify files nobody is editing.
|
|
33
|
+
*
|
|
34
|
+
* Untracked files are included but ignored files are not (`--exclude-standard`), so
|
|
35
|
+
* `node_modules` and build output never enter the scope.
|
|
36
|
+
*
|
|
37
|
+
* Never throws: a failure to resolve the scope returns `error`, and the caller
|
|
38
|
+
* degrades to the unscoped command rather than guessing at a narrower one.
|
|
39
|
+
*/
|
|
40
|
+
function changedFiles(cwd, base = 'HEAD') {
|
|
41
|
+
let out = [];
|
|
42
|
+
try {
|
|
43
|
+
// `HEAD` means "everything not yet committed" — no merge-base needed, and it
|
|
44
|
+
// is the right default for a gate that runs before the work is committed.
|
|
45
|
+
if (base !== 'HEAD') {
|
|
46
|
+
const from = mergeBase(base, cwd);
|
|
47
|
+
out = out.concat(git(['diff', '--name-only', '--diff-filter=d', from, 'HEAD'], cwd).split('\n'));
|
|
48
|
+
}
|
|
49
|
+
out = out.concat(git(['diff', '--name-only', '--diff-filter=d', 'HEAD'], cwd).split('\n'));
|
|
50
|
+
out = out.concat(git(['diff', '--name-only', '--diff-filter=d', '--cached'], cwd).split('\n'));
|
|
51
|
+
out = out.concat(git(['ls-files', '--others', '--exclude-standard'], cwd).split('\n'));
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
return { files: [], error: e.message.split('\n')[0] };
|
|
55
|
+
}
|
|
56
|
+
const files = Array.from(new Set(out.map(s => s.trim()).filter(Boolean))).sort();
|
|
57
|
+
return { files };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Quote a path for a shell command line. Sensor commands are strings run through a
|
|
61
|
+
* shell, so a path with a space or a quote in it would otherwise split into two
|
|
62
|
+
* arguments — or, worse, end the quoting and let the rest of the name be read as
|
|
63
|
+
* shell syntax. Single quotes with the `'\''` escape are the only form POSIX shells
|
|
64
|
+
* treat as fully literal.
|
|
65
|
+
*/
|
|
66
|
+
function shellQuote(file) {
|
|
67
|
+
return `'${file.replace(/'/g, `'\\''`)}'`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Substitute the file list into a `changedCmd` template.
|
|
71
|
+
*
|
|
72
|
+
* The template must contain `{files}`. A template without it would silently run over
|
|
73
|
+
* the whole repo while the output claimed the run was scoped, so that case is
|
|
74
|
+
* rejected by the caller rather than papered over here.
|
|
75
|
+
*/
|
|
76
|
+
function applyChangedCmd(template, files) {
|
|
77
|
+
return template.replace('{files}', files.map(shellQuote).join(' '));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Narrow the changed set to what a given sensor can be handed.
|
|
81
|
+
*
|
|
82
|
+
* The changed set is everything the tree touched — a README, a lockfile, a PNG. The
|
|
83
|
+
* tools do not shrug those off: eslint given a `.md` fails rather than skipping it,
|
|
84
|
+
* so an unfiltered scoped run would turn "you edited the docs" into a red gate.
|
|
85
|
+
*
|
|
86
|
+
* Case-insensitive because Windows and macOS checkouts routinely carry `.TS`/`.Ts`,
|
|
87
|
+
* and a case-sensitive match would silently drop those files from the scope — the
|
|
88
|
+
* quiet direction of wrong, where the sensor reports clean over files it never saw.
|
|
89
|
+
*/
|
|
90
|
+
function filterByExtension(files, extensions) {
|
|
91
|
+
if (!extensions || extensions.length === 0)
|
|
92
|
+
return files;
|
|
93
|
+
const allowed = new Set(extensions.map(e => e.toLowerCase()));
|
|
94
|
+
return files.filter(f => allowed.has(path_1.default.extname(f).toLowerCase()));
|
|
95
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runCommand = runCommand;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
6
|
+
const DEFAULT_KILL_GRACE_MS = 2_000;
|
|
7
|
+
/** After SIGKILL, resolve regardless. A sensor must never hang the gate. */
|
|
8
|
+
const POST_KILL_GRACE_MS = 1_000;
|
|
9
|
+
/**
|
|
10
|
+
* Kill an entire process tree, not just its root.
|
|
11
|
+
*
|
|
12
|
+
* This is the reason `execSync` had to go. `execSync(cmd, { timeout })` spawns
|
|
13
|
+
* `/bin/sh -c cmd` and, on the deadline, SIGTERMs *that shell only*. A sensor
|
|
14
|
+
* command is almost always a wrapper (`npx tsc --noEmit`, `npm test`), so the
|
|
15
|
+
* tool doing the actual work is a grandchild: it survives, gets reparented to
|
|
16
|
+
* init, and keeps burning CPU. Every timeout then leaves a full tsc/eslint
|
|
17
|
+
* running, which makes the next run slower, which makes it time out too. The
|
|
18
|
+
* leak compounds — a repo that was fine at 200 files becomes ungateable at 2000
|
|
19
|
+
* for reasons that have nothing to do with its size.
|
|
20
|
+
*
|
|
21
|
+
* `detached: true` puts the child in its own process group (pgid === pid) so a
|
|
22
|
+
* negative-pid kill reaches every descendant at once.
|
|
23
|
+
*/
|
|
24
|
+
function killTree(pid, signal) {
|
|
25
|
+
if (process.platform === 'win32') {
|
|
26
|
+
// Windows has no process groups in the POSIX sense; taskkill /T walks the tree.
|
|
27
|
+
try {
|
|
28
|
+
(0, child_process_1.execFile)('taskkill', ['/pid', String(pid), '/T', '/F'], () => { });
|
|
29
|
+
}
|
|
30
|
+
catch { /* ignore */ }
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
process.kill(-pid, signal); // negative pid → the whole group
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Group already gone (normal race with a process exiting on its own),
|
|
38
|
+
// or we never became a group leader. Fall back to the direct child.
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, signal);
|
|
41
|
+
}
|
|
42
|
+
catch { /* already dead */ }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Run a shell command to completion, a deadline, or an output cap — whichever
|
|
47
|
+
* comes first — and always return what was collected.
|
|
48
|
+
*
|
|
49
|
+
* Two guarantees `execSync` could not give:
|
|
50
|
+
* 1. Cutting a run short kills the whole process tree (see `killTree`).
|
|
51
|
+
* 2. Output produced before the cut is returned, not discarded. A timeout that
|
|
52
|
+
* throws away 60s of eslint output costs double: the wall clock, and then
|
|
53
|
+
* the re-run the caller has to do to learn anything at all.
|
|
54
|
+
*/
|
|
55
|
+
function runCommand(cmd, opts) {
|
|
56
|
+
const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
57
|
+
const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
let stdout = '';
|
|
60
|
+
let stderr = '';
|
|
61
|
+
let timedOut = false;
|
|
62
|
+
let overflowed = false;
|
|
63
|
+
let settled = false;
|
|
64
|
+
const timers = [];
|
|
65
|
+
const child = (0, child_process_1.spawn)(cmd, {
|
|
66
|
+
shell: true,
|
|
67
|
+
cwd: opts.cwd,
|
|
68
|
+
detached: process.platform !== 'win32',
|
|
69
|
+
// stdin closed: a sensor must never block waiting for input, and the
|
|
70
|
+
// EOF also tells watch-mode-capable tools (vitest, jest) to run once.
|
|
71
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
72
|
+
});
|
|
73
|
+
const later = (fn, ms) => {
|
|
74
|
+
const t = setTimeout(fn, ms);
|
|
75
|
+
t.unref?.();
|
|
76
|
+
timers.push(t);
|
|
77
|
+
return t;
|
|
78
|
+
};
|
|
79
|
+
const finish = (extra) => {
|
|
80
|
+
if (settled)
|
|
81
|
+
return;
|
|
82
|
+
settled = true;
|
|
83
|
+
timers.forEach(clearTimeout);
|
|
84
|
+
resolve({ stdout, stderr, code: null, signal: null, timedOut, overflowed, ...extra });
|
|
85
|
+
};
|
|
86
|
+
/** Cut the run short: kill the tree, escalate, and never hang waiting for it. */
|
|
87
|
+
const cutShort = () => {
|
|
88
|
+
if (settled || child.pid === undefined)
|
|
89
|
+
return;
|
|
90
|
+
const pid = child.pid;
|
|
91
|
+
killTree(pid, 'SIGTERM');
|
|
92
|
+
later(() => killTree(pid, 'SIGKILL'), killGraceMs);
|
|
93
|
+
// If `close` still has not fired after the escalation, stop waiting.
|
|
94
|
+
// Whatever is holding the pipe open is no longer our problem to block on.
|
|
95
|
+
later(() => { child.unref(); finish({ signal: 'SIGKILL' }); }, killGraceMs + POST_KILL_GRACE_MS);
|
|
96
|
+
};
|
|
97
|
+
const collect = (into) => (chunk) => {
|
|
98
|
+
if (settled || overflowed)
|
|
99
|
+
return;
|
|
100
|
+
const text = String(chunk);
|
|
101
|
+
const current = into === 'out' ? stdout : stderr;
|
|
102
|
+
const room = maxBuffer - current.length;
|
|
103
|
+
const next = current + (text.length > room ? text.slice(0, room) : text);
|
|
104
|
+
if (into === 'out')
|
|
105
|
+
stdout = next;
|
|
106
|
+
else
|
|
107
|
+
stderr = next;
|
|
108
|
+
if (next.length >= maxBuffer) {
|
|
109
|
+
overflowed = true;
|
|
110
|
+
cutShort();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
child.stdout?.on('data', collect('out'));
|
|
114
|
+
child.stderr?.on('data', collect('err'));
|
|
115
|
+
child.stdout?.on('error', () => { });
|
|
116
|
+
child.stderr?.on('error', () => { });
|
|
117
|
+
child.on('error', (err) => finish({ spawnError: err }));
|
|
118
|
+
child.on('close', (code, signal) => finish({ code, signal }));
|
|
119
|
+
later(() => { timedOut = true; cutShort(); }, opts.timeout);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -27,8 +27,13 @@ function registerSensorsCommand(program) {
|
|
|
27
27
|
.option('--fast', 'run fast sensors only (tsc, lint)')
|
|
28
28
|
.option('--slow', 'run slow sensors only (semgrep, mutation)')
|
|
29
29
|
.option('--all', 'run all sensors regardless of speed')
|
|
30
|
-
.
|
|
31
|
-
|
|
30
|
+
.option('--changed', 'scope sensors that support it to the files changed vs --base')
|
|
31
|
+
.option('--base <ref>', 'comparison point for --changed (default: HEAD, i.e. uncommitted work)')
|
|
32
|
+
.action(async (opts) => {
|
|
33
|
+
const output = await (0, run_1.runSensors)({
|
|
34
|
+
fast: opts.fast, slow: opts.slow, all: opts.all,
|
|
35
|
+
changed: opts.changed, base: opts.base,
|
|
36
|
+
});
|
|
32
37
|
// Emit the verdict ALWAYS — an empty `sensors` with overall:'not_certified'
|
|
33
38
|
// must be visible, never a silent exit-0 that reads as "clean".
|
|
34
39
|
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
@@ -51,9 +56,9 @@ function registerSensorsCommand(program) {
|
|
|
51
56
|
sensors
|
|
52
57
|
.command('baseline')
|
|
53
58
|
.description('snapshot current findings as accepted — sensors then fail only on NEW ones')
|
|
54
|
-
.action(() => {
|
|
59
|
+
.action(async () => {
|
|
55
60
|
const manifestDir = (0, run_1.findManifestDir)(process.cwd());
|
|
56
|
-
const output = (0, run_1.runSensors)({ all: true, ignoreBaseline: true });
|
|
61
|
+
const output = await (0, run_1.runSensors)({ all: true, ignoreBaseline: true });
|
|
57
62
|
const baseline = (0, baseline_1.buildBaseline)(output.sensors.map(s => ({ name: s.name, errors: s.errors })));
|
|
58
63
|
const writeDir = manifestDir ?? process.cwd();
|
|
59
64
|
(0, baseline_1.writeBaseline)(writeDir, baseline);
|
|
@@ -68,6 +68,14 @@ function readPackDefaults(pack, registryRoot, cwd) {
|
|
|
68
68
|
entry.fast = def.fast;
|
|
69
69
|
if (def.enabled !== undefined)
|
|
70
70
|
entry.enabled = def.enabled;
|
|
71
|
+
// Carried through verbatim: without these in the written manifest, a pack can
|
|
72
|
+
// declare a sensor scopable and `--changed` would silently run it in full —
|
|
73
|
+
// the flag would look supported and do nothing. `{{SOURCE_DIRS}}` is not
|
|
74
|
+
// substituted here on purpose: a scoped command takes an explicit file list.
|
|
75
|
+
if (def.changedCmd)
|
|
76
|
+
entry.changedCmd = def.changedCmd;
|
|
77
|
+
if (def.changedExtensions)
|
|
78
|
+
entry.changedExtensions = def.changedExtensions;
|
|
71
79
|
sensors[name] = entry;
|
|
72
80
|
}
|
|
73
81
|
return sensors;
|
|
@@ -6,25 +6,30 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.applyBaseline = applyBaseline;
|
|
7
7
|
exports.reconcilePack = reconcilePack;
|
|
8
8
|
exports.findManifestDir = findManifestDir;
|
|
9
|
+
exports.resolveConcurrency = resolveConcurrency;
|
|
9
10
|
exports.runSensors = runSensors;
|
|
10
|
-
const child_process_1 = require("child_process");
|
|
11
11
|
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const os_1 = __importDefault(require("os"));
|
|
12
13
|
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const exec_1 = require("./exec");
|
|
13
15
|
const tsc_1 = require("./formatters/tsc");
|
|
14
16
|
const eslint_1 = require("./formatters/eslint");
|
|
15
17
|
const semgrep_1 = require("./formatters/semgrep");
|
|
16
18
|
const generic_1 = require("./formatters/generic");
|
|
17
19
|
const test_1 = require("./formatters/test");
|
|
18
20
|
const baseline_1 = require("./baseline");
|
|
21
|
+
const changed_1 = require("./changed");
|
|
19
22
|
const init_1 = require("./init");
|
|
20
23
|
const registries_1 = require("../../core/registries");
|
|
21
24
|
const MANIFEST_FILE = '.awm/sensors.json';
|
|
22
25
|
const DEFAULT_FAST_TIMEOUT = 10_000;
|
|
23
26
|
const DEFAULT_SLOW_TIMEOUT = 120_000;
|
|
24
27
|
// Sensor JSON output can be several MB on large repos (e.g. `eslint --format json`
|
|
25
|
-
// with thousands of findings).
|
|
26
|
-
//
|
|
28
|
+
// with thousands of findings). A 1MB cap killed the child with SIGTERM when
|
|
29
|
+
// exceeded — which previously surfaced as a false "timeout".
|
|
27
30
|
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
31
|
+
/** Hard ceiling on parallel sensors: past this, they only contend for the same cores. */
|
|
32
|
+
const MAX_CONCURRENCY = 4;
|
|
28
33
|
/**
|
|
29
34
|
* Apply the baseline to a sensor result: keep only findings not already accepted.
|
|
30
35
|
* `status` becomes 'pass' when every finding was baseline-suppressed. Results
|
|
@@ -125,75 +130,117 @@ function getFormatter(name) {
|
|
|
125
130
|
function isExitCodeSensor(name) {
|
|
126
131
|
return name === 'test';
|
|
127
132
|
}
|
|
128
|
-
function runSensor(name, cmd, timeout, cwd) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
async function runSensor(name, cmd, timeout, cwd) {
|
|
134
|
+
const res = await (0, exec_1.runCommand)(cmd, { timeout, cwd, maxBuffer: MAX_BUFFER });
|
|
135
|
+
const format = getFormatter(name);
|
|
136
|
+
// The shell itself never started (bad cwd, no shell). Nothing ran.
|
|
137
|
+
if (res.spawnError) {
|
|
138
|
+
return {
|
|
139
|
+
name,
|
|
140
|
+
status: 'fail',
|
|
141
|
+
errors: [{ message: `sensor could not be started: ${res.spawnError.message}` }],
|
|
142
|
+
};
|
|
133
143
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
// Non-zero exit — the normal path for linters/typecheckers that found
|
|
147
|
-
// findings. Parse the output; if it yields findings, that's a fail.
|
|
148
|
-
const raw = String((err.stdout ?? '') + (err.stderr ?? ''));
|
|
149
|
-
const errors = getFormatter(name)(raw);
|
|
150
|
-
if (errors.length > 0)
|
|
151
|
-
return { name, status: 'fail', errors };
|
|
152
|
-
// A missing tool (binary not installed) must NOT pass silently — the gate
|
|
153
|
-
// cannot certify what it could not run. Treat it as a fail with a clear message.
|
|
154
|
-
//
|
|
155
|
-
// Exit 127 is the POSIX signal for "command not found" and is the only check
|
|
156
|
-
// here that holds across shells and locales: bash writes `command not found`
|
|
157
|
-
// but dash — `/bin/sh` on Debian/Ubuntu, hence most CI runners and containers
|
|
158
|
-
// — writes `not found`, so matching shell text alone read an absent tool as a
|
|
159
|
-
// benign skip. `err.code` does not cover it either: that is ENOENT only when
|
|
160
|
-
// spawning the shell itself fails, not when the shell starts and the command
|
|
161
|
-
// inside it is missing. The ENOBUFS and timeout branches are evaluated above,
|
|
162
|
-
// so reaching here with status 127 means the command did not exist.
|
|
163
|
-
//
|
|
164
|
-
// A wrapper (`npm test`, `npx …`) that exits 127 because a binary it invokes
|
|
165
|
-
// is absent is classified the same way, deliberately: the gate still ran
|
|
166
|
-
// nothing and still cannot certify anything.
|
|
167
|
-
const lower = raw.toLowerCase();
|
|
168
|
-
const toolMissing = err.status === 127 || // POSIX: command not found
|
|
169
|
-
err.code === 'ENOENT' || // execSync spawn failure (no shell)
|
|
170
|
-
lower.includes('command not found') || // bash, zsh
|
|
171
|
-
// cmd.exe reports an absent binary with exit 1, so 127 does not cover
|
|
172
|
-
// Windows; this exact phrase does. Kept narrow on purpose — a loose
|
|
173
|
-
// `not found` would also match a tool that ran and said "not found"
|
|
174
|
-
// for reasons of its own.
|
|
175
|
-
lower.includes('is not recognized as an internal or external command') ||
|
|
176
|
-
lower.includes('enoent') ||
|
|
177
|
-
lower.includes('could not determine executable');
|
|
178
|
-
if (toolMissing) {
|
|
144
|
+
// Cut short by the deadline or the output cap. The run is NOT a verdict — but
|
|
145
|
+
// whatever it printed before being cut is still evidence, and throwing it away
|
|
146
|
+
// is what forced the caller to re-run the same command by hand to learn
|
|
147
|
+
// anything. Findings in the partial output are real findings; their absence
|
|
148
|
+
// proves nothing, so a clean partial can never be `pass`.
|
|
149
|
+
if (res.timedOut || res.overflowed) {
|
|
150
|
+
const reason = res.timedOut
|
|
151
|
+
? `timeout after ${timeout}ms`
|
|
152
|
+
: `output exceeded ${MAX_BUFFER} bytes`;
|
|
153
|
+
const errors = format(res.stdout + res.stderr);
|
|
154
|
+
if (errors.length > 0) {
|
|
179
155
|
return {
|
|
180
156
|
name,
|
|
181
157
|
status: 'fail',
|
|
182
|
-
errors
|
|
158
|
+
errors,
|
|
159
|
+
incomplete: `${reason} — findings below are from partial output; the run did not finish`,
|
|
183
160
|
};
|
|
184
161
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
162
|
+
return { name, status: 'inconclusive', errors: [], skipReason: reason };
|
|
163
|
+
}
|
|
164
|
+
if (res.code === 0) {
|
|
165
|
+
const errors = format(res.stdout);
|
|
166
|
+
return { name, status: errors.length > 0 ? 'fail' : 'pass', errors };
|
|
167
|
+
}
|
|
168
|
+
// Non-zero exit — the normal path for linters/typecheckers that found
|
|
169
|
+
// findings. Parse the output; if it yields findings, that's a fail.
|
|
170
|
+
const raw = res.stdout + res.stderr;
|
|
171
|
+
const errors = format(raw);
|
|
172
|
+
if (errors.length > 0)
|
|
173
|
+
return { name, status: 'fail', errors };
|
|
174
|
+
// A missing tool (binary not installed) must NOT pass silently — the gate
|
|
175
|
+
// cannot certify what it could not run. Treat it as a fail with a clear message.
|
|
176
|
+
//
|
|
177
|
+
// Exit 127 is the POSIX signal for "command not found" and is the only check
|
|
178
|
+
// here that holds across shells and locales: bash writes `command not found`
|
|
179
|
+
// but dash — `/bin/sh` on Debian/Ubuntu, hence most CI runners and containers
|
|
180
|
+
// — writes `not found`, so matching shell text alone read an absent tool as a
|
|
181
|
+
// benign skip. A failure to spawn the shell itself is a different thing and
|
|
182
|
+
// is handled above via `spawnError`. The cut-short branches are evaluated
|
|
183
|
+
// above too, so reaching here with status 127 means the command did not exist.
|
|
184
|
+
//
|
|
185
|
+
// A wrapper (`npm test`, `npx …`) that exits 127 because a binary it invokes
|
|
186
|
+
// is absent is classified the same way, deliberately: the gate still ran
|
|
187
|
+
// nothing and still cannot certify anything.
|
|
188
|
+
const lower = raw.toLowerCase();
|
|
189
|
+
const toolMissing = res.code === 127 || // POSIX: command not found
|
|
190
|
+
lower.includes('command not found') || // bash, zsh
|
|
191
|
+
// cmd.exe reports an absent binary with exit 1, so 127 does not cover
|
|
192
|
+
// Windows; this exact phrase does. Kept narrow on purpose — a loose
|
|
193
|
+
// `not found` would also match a tool that ran and said "not found"
|
|
194
|
+
// for reasons of its own.
|
|
195
|
+
lower.includes('is not recognized as an internal or external command') ||
|
|
196
|
+
lower.includes('enoent') ||
|
|
197
|
+
lower.includes('could not determine executable');
|
|
198
|
+
if (toolMissing) {
|
|
199
|
+
return {
|
|
200
|
+
name,
|
|
201
|
+
status: 'fail',
|
|
202
|
+
errors: [{ message: `sensor tool not available: ${raw.slice(0, 200)}` }],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// Exit-code sensors (tests): any genuine non-zero exit is a real failure,
|
|
206
|
+
// even when no per-line findings can be parsed from the output.
|
|
207
|
+
if (isExitCodeSensor(name)) {
|
|
208
|
+
return { name, status: 'fail', errors: [{ message: `SENSOR[${name}] failed (exit ${res.code})` }] };
|
|
194
209
|
}
|
|
210
|
+
// Residual case: it exited non-zero, the tool exists, and no finding
|
|
211
|
+
// could be parsed. We do not know what happened — say so instead of
|
|
212
|
+
// reporting a benign skip.
|
|
213
|
+
return { name, status: 'inconclusive', errors: [], skipReason: `exit ${res.code}: ${raw.slice(0, 200)}` };
|
|
195
214
|
}
|
|
196
|
-
|
|
215
|
+
/**
|
|
216
|
+
* How many sensors may run at once. Sensors are separate processes over the same
|
|
217
|
+
* tree, so they parallelise cleanly — but each one (tsc, eslint, depcruise) is
|
|
218
|
+
* largely single-threaded, and oversubscribing the box just makes every sensor
|
|
219
|
+
* slower and more likely to hit its own deadline. Leave a core for the agent.
|
|
220
|
+
*/
|
|
221
|
+
function resolveConcurrency(manifest, sensorCount) {
|
|
222
|
+
const configured = Number(process.env.AWM_SENSORS_CONCURRENCY ?? manifest.concurrency);
|
|
223
|
+
if (Number.isFinite(configured) && configured >= 1)
|
|
224
|
+
return Math.min(Math.floor(configured), sensorCount);
|
|
225
|
+
const cores = os_1.default.cpus()?.length ?? 2;
|
|
226
|
+
return Math.max(1, Math.min(MAX_CONCURRENCY, cores - 1, sensorCount));
|
|
227
|
+
}
|
|
228
|
+
/** Run `tasks` with at most `limit` in flight, preserving input order in the output. */
|
|
229
|
+
async function pooled(tasks, limit) {
|
|
230
|
+
const results = new Array(tasks.length);
|
|
231
|
+
let next = 0;
|
|
232
|
+
const worker = async () => {
|
|
233
|
+
while (true) {
|
|
234
|
+
const i = next++;
|
|
235
|
+
if (i >= tasks.length)
|
|
236
|
+
return;
|
|
237
|
+
results[i] = await tasks[i]();
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
|
|
241
|
+
return results;
|
|
242
|
+
}
|
|
243
|
+
async function runSensors(opts = {}) {
|
|
197
244
|
const startCwd = opts.cwd ?? process.cwd();
|
|
198
245
|
const manifestDir = findManifestDir(startCwd);
|
|
199
246
|
if (!manifestDir)
|
|
@@ -204,31 +251,81 @@ function runSensors(opts = {}) {
|
|
|
204
251
|
const reconciled = reconcilePack(manifestDir, manifest);
|
|
205
252
|
const activeManifest = reconciled.manifest;
|
|
206
253
|
const cwd = manifestDir; // ejecutar sensores y baseline desde donde vive el manifest
|
|
207
|
-
const results = [];
|
|
208
254
|
// Baseline suppresses already-accepted findings so sensors fail only on NEW
|
|
209
255
|
// ones (essential on repos with a large pre-existing baseline). Absent file or
|
|
210
256
|
// --ignore-baseline → every finding counts (backward-compatible).
|
|
211
257
|
const baseline = opts.ignoreBaseline ? null : (0, baseline_1.readBaseline)(cwd);
|
|
258
|
+
// A scoped run must never define the accepted set. `buildBaseline` snapshots the
|
|
259
|
+
// findings of the run it is given, so baselining a `--changed` run would write a
|
|
260
|
+
// baseline covering only the touched files and silently drop every accepted
|
|
261
|
+
// finding elsewhere in the repo — which then reports as NEW on the next full run.
|
|
262
|
+
// The two flags only ever meet by mistake, so refuse rather than pick a meaning.
|
|
263
|
+
if (opts.changed && opts.ignoreBaseline) {
|
|
264
|
+
throw new Error('refusing to combine --changed with a baseline capture: a partial run cannot define '
|
|
265
|
+
+ 'the accepted set (it would drop every accepted finding outside the diff). '
|
|
266
|
+
+ 'Run `awm sensors baseline` without --changed.');
|
|
267
|
+
}
|
|
268
|
+
// Resolved once for the whole run, not per sensor: `git` is cheap but the answer
|
|
269
|
+
// must be identical across sensors, or two of them scope to different file sets.
|
|
270
|
+
const changed = opts.changed ? (0, changed_1.changedFiles)(cwd, opts.base ?? 'HEAD') : null;
|
|
271
|
+
// Sensors are independent processes over the same tree, so they run
|
|
272
|
+
// concurrently rather than one-after-another: wall clock becomes the slowest
|
|
273
|
+
// sensor instead of the sum of all of them. Tasks are built — and dispatched —
|
|
274
|
+
// in manifest order, so the reported order stays stable.
|
|
275
|
+
const tasks = [];
|
|
276
|
+
const settled = (r) => () => Promise.resolve(r);
|
|
212
277
|
for (const [name, config] of Object.entries(activeManifest.sensors)) {
|
|
213
278
|
const isFast = config.fast ?? false;
|
|
214
279
|
if (!shouldRun(isFast, opts))
|
|
215
280
|
continue;
|
|
216
281
|
if (config.enabled === false) {
|
|
217
|
-
|
|
282
|
+
tasks.push(settled({ name, status: 'skipped', errors: [], skipReason: 'disabled' }));
|
|
218
283
|
continue;
|
|
219
284
|
}
|
|
220
285
|
if (!config.cmd) {
|
|
221
286
|
// Enabled but with nothing to run: broken config, not a deliberate
|
|
222
287
|
// opt-out. `enabled: false` is how a sensor is turned off.
|
|
223
|
-
|
|
288
|
+
tasks.push(settled({ name, status: 'inconclusive', errors: [], skipReason: 'no cmd configured' }));
|
|
224
289
|
continue;
|
|
225
290
|
}
|
|
291
|
+
// Scoping applies only where the pack opted in AND the scope resolved. Any
|
|
292
|
+
// other combination falls back to the full command: slower, never wrong.
|
|
293
|
+
let cmd = config.cmd;
|
|
294
|
+
let scope;
|
|
295
|
+
if (changed && !changed.error && config.changedCmd) {
|
|
296
|
+
if (!config.changedCmd.includes('{files}')) {
|
|
297
|
+
// Running the template as-is would cover the whole repo while the
|
|
298
|
+
// result claimed to be scoped — a mislabel, not a slow path.
|
|
299
|
+
tasks.push(settled({
|
|
300
|
+
name,
|
|
301
|
+
status: 'inconclusive',
|
|
302
|
+
errors: [],
|
|
303
|
+
skipReason: 'changedCmd has no {files} placeholder',
|
|
304
|
+
}));
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const inScope = (0, changed_1.filterByExtension)(changed.files, config.changedExtensions);
|
|
308
|
+
if (inScope.length === 0) {
|
|
309
|
+
tasks.push(settled({
|
|
310
|
+
name,
|
|
311
|
+
status: 'skipped',
|
|
312
|
+
errors: [],
|
|
313
|
+
skipReason: 'no changed files in scope',
|
|
314
|
+
scope: 'changed',
|
|
315
|
+
}));
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
cmd = (0, changed_1.applyChangedCmd)(config.changedCmd, inScope);
|
|
319
|
+
scope = 'changed';
|
|
320
|
+
}
|
|
226
321
|
const timeout = config.timeout ?? (isFast ? DEFAULT_FAST_TIMEOUT : DEFAULT_SLOW_TIMEOUT);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
322
|
+
tasks.push(async () => {
|
|
323
|
+
const result = await runSensor(name, cmd, timeout, cwd);
|
|
324
|
+
const scoped = scope ? { ...result, scope } : result;
|
|
325
|
+
return baseline ? applyBaseline(scoped, baseline[name]) : scoped;
|
|
326
|
+
});
|
|
231
327
|
}
|
|
328
|
+
const results = await pooled(tasks, resolveConcurrency(activeManifest, tasks.length));
|
|
232
329
|
// `fail` outranks `inconclusive`: when something is broken AND something
|
|
233
330
|
// could not be measured, the broken thing is the actionable verdict.
|
|
234
331
|
let overall = results.some(r => r.status === 'fail') ? 'fail'
|
|
@@ -245,5 +342,10 @@ function runSensors(opts = {}) {
|
|
|
245
342
|
sensors: results,
|
|
246
343
|
overall,
|
|
247
344
|
...(reconciled.upgradedFrom ? { packUpgraded: `${reconciled.upgradedFrom}→${activeManifest.pack}` } : {}),
|
|
345
|
+
// Always emitted on a --changed run, including when the scope failed to
|
|
346
|
+
// resolve: a green that came back from an unscoped fallback and a green from a
|
|
347
|
+
// genuinely scoped run are different claims, and the caller cannot tell them
|
|
348
|
+
// apart from the sensor list alone.
|
|
349
|
+
...(changed ? { changedScope: { files: changed.files.length, ...(changed.error ? { error: changed.error } : {}) } } : {}),
|
|
248
350
|
};
|
|
249
351
|
}
|