agentic-workflow-manager 3.2.1 → 3.3.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/run.js +45 -13
- package/dist/src/core/init/orchestrator.js +19 -0
- package/dist/tests/commands/sensors/run-inconclusive.test.js +237 -0
- package/dist/tests/commands/sensors/run-tool-missing.test.js +69 -0
- package/dist/tests/commands/sensors/run.test.js +18 -10
- package/dist/tests/core/init/orchestrator.test.js +28 -0
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.applyBaseline = applyBaseline;
|
|
6
7
|
exports.reconcilePack = reconcilePack;
|
|
7
8
|
exports.findManifestDir = findManifestDir;
|
|
8
9
|
exports.runSensors = runSensors;
|
|
@@ -26,11 +27,13 @@ const DEFAULT_SLOW_TIMEOUT = 120_000;
|
|
|
26
27
|
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
27
28
|
/**
|
|
28
29
|
* Apply the baseline to a sensor result: keep only findings not already accepted.
|
|
29
|
-
* `status` becomes 'pass' when every finding was baseline-suppressed.
|
|
30
|
-
*
|
|
30
|
+
* `status` becomes 'pass' when every finding was baseline-suppressed. Results
|
|
31
|
+
* without a verdict of their own — skipped and inconclusive — are returned
|
|
32
|
+
* untouched: there is nothing to ratchet, and letting them through here would
|
|
33
|
+
* hand back a `pass` for a sensor that never reported anything.
|
|
31
34
|
*/
|
|
32
35
|
function applyBaseline(result, accepted) {
|
|
33
|
-
if (result.status === 'skipped')
|
|
36
|
+
if (result.status === 'skipped' || result.status === 'inconclusive')
|
|
34
37
|
return result;
|
|
35
38
|
const { newErrors, suppressed } = (0, baseline_1.partition)(result.name, result.errors, accepted);
|
|
36
39
|
if (suppressed === 0)
|
|
@@ -131,12 +134,14 @@ function runSensor(name, cmd, timeout, cwd) {
|
|
|
131
134
|
catch (err) {
|
|
132
135
|
// Output exceeded maxBuffer — child is killed before output can be read.
|
|
133
136
|
// Check this BEFORE the SIGTERM branch (ENOBUFS kills with SIGTERM too).
|
|
137
|
+
// Nothing could be read, so nothing was certified.
|
|
134
138
|
if (err.code === 'ENOBUFS') {
|
|
135
|
-
return { name, status: '
|
|
139
|
+
return { name, status: 'inconclusive', errors: [], skipReason: `output exceeded ${MAX_BUFFER} bytes` };
|
|
136
140
|
}
|
|
137
|
-
// Genuine timeout: execSync kills with SIGTERM after `timeout` ms.
|
|
141
|
+
// Genuine timeout: execSync kills with SIGTERM after `timeout` ms. The
|
|
142
|
+
// sensor produced no verdict — inconclusive, not a benign skip.
|
|
138
143
|
if (err.code === 'ETIMEDOUT' || (err.killed && err.signal === 'SIGTERM')) {
|
|
139
|
-
return { name, status: '
|
|
144
|
+
return { name, status: 'inconclusive', errors: [], skipReason: `timeout after ${timeout}ms` };
|
|
140
145
|
}
|
|
141
146
|
// Non-zero exit — the normal path for linters/typecheckers that found
|
|
142
147
|
// findings. Parse the output; if it yields findings, that's a fail.
|
|
@@ -146,9 +151,28 @@ function runSensor(name, cmd, timeout, cwd) {
|
|
|
146
151
|
return { name, status: 'fail', errors };
|
|
147
152
|
// A missing tool (binary not installed) must NOT pass silently — the gate
|
|
148
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.
|
|
149
167
|
const lower = raw.toLowerCase();
|
|
150
|
-
const toolMissing = err.
|
|
151
|
-
|
|
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') ||
|
|
152
176
|
lower.includes('enoent') ||
|
|
153
177
|
lower.includes('could not determine executable');
|
|
154
178
|
if (toolMissing) {
|
|
@@ -163,7 +187,10 @@ function runSensor(name, cmd, timeout, cwd) {
|
|
|
163
187
|
if (isExitCodeSensor(name)) {
|
|
164
188
|
return { name, status: 'fail', errors: [{ message: `SENSOR[${name}] failed (exit ${err.status})` }] };
|
|
165
189
|
}
|
|
166
|
-
|
|
190
|
+
// Residual case: it exited non-zero, the tool exists, and no finding
|
|
191
|
+
// could be parsed. We do not know what happened — say so instead of
|
|
192
|
+
// reporting a benign skip.
|
|
193
|
+
return { name, status: 'inconclusive', errors: [], skipReason: `exit ${err.status}: ${raw.slice(0, 200)}` };
|
|
167
194
|
}
|
|
168
195
|
}
|
|
169
196
|
function runSensors(opts = {}) {
|
|
@@ -191,7 +218,9 @@ function runSensors(opts = {}) {
|
|
|
191
218
|
continue;
|
|
192
219
|
}
|
|
193
220
|
if (!config.cmd) {
|
|
194
|
-
|
|
221
|
+
// Enabled but with nothing to run: broken config, not a deliberate
|
|
222
|
+
// opt-out. `enabled: false` is how a sensor is turned off.
|
|
223
|
+
results.push({ name, status: 'inconclusive', errors: [], skipReason: 'no cmd configured' });
|
|
195
224
|
continue;
|
|
196
225
|
}
|
|
197
226
|
const timeout = config.timeout ?? (isFast ? DEFAULT_FAST_TIMEOUT : DEFAULT_SLOW_TIMEOUT);
|
|
@@ -200,10 +229,13 @@ function runSensors(opts = {}) {
|
|
|
200
229
|
result = applyBaseline(result, baseline[name]);
|
|
201
230
|
results.push(result);
|
|
202
231
|
}
|
|
232
|
+
// `fail` outranks `inconclusive`: when something is broken AND something
|
|
233
|
+
// could not be measured, the broken thing is the actionable verdict.
|
|
203
234
|
let overall = results.some(r => r.status === 'fail') ? 'fail'
|
|
204
|
-
: results.
|
|
205
|
-
: results.length
|
|
206
|
-
: '
|
|
235
|
+
: results.some(r => r.status === 'inconclusive') ? 'not_certified'
|
|
236
|
+
: results.length > 0 && results.every(r => r.status === 'skipped') ? 'skipped'
|
|
237
|
+
: results.length === 0 ? 'skipped'
|
|
238
|
+
: 'pass';
|
|
207
239
|
// Honest floor: a benign-green 'skipped' over a tree that clearly HAS a stack
|
|
208
240
|
// (indicators present) is a false green — the gate ran nothing real. Never green.
|
|
209
241
|
if (overall === 'skipped' && reconciled.detection.pack !== 'generic') {
|
|
@@ -42,7 +42,26 @@ async function runInitSteps(deps) {
|
|
|
42
42
|
steps.push(await wrapStep('project.constitutionInjection', 'project', () => (0, steps_1.stepConstitutionInjection)(deps)));
|
|
43
43
|
steps.push(await wrapStep('project.context', 'project', () => (0, steps_1.stepContext)(deps)));
|
|
44
44
|
}
|
|
45
|
+
// Re-gather fresh so the report reflects whatever the steps above just
|
|
46
|
+
// installed. `results` deliberately keeps every check — including project
|
|
47
|
+
// ones — so the render still shows real project state (e.g. "run awm sync")
|
|
48
|
+
// when one exists; only `overall`, the exit-code verdict, needs scoping.
|
|
49
|
+
//
|
|
50
|
+
// If this run started with project scope nulled — `--machine-only`
|
|
51
|
+
// (init.ts's effectiveCtx), or genuinely no project at this cwd — `overall`
|
|
52
|
+
// must reflect only what THIS run attempted. `runChecks` otherwise mixes
|
|
53
|
+
// machine + project results into one flat `overall` (checks.ts), so a
|
|
54
|
+
// `--machine-only` bootstrap into an already-initialized project with
|
|
55
|
+
// unrelated unsynced bundles reports "degraded" and callers gating on it
|
|
56
|
+
// (`runInit`'s exit code) fail a run that fully succeeded at everything it
|
|
57
|
+
// was asked to do — the exact shape of a real Codex Cloud bootstrap script
|
|
58
|
+
// aborting under `set -euo pipefail` right after `awm init`.
|
|
45
59
|
const after = (0, checks_1.runChecks)((0, context_1.gatherContext)({ cwd: deps.cwd, bundles: deps.bundles, agent: deps.agent }));
|
|
60
|
+
if (deps.ctx.project === null) {
|
|
61
|
+
after.overall = after.results
|
|
62
|
+
.filter((r) => r.level === 'machine')
|
|
63
|
+
.some((r) => r.status === 'missing') ? 'degraded' : 'healthy';
|
|
64
|
+
}
|
|
46
65
|
return {
|
|
47
66
|
steps,
|
|
48
67
|
applied: steps.filter((s) => s.action === 'applied').length,
|
|
@@ -0,0 +1,237 @@
|
|
|
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 mockExecSyncFn = jest.fn();
|
|
10
|
+
jest.mock('child_process', () => ({
|
|
11
|
+
execSync: (...args) => mockExecSyncFn(...args),
|
|
12
|
+
}));
|
|
13
|
+
/** Sensors run in manifest insertion order, so mocks are queued in that order. */
|
|
14
|
+
const MANIFEST = {
|
|
15
|
+
pack: 'js-ts',
|
|
16
|
+
sensors: {
|
|
17
|
+
typecheck: { cmd: 'npx tsc --noEmit', fast: true },
|
|
18
|
+
security: { cmd: 'semgrep .', fast: false },
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
const timeoutError = () => { throw Object.assign(new Error('killed'), { code: 'ETIMEDOUT' }); };
|
|
22
|
+
describe('runSensors — inconclusive: a sensor that could not certify is never green', () => {
|
|
23
|
+
let root;
|
|
24
|
+
let fakeAwmHome;
|
|
25
|
+
let prevAwmHome;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
jest.resetModules();
|
|
28
|
+
mockExecSyncFn.mockReset();
|
|
29
|
+
root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-inconclusive-'));
|
|
30
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm'), { recursive: true });
|
|
31
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify(MANIFEST));
|
|
32
|
+
// CLAUDE.md: no test may reach the real ~/.awm.
|
|
33
|
+
fakeAwmHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-home-'));
|
|
34
|
+
prevAwmHome = process.env.AWM_HOME;
|
|
35
|
+
process.env.AWM_HOME = fakeAwmHome;
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
process.env.AWM_HOME = prevAwmHome;
|
|
39
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
40
|
+
fs_1.default.rmSync(fakeAwmHome, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
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
|
|
47
|
+
const { runSensors } = load();
|
|
48
|
+
const out = runSensors({ cwd: root });
|
|
49
|
+
const security = out.sensors.find((s) => s.name === 'security');
|
|
50
|
+
expect(security.status).toBe('inconclusive');
|
|
51
|
+
expect(security.skipReason).toMatch(/timeout/);
|
|
52
|
+
});
|
|
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);
|
|
57
|
+
const { runSensors } = load();
|
|
58
|
+
const out = runSensors({ cwd: root });
|
|
59
|
+
expect(out.sensors.find((s) => s.name === 'typecheck').status).toBe('pass');
|
|
60
|
+
expect(out.overall).toBe('not_certified');
|
|
61
|
+
});
|
|
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' }); });
|
|
66
|
+
const { runSensors } = load();
|
|
67
|
+
const out = runSensors({ cwd: root });
|
|
68
|
+
const security = out.sensors.find((s) => s.name === 'security');
|
|
69
|
+
expect(security.status).toBe('inconclusive');
|
|
70
|
+
expect(security.skipReason).toMatch(/exceeded/);
|
|
71
|
+
expect(out.overall).toBe('not_certified');
|
|
72
|
+
});
|
|
73
|
+
it('reports an uninterpretable non-zero exit as inconclusive', () => {
|
|
74
|
+
mockExecSyncFn
|
|
75
|
+
.mockReturnValueOnce('')
|
|
76
|
+
.mockImplementationOnce(() => {
|
|
77
|
+
// semgrep formatter yields no findings for non-JSON output, the
|
|
78
|
+
// tool is present (exit 2, not 127), and `security` is not an
|
|
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
|
+
});
|
|
84
|
+
const { runSensors } = load();
|
|
85
|
+
const out = runSensors({ cwd: root });
|
|
86
|
+
const security = out.sensors.find((s) => s.name === 'security');
|
|
87
|
+
expect(security.status).toBe('inconclusive');
|
|
88
|
+
expect(security.skipReason).toMatch(/exit 2/);
|
|
89
|
+
expect(out.overall).toBe('not_certified');
|
|
90
|
+
});
|
|
91
|
+
it('reports an enabled sensor with no cmd as inconclusive', () => {
|
|
92
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
|
|
93
|
+
pack: 'js-ts',
|
|
94
|
+
sensors: {
|
|
95
|
+
typecheck: { cmd: 'npx tsc --noEmit', fast: true },
|
|
96
|
+
depcheck: { fast: false }, // enabled, but nothing to run
|
|
97
|
+
},
|
|
98
|
+
}));
|
|
99
|
+
mockExecSyncFn.mockReturnValueOnce(''); // typecheck: clean
|
|
100
|
+
const { runSensors } = load();
|
|
101
|
+
const out = runSensors({ cwd: root });
|
|
102
|
+
const depcheck = out.sensors.find((s) => s.name === 'depcheck');
|
|
103
|
+
expect(depcheck.status).toBe('inconclusive');
|
|
104
|
+
expect(depcheck.skipReason).toBe('no cmd configured');
|
|
105
|
+
expect(out.overall).toBe('not_certified');
|
|
106
|
+
});
|
|
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
|
|
115
|
+
const { runSensors } = load();
|
|
116
|
+
const out = runSensors({ cwd: root });
|
|
117
|
+
expect(out.sensors.find((s) => s.name === 'typecheck').status).toBe('fail');
|
|
118
|
+
expect(out.sensors.find((s) => s.name === 'security').status).toBe('inconclusive');
|
|
119
|
+
expect(out.overall).toBe('fail');
|
|
120
|
+
});
|
|
121
|
+
it('never emits an overall value outside the published domain', () => {
|
|
122
|
+
// `inconclusive` is a per-sensor status only. External consumers (the
|
|
123
|
+
// registry skills) read `overall`, whose domain must not grow — this
|
|
124
|
+
// pins that invariant at runtime on a three-sensor pass+fail+inconclusive
|
|
125
|
+
// mix, a combination neither R8 (pass+inconclusive) nor R9
|
|
126
|
+
// (fail+inconclusive) exercises. R9's own assertion already catches a
|
|
127
|
+
// fail/inconclusive precedence regression specifically; what this test
|
|
128
|
+
// adds is runtime coverage of the domain claim itself, on a fixture
|
|
129
|
+
// neither of those covers — not independent detection of every
|
|
130
|
+
// aggregation mutation.
|
|
131
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
|
|
132
|
+
pack: 'js-ts',
|
|
133
|
+
sensors: {
|
|
134
|
+
typecheck: { cmd: 'npx tsc --noEmit', fast: true },
|
|
135
|
+
lint: { cmd: 'npx eslint . --format json', fast: true },
|
|
136
|
+
security: { cmd: 'semgrep .', fast: false },
|
|
137
|
+
},
|
|
138
|
+
}));
|
|
139
|
+
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
|
|
148
|
+
const { runSensors } = load();
|
|
149
|
+
const out = runSensors({ cwd: root });
|
|
150
|
+
expect(DOMAIN).toContain(out.overall);
|
|
151
|
+
expect(out.overall).not.toBe('inconclusive');
|
|
152
|
+
});
|
|
153
|
+
it('keeps a deliberately disabled sensor apart from one that could not certify', () => {
|
|
154
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
|
|
155
|
+
pack: 'js-ts',
|
|
156
|
+
sensors: {
|
|
157
|
+
security: { cmd: 'semgrep .', fast: false },
|
|
158
|
+
mutation: { cmd: 'npx stryker run', enabled: false },
|
|
159
|
+
},
|
|
160
|
+
}));
|
|
161
|
+
mockExecSyncFn.mockImplementationOnce(timeoutError); // security: times out
|
|
162
|
+
// mutation: never invoked
|
|
163
|
+
const { runSensors } = load();
|
|
164
|
+
const out = runSensors({ cwd: root });
|
|
165
|
+
// Same run, two different meanings — the whole point of the split.
|
|
166
|
+
expect(out.sensors.find((s) => s.name === 'mutation').status).toBe('skipped');
|
|
167
|
+
expect(out.sensors.find((s) => s.name === 'mutation').skipReason).toBe('disabled');
|
|
168
|
+
expect(out.sensors.find((s) => s.name === 'security').status).toBe('inconclusive');
|
|
169
|
+
expect(out.overall).toBe('not_certified');
|
|
170
|
+
});
|
|
171
|
+
it('does not degrade the verdict for a disabled sensor alongside healthy ones', () => {
|
|
172
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
|
|
173
|
+
pack: 'js-ts',
|
|
174
|
+
sensors: {
|
|
175
|
+
typecheck: { cmd: 'npx tsc --noEmit', fast: true },
|
|
176
|
+
mutation: { cmd: 'npx stryker run', enabled: false },
|
|
177
|
+
},
|
|
178
|
+
}));
|
|
179
|
+
mockExecSyncFn.mockReturnValueOnce('');
|
|
180
|
+
const { runSensors } = load();
|
|
181
|
+
const out = runSensors({ cwd: root });
|
|
182
|
+
expect(out.overall).toBe('pass');
|
|
183
|
+
});
|
|
184
|
+
it('still refuses to certify a tree whose sensors are all disabled', () => {
|
|
185
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
|
|
186
|
+
pack: 'js-ts',
|
|
187
|
+
sensors: {
|
|
188
|
+
typecheck: { cmd: 'npx tsc --noEmit', enabled: false },
|
|
189
|
+
security: { cmd: 'semgrep .', enabled: false },
|
|
190
|
+
},
|
|
191
|
+
}));
|
|
192
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'package.json'), '{}'); // real stack indicator
|
|
193
|
+
const { runSensors } = load();
|
|
194
|
+
const out = runSensors({ cwd: root });
|
|
195
|
+
expect(out.sensors.every((s) => s.status === 'skipped')).toBe(true);
|
|
196
|
+
expect(out.overall).toBe('not_certified');
|
|
197
|
+
expect(mockExecSyncFn).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
it('leaves an inconclusive result untouched when a baseline is applied', () => {
|
|
200
|
+
const { writeBaseline } = require('../../../src/commands/sensors/baseline');
|
|
201
|
+
writeBaseline(root, { security: ['some-accepted-fingerprint'] });
|
|
202
|
+
mockExecSyncFn
|
|
203
|
+
.mockReturnValueOnce('')
|
|
204
|
+
.mockImplementationOnce(timeoutError);
|
|
205
|
+
const { runSensors } = load();
|
|
206
|
+
const out = runSensors({ cwd: root });
|
|
207
|
+
const security = out.sensors.find((s) => s.name === 'security');
|
|
208
|
+
expect(security.status).toBe('inconclusive');
|
|
209
|
+
expect(security.baselineCount).toBeUndefined();
|
|
210
|
+
expect(out.overall).toBe('not_certified');
|
|
211
|
+
});
|
|
212
|
+
it('applyBaseline leaves an inconclusive result untouched even if it somehow carried findings', () => {
|
|
213
|
+
// Every current `inconclusive` producer sets `errors: []`, so a test built
|
|
214
|
+
// on the public `runSensors()` API can't tell "the explicit guard fired"
|
|
215
|
+
// apart from "fell through to partition() and incidentally suppressed 0
|
|
216
|
+
// findings." This unit-tests applyBaseline directly, with a hand-built
|
|
217
|
+
// result that has `errors` populated, to prove the guard itself — not an
|
|
218
|
+
// accidental empty-array interaction — is what keeps inconclusive inert.
|
|
219
|
+
const { applyBaseline } = load();
|
|
220
|
+
const { buildBaseline } = require('../../../src/commands/sensors/baseline');
|
|
221
|
+
const result = {
|
|
222
|
+
name: 'security',
|
|
223
|
+
status: 'inconclusive',
|
|
224
|
+
errors: [{ message: 'hypothetical finding that should never be ratcheted', rule: 'some-rule', file: 'src/x.ts' }],
|
|
225
|
+
skipReason: 'timeout after 10000ms',
|
|
226
|
+
};
|
|
227
|
+
// Build a baseline that partition() WOULD genuinely match/suppress for
|
|
228
|
+
// this exact finding, so the old code (without the inconclusive guard)
|
|
229
|
+
// would have mutated the result — proving the new guard, not an
|
|
230
|
+
// incidental "suppressed === 0", is what keeps it untouched.
|
|
231
|
+
const accepted = buildBaseline([{ name: result.name, errors: result.errors }])[result.name];
|
|
232
|
+
const out = applyBaseline(result, accepted);
|
|
233
|
+
expect(out).toBe(result);
|
|
234
|
+
expect(out.status).toBe('inconclusive');
|
|
235
|
+
expect(out.baselineCount).toBeUndefined();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
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 run_1 = require("../../../src/commands/sensors/run");
|
|
10
|
+
/**
|
|
11
|
+
* These tests deliberately do NOT mock `child_process`: the defect they pin only
|
|
12
|
+
* exists against a real shell. On Debian/Ubuntu `/bin/sh` is dash, which writes
|
|
13
|
+
* `not found` — not bash's `command not found` — so a string-matching heuristic
|
|
14
|
+
* reads a missing binary as a benign skip. What is being fixed here is the
|
|
15
|
+
* invariant "a tool that could not run is never green", NOT the wording of any
|
|
16
|
+
* particular shell, so no assertion below matches shell text.
|
|
17
|
+
*
|
|
18
|
+
* The sensor is named `security` on purpose. Sensors with a structured formatter
|
|
19
|
+
* (semgrep/tsc/eslint) return zero findings for unparseable shell noise and fall
|
|
20
|
+
* through to the tool-missing branch; a sensor with the generic formatter turns
|
|
21
|
+
* any stderr into a finding and never reaches it.
|
|
22
|
+
*/
|
|
23
|
+
const MISSING_BIN = 'awm-nonexistent-binary-xyz';
|
|
24
|
+
function mkProject(sensors) {
|
|
25
|
+
const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-tool-missing-'));
|
|
26
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm'));
|
|
27
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({ pack: 'js-ts', sensors }));
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
describe('runSensors — an absent tool never reads as green (real /bin/sh)', () => {
|
|
31
|
+
const roots = [];
|
|
32
|
+
afterAll(() => { for (const r of roots)
|
|
33
|
+
fs_1.default.rmSync(r, { recursive: true, force: true }); });
|
|
34
|
+
const project = (sensors) => {
|
|
35
|
+
const root = mkProject(sensors);
|
|
36
|
+
roots.push(root);
|
|
37
|
+
return root;
|
|
38
|
+
};
|
|
39
|
+
it('marks a sensor whose binary is absent as fail, not skipped', () => {
|
|
40
|
+
const root = project({ security: { cmd: `${MISSING_BIN} .`, fast: true } });
|
|
41
|
+
const out = (0, run_1.runSensors)({ cwd: root });
|
|
42
|
+
const security = out.sensors.find(s => s.name === 'security');
|
|
43
|
+
expect(security.status).toBe('fail');
|
|
44
|
+
expect(security.errors[0].message).toMatch(/not available/i);
|
|
45
|
+
});
|
|
46
|
+
it('does not let a healthy sensor carry the run to pass while another tool is absent', () => {
|
|
47
|
+
const root = project({
|
|
48
|
+
typecheck: { cmd: 'node -e ""', fast: true },
|
|
49
|
+
security: { cmd: `${MISSING_BIN} .`, fast: true },
|
|
50
|
+
});
|
|
51
|
+
const out = (0, run_1.runSensors)({ cwd: root });
|
|
52
|
+
expect(out.sensors.find(s => s.name === 'typecheck').status).toBe('pass');
|
|
53
|
+
expect(out.overall).toBe('fail');
|
|
54
|
+
});
|
|
55
|
+
it('does not misread a tool that ran and merely printed "not found" as an absent tool', () => {
|
|
56
|
+
// Exits 1, not 127: the binary existed and reported something of its own.
|
|
57
|
+
// Classifying this as a missing tool would be a false accusation. It also
|
|
58
|
+
// must not read as a benign 'skipped': the formatter parsed no findings
|
|
59
|
+
// from a genuine non-zero exit, which is the residual "I don't know" case
|
|
60
|
+
// (Task 3) — 'inconclusive', not 'fail' and not 'skipped'.
|
|
61
|
+
const root = project({
|
|
62
|
+
security: { cmd: `node -e "console.error('rule pack not found'); process.exit(1)"`, fast: true },
|
|
63
|
+
});
|
|
64
|
+
const out = (0, run_1.runSensors)({ cwd: root });
|
|
65
|
+
const security = out.sensors.find(s => s.name === 'security');
|
|
66
|
+
expect(security.status).toBe('inconclusive');
|
|
67
|
+
expect(security.errors).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -68,13 +68,13 @@ describe('runSensors', () => {
|
|
|
68
68
|
expect(tc.status).toBe('fail');
|
|
69
69
|
expect(tc.errors[0].message).toMatch('SENSOR[typecheck]');
|
|
70
70
|
});
|
|
71
|
-
it('marks sensor as
|
|
71
|
+
it('marks sensor as inconclusive on timeout', () => {
|
|
72
72
|
mockExecSyncFn.mockImplementationOnce(() => { throw Object.assign(new Error('killed'), { code: 'ETIMEDOUT' }); });
|
|
73
73
|
mockExecSyncFn.mockReturnValueOnce('');
|
|
74
74
|
const { runSensors } = load();
|
|
75
75
|
const result = runSensors({ fast: true, cwd: tmpDir });
|
|
76
76
|
const tc = result.sensors.find((s) => s.name === 'typecheck');
|
|
77
|
-
expect(tc.status).toBe('
|
|
77
|
+
expect(tc.status).toBe('inconclusive');
|
|
78
78
|
expect(tc.skipReason).toMatch('timeout');
|
|
79
79
|
});
|
|
80
80
|
it('skips disabled sensors', () => {
|
|
@@ -134,26 +134,34 @@ describe('runSensors — missing tool is a fail, not a skip', () => {
|
|
|
134
134
|
});
|
|
135
135
|
beforeEach(() => {
|
|
136
136
|
mockExecSyncFn.mockReset();
|
|
137
|
-
//
|
|
138
|
-
//
|
|
137
|
+
// What Node's execSync actually throws when the binary is absent and `/bin/sh`
|
|
138
|
+
// is dash (Debian/Ubuntu, hence most CI runners): status 127, `not found`
|
|
139
|
+
// rather than bash's `command not found`, and no `code` — ENOENT is set only
|
|
140
|
+
// when spawning the shell itself fails, not the command inside it.
|
|
139
141
|
mockExecSyncFn.mockImplementation(() => {
|
|
140
|
-
throw Object.assign(new Error('Command failed: awm-nonexistent-binary-xyz
|
|
142
|
+
throw Object.assign(new Error('Command failed: awm-nonexistent-binary-xyz .'), {
|
|
141
143
|
stdout: '',
|
|
142
|
-
stderr: '/bin/sh: awm-nonexistent-binary-xyz:
|
|
144
|
+
stderr: '/bin/sh: 1: awm-nonexistent-binary-xyz: not found\n',
|
|
143
145
|
status: 127,
|
|
144
146
|
});
|
|
145
147
|
});
|
|
146
148
|
});
|
|
149
|
+
// The sensor is named `security` so it uses the semgrep formatter, which returns
|
|
150
|
+
// zero findings for unparseable shell noise and lets execution reach the
|
|
151
|
+
// tool-missing branch. Under the generic formatter any stderr becomes a finding,
|
|
152
|
+
// so a sensor named `ghost` would report `fail` without that branch ever running —
|
|
153
|
+
// green for a reason unrelated to what this test claims to cover.
|
|
147
154
|
it('marks a sensor whose binary is missing as fail', () => {
|
|
148
155
|
root = mkTmp();
|
|
149
156
|
fs_1.default.mkdirSync(path_1.default.join(root, '.awm'));
|
|
150
157
|
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
|
|
151
|
-
pack: '
|
|
152
|
-
sensors: {
|
|
158
|
+
pack: 'js-ts',
|
|
159
|
+
sensors: { security: { cmd: 'awm-nonexistent-binary-xyz .', fast: true } },
|
|
153
160
|
}));
|
|
154
161
|
const out = (0, run_1.runSensors)({ cwd: root });
|
|
155
|
-
const
|
|
156
|
-
expect(
|
|
162
|
+
const security = out.sensors.find((s) => s.name === 'security');
|
|
163
|
+
expect(security?.status).toBe('fail');
|
|
164
|
+
expect(security?.errors[0].message).toMatch(/not available/i);
|
|
157
165
|
expect(out.overall).toBe('fail');
|
|
158
166
|
});
|
|
159
167
|
});
|
|
@@ -96,6 +96,34 @@ describe('runInitSteps — orchestrator', () => {
|
|
|
96
96
|
fs_1.default.rmSync(bareCwd, { recursive: true, force: true });
|
|
97
97
|
}
|
|
98
98
|
});
|
|
99
|
+
it('machine-only inside an already-initialized project with unsynced bundles still reaches overall healthy', async () => {
|
|
100
|
+
// Reproduces a real Codex Cloud bootstrap: `awm init --agent codex --yes
|
|
101
|
+
// --machine-only` run with cwd inside an existing, previously-initialized
|
|
102
|
+
// project (its own `.awm/profile.json` already committed, declaring bundles
|
|
103
|
+
// this machine-only run was never asked to sync). The bare-cwd test above
|
|
104
|
+
// can't catch this — there, `ctx.project` is null for two reasons at once
|
|
105
|
+
// (machineOnly AND no project exists), so it never exercises "machineOnly
|
|
106
|
+
// nulled a project that actually has real, degraded content."
|
|
107
|
+
const root = path_1.default.join(tmpHome, 'existing-project');
|
|
108
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm'), { recursive: true });
|
|
109
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'package.json'), JSON.stringify({ dependencies: { next: '14.0.0' } }));
|
|
110
|
+
// Declares the "dev" bundle active, but its skills were never symlinked —
|
|
111
|
+
// exactly `active bundles (N missing)` in the real report the user saw.
|
|
112
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'profile.json'), JSON.stringify({ extensions: ['dev'] }));
|
|
113
|
+
const deps = buildDeps(root);
|
|
114
|
+
deps.ctx.project = null; // what init.ts's effectiveCtx does for --machine-only
|
|
115
|
+
const { runInitSteps } = require('../../../src/core/init/orchestrator');
|
|
116
|
+
const out = await runInitSteps(deps);
|
|
117
|
+
// The steps this run actually attempted (machine-only) all succeeded —
|
|
118
|
+
// that must be reflected in the caller's exit code.
|
|
119
|
+
expect(out.steps.every((s) => s.action !== 'failed')).toBe(true);
|
|
120
|
+
expect(out.after.results.find((r) => r.id === 'machine.devCore')?.status).toBe('ok');
|
|
121
|
+
// Project state exists and IS genuinely degraded, and it must stay visible
|
|
122
|
+
// in the report (the CLI's own render still shows it) — but it must not
|
|
123
|
+
// drag down the overall verdict for a run that never touched project scope.
|
|
124
|
+
expect(out.after.results.some((r) => r.id === 'project.activation' && r.status === 'missing')).toBe(true);
|
|
125
|
+
expect(out.after.overall).toBe('healthy');
|
|
126
|
+
});
|
|
99
127
|
it('project repo: applies activation/sensors, flags constitution+context as pending', async () => {
|
|
100
128
|
const root = path_1.default.join(tmpHome, 'repo');
|
|
101
129
|
fs_1.default.mkdirSync(path_1.default.join(root, '.awm'), { recursive: true });
|