agentic-workflow-manager 8.1.4 → 8.1.5
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/preflight/checks.js +70 -1
- package/dist/src/commands/preflight/index.js +6 -5
- package/dist/src/commands/sensors/changed.js +15 -0
- package/dist/src/commands/sensors/compatibility/contract.js +17 -3
- package/dist/src/commands/sensors/compatibility/manifest.js +16 -4
- package/dist/src/commands/sensors/compatibility/timeout.js +21 -0
- package/dist/src/commands/sensors/exec.js +3 -1
- package/dist/src/commands/sensors/index.js +5 -13
- package/dist/src/commands/sensors/init.js +1 -0
- package/dist/src/commands/sensors/prepare.js +136 -0
- package/dist/src/commands/sensors/result.js +140 -0
- package/dist/src/commands/sensors/run.js +56 -335
- package/dist/src/commands/sensors/status.js +93 -21
- package/dist/src/commands/sensors/verdict.js +27 -0
- package/dist/tests/commands/preflight/preflight.test.js +50 -0
- package/dist/tests/commands/sensors/baseline.test.js +14 -0
- package/dist/tests/commands/sensors/changed-windows.test.js +3 -0
- package/dist/tests/commands/sensors/compatibility/contract.test.js +51 -0
- package/dist/tests/commands/sensors/compatibility/manifest.test.js +27 -11
- package/dist/tests/commands/sensors/compatibility/probe.test.js +13 -3
- package/dist/tests/commands/sensors/exec-fixtures.js +1 -1
- package/dist/tests/commands/sensors/exec.test.js +17 -0
- package/dist/tests/commands/sensors/index.test.js +46 -8
- package/dist/tests/commands/sensors/init.test.js +30 -1
- package/dist/tests/commands/sensors/prepare.test.js +88 -0
- package/dist/tests/commands/sensors/router.test.js +15 -1
- package/dist/tests/commands/sensors/run-changed.test.js +4 -4
- package/dist/tests/commands/sensors/run.test.js +29 -0
- package/dist/tests/commands/sensors/status-windows.test.js +1 -1
- package/dist/tests/commands/sensors/status.test.js +52 -20
- package/dist/tests/integration/preflight-json-pipe.e2e.test.js +79 -3
- package/dist/tests/integration/sensor-compatibility.e2e.test.js +40 -1
- package/dist/tests/structural/sensor-documentation-contract.test.js +33 -5
- package/dist/tests/structural/support-matrix-is-current.test.js +25 -0
- package/package.json +1 -1
|
@@ -3,7 +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 =
|
|
6
|
+
exports.applyBaseline = void 0;
|
|
7
7
|
exports.detectPackDrift = detectPackDrift;
|
|
8
8
|
exports.findManifestDir = findManifestDir;
|
|
9
9
|
exports.resolveConcurrency = resolveConcurrency;
|
|
@@ -11,23 +11,17 @@ exports.runSensors = runSensors;
|
|
|
11
11
|
const fs_1 = __importDefault(require("fs"));
|
|
12
12
|
const os_1 = __importDefault(require("os"));
|
|
13
13
|
const path_1 = __importDefault(require("path"));
|
|
14
|
-
const exec_1 = require("./exec");
|
|
15
|
-
const tsc_1 = require("./formatters/tsc");
|
|
16
|
-
const eslint_1 = require("./formatters/eslint");
|
|
17
|
-
const semgrep_1 = require("./formatters/semgrep");
|
|
18
|
-
const generic_1 = require("./formatters/generic");
|
|
19
|
-
const test_1 = require("./formatters/test");
|
|
20
|
-
const mypy_1 = require("./formatters/mypy");
|
|
21
|
-
const ruff_1 = require("./formatters/ruff");
|
|
22
|
-
const shellcheck_1 = require("./formatters/shellcheck");
|
|
23
14
|
const baseline_1 = require("./baseline");
|
|
15
|
+
const result_1 = require("./result");
|
|
16
|
+
Object.defineProperty(exports, "applyBaseline", { enumerable: true, get: function () { return result_1.applyBaseline; } });
|
|
24
17
|
const changed_1 = require("./changed");
|
|
25
18
|
// Solo `detectStack` (puro, lee el arbol) — NO `initSensors`, que escribe. `run` es un
|
|
26
19
|
// verbo de lectura: no debe tener a mano ninguna funcion capaz de mutar el proyecto.
|
|
27
20
|
const init_1 = require("./init");
|
|
28
|
-
const paths_1 = require("../../core/paths");
|
|
29
21
|
const manifest_1 = require("./compatibility/manifest");
|
|
30
22
|
const live_1 = require("./compatibility/live");
|
|
23
|
+
const prepare_1 = require("./prepare");
|
|
24
|
+
const verdict_1 = require("./verdict");
|
|
31
25
|
const MANIFEST_FILE = '.awm/sensors.json';
|
|
32
26
|
const DEFAULT_FAST_TIMEOUT = 10_000;
|
|
33
27
|
const DEFAULT_SLOW_TIMEOUT = 120_000;
|
|
@@ -37,38 +31,6 @@ const DEFAULT_SLOW_TIMEOUT = 120_000;
|
|
|
37
31
|
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
38
32
|
/** Hard ceiling on parallel sensors: past this, they only contend for the same cores. */
|
|
39
33
|
const MAX_CONCURRENCY = 4;
|
|
40
|
-
/**
|
|
41
|
-
* Apply the baseline to a sensor result: keep only findings not already accepted.
|
|
42
|
-
* `status` becomes 'pass' when every finding was baseline-suppressed. Results
|
|
43
|
-
* without a verdict of their own — skipped and inconclusive — are returned
|
|
44
|
-
* untouched: there is nothing to ratchet, and letting them through here would
|
|
45
|
-
* hand back a `pass` for a sensor that never reported anything.
|
|
46
|
-
*/
|
|
47
|
-
function applyBaseline(result, accepted) {
|
|
48
|
-
if (result.status === 'skipped' || result.status === 'inconclusive')
|
|
49
|
-
return result;
|
|
50
|
-
const { newErrors, suppressed } = (0, baseline_1.partition)(result.name, result.errors, accepted);
|
|
51
|
-
if (suppressed === 0)
|
|
52
|
-
return result;
|
|
53
|
-
return {
|
|
54
|
-
...result,
|
|
55
|
-
errors: newErrors,
|
|
56
|
-
status: newErrors.length > 0 ? 'fail' : 'pass',
|
|
57
|
-
newCount: newErrors.length,
|
|
58
|
-
baselineCount: suppressed,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
function readManifest(cwd) {
|
|
62
|
-
const p = path_1.default.join(cwd, MANIFEST_FILE);
|
|
63
|
-
if (!fs_1.default.existsSync(p))
|
|
64
|
-
return null;
|
|
65
|
-
try {
|
|
66
|
-
return JSON.parse(fs_1.default.readFileSync(p, 'utf-8'));
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
34
|
async function resolveLiveV2(cwd, manifest) {
|
|
73
35
|
if (manifest.kind !== 'v2')
|
|
74
36
|
return null;
|
|
@@ -79,18 +41,6 @@ async function resolveLiveV2(cwd, manifest) {
|
|
|
79
41
|
return null;
|
|
80
42
|
}
|
|
81
43
|
}
|
|
82
|
-
async function runV2Sensor(name, command, timeout, cwd, formatter) {
|
|
83
|
-
const res = await (0, exec_1.runStructuredCommand)(command, { timeout, cwd, maxBuffer: MAX_BUFFER });
|
|
84
|
-
const format = getFormatter(name, formatter);
|
|
85
|
-
if (res.spawnError)
|
|
86
|
-
return { name, status: 'fail', errors: [{ message: `sensor could not be started: ${res.spawnError.message}` }] };
|
|
87
|
-
if (res.timedOut || res.overflowed)
|
|
88
|
-
return { name, status: 'inconclusive', errors: [], skipReason: res.timedOut ? `timeout after ${timeout}ms` : `output exceeded ${MAX_BUFFER} bytes` };
|
|
89
|
-
const errors = format(res.stdout + res.stderr);
|
|
90
|
-
if (res.code === 0)
|
|
91
|
-
return { name, status: errors.length ? 'fail' : 'pass', errors };
|
|
92
|
-
return errors.length ? { name, status: 'fail', errors } : { name, status: 'inconclusive', errors: [], skipReason: `exit ${res.code}` };
|
|
93
|
-
}
|
|
94
44
|
/**
|
|
95
45
|
* Detect — and only detect — that the manifest's pack no longer describes the tree:
|
|
96
46
|
* it sits on the `generic` fallback while real stack indicators (package.json,
|
|
@@ -142,137 +92,13 @@ function findManifestDir(startCwd) {
|
|
|
142
92
|
function shouldRun(isFast, opts) {
|
|
143
93
|
if (opts.all)
|
|
144
94
|
return true;
|
|
145
|
-
if (opts.fast &&
|
|
146
|
-
return true;
|
|
147
|
-
if (opts.slow && !isFast)
|
|
95
|
+
if (opts.fast && opts.slow)
|
|
148
96
|
return true;
|
|
149
|
-
if (
|
|
150
|
-
return
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
* Dispatch by the pack's `formatter` field (the real tool behind the sensor slot —
|
|
155
|
-
* `lint` is eslint on js-ts but ruff on python, shellcheck on shell) when present.
|
|
156
|
-
* Manifests written before this field existed carry no `formatter`, so they fall back
|
|
157
|
-
* to the pre-existing name-based dispatch — nothing already installed breaks.
|
|
158
|
-
*/
|
|
159
|
-
function getFormatter(name, formatterField) {
|
|
160
|
-
// A `formatter` field that is PRESENT but unrecognized (a typo in a pack.json, or a
|
|
161
|
-
// future pack declaring a tool this CLI version doesn't know about yet) is a
|
|
162
|
-
// different situation from no field at all. Falling through to name-based dispatch
|
|
163
|
-
// in that case would silently misparse a foreign output shape via the wrong parser
|
|
164
|
-
// (e.g. a `bandit` formatter falling through to `parseSemgrepOutput`, reading
|
|
165
|
-
// bandit's differently-shaped JSON and producing garbage findings). Only the
|
|
166
|
-
// ABSENT case (old manifest, written before this field existed) gets name-based
|
|
167
|
-
// backward-compat dispatch; a present-but-unknown value degrades honestly to the
|
|
168
|
-
// generic raw-wrap formatter instead.
|
|
169
|
-
if (formatterField !== undefined) {
|
|
170
|
-
switch (formatterField) {
|
|
171
|
-
case 'tsc': return tsc_1.parseTscOutput;
|
|
172
|
-
case 'eslint-llm': return eslint_1.parseEslintOutput;
|
|
173
|
-
case 'semgrep': return semgrep_1.parseSemgrepOutput;
|
|
174
|
-
case 'test': return test_1.parseTestOutput;
|
|
175
|
-
case 'mypy': return mypy_1.parseMypyOutput;
|
|
176
|
-
case 'ruff': return ruff_1.parseRuffOutput;
|
|
177
|
-
case 'shellcheck': return shellcheck_1.parseShellcheckOutput;
|
|
178
|
-
case 'generic': return generic_1.parseGenericOutput;
|
|
179
|
-
default: return generic_1.parseGenericOutput;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
if (name === 'typecheck')
|
|
183
|
-
return tsc_1.parseTscOutput;
|
|
184
|
-
if (name === 'lint')
|
|
185
|
-
return eslint_1.parseEslintOutput;
|
|
186
|
-
if (name === 'security')
|
|
187
|
-
return semgrep_1.parseSemgrepOutput;
|
|
188
|
-
if (name === 'test')
|
|
189
|
-
return test_1.parseTestOutput;
|
|
190
|
-
return generic_1.parseGenericOutput;
|
|
191
|
-
}
|
|
192
|
-
function isExitCodeSensor(name) {
|
|
193
|
-
return name === 'test';
|
|
194
|
-
}
|
|
195
|
-
async function runSensor(name, cmd, timeout, cwd, formatterField) {
|
|
196
|
-
const res = await (0, exec_1.runCommand)(cmd, { timeout, cwd, maxBuffer: MAX_BUFFER });
|
|
197
|
-
const format = getFormatter(name, formatterField);
|
|
198
|
-
// The shell itself never started (bad cwd, no shell). Nothing ran.
|
|
199
|
-
if (res.spawnError) {
|
|
200
|
-
return {
|
|
201
|
-
name,
|
|
202
|
-
status: 'fail',
|
|
203
|
-
errors: [{ message: `sensor could not be started: ${res.spawnError.message}` }],
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
// Cut short by the deadline or the output cap. The run is NOT a verdict — but
|
|
207
|
-
// whatever it printed before being cut is still evidence, and throwing it away
|
|
208
|
-
// is what forced the caller to re-run the same command by hand to learn
|
|
209
|
-
// anything. Findings in the partial output are real findings; their absence
|
|
210
|
-
// proves nothing, so a clean partial can never be `pass`.
|
|
211
|
-
if (res.timedOut || res.overflowed) {
|
|
212
|
-
const reason = res.timedOut
|
|
213
|
-
? `timeout after ${timeout}ms`
|
|
214
|
-
: `output exceeded ${MAX_BUFFER} bytes`;
|
|
215
|
-
const errors = format(res.stdout + res.stderr);
|
|
216
|
-
if (errors.length > 0) {
|
|
217
|
-
return {
|
|
218
|
-
name,
|
|
219
|
-
status: 'fail',
|
|
220
|
-
errors,
|
|
221
|
-
incomplete: `${reason} — findings below are from partial output; the run did not finish`,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
return { name, status: 'inconclusive', errors: [], skipReason: reason };
|
|
225
|
-
}
|
|
226
|
-
if (res.code === 0) {
|
|
227
|
-
const errors = format(res.stdout);
|
|
228
|
-
return { name, status: errors.length > 0 ? 'fail' : 'pass', errors };
|
|
229
|
-
}
|
|
230
|
-
// Non-zero exit — the normal path for linters/typecheckers that found
|
|
231
|
-
// findings. Parse the output; if it yields findings, that's a fail.
|
|
232
|
-
const raw = res.stdout + res.stderr;
|
|
233
|
-
const errors = format(raw);
|
|
234
|
-
if (errors.length > 0)
|
|
235
|
-
return { name, status: 'fail', errors };
|
|
236
|
-
// A missing tool (binary not installed) must NOT pass silently — the gate
|
|
237
|
-
// cannot certify what it could not run. Treat it as a fail with a clear message.
|
|
238
|
-
//
|
|
239
|
-
// Exit 127 is the POSIX signal for "command not found" and is the only check
|
|
240
|
-
// here that holds across shells and locales: bash writes `command not found`
|
|
241
|
-
// but dash — `/bin/sh` on Debian/Ubuntu, hence most CI runners and containers
|
|
242
|
-
// — writes `not found`, so matching shell text alone read an absent tool as a
|
|
243
|
-
// benign skip. A failure to spawn the shell itself is a different thing and
|
|
244
|
-
// is handled above via `spawnError`. The cut-short branches are evaluated
|
|
245
|
-
// above too, so reaching here with status 127 means the command did not exist.
|
|
246
|
-
//
|
|
247
|
-
// A wrapper (`npm test`, `npx …`) that exits 127 because a binary it invokes
|
|
248
|
-
// is absent is classified the same way, deliberately: the gate still ran
|
|
249
|
-
// nothing and still cannot certify anything.
|
|
250
|
-
const lower = raw.toLowerCase();
|
|
251
|
-
const toolMissing = res.code === 127 || // POSIX: command not found
|
|
252
|
-
lower.includes('command not found') || // bash, zsh
|
|
253
|
-
// cmd.exe reports an absent binary with exit 1, so 127 does not cover
|
|
254
|
-
// Windows; this exact phrase does. Kept narrow on purpose — a loose
|
|
255
|
-
// `not found` would also match a tool that ran and said "not found"
|
|
256
|
-
// for reasons of its own.
|
|
257
|
-
lower.includes('is not recognized as an internal or external command') ||
|
|
258
|
-
lower.includes('enoent') ||
|
|
259
|
-
lower.includes('could not determine executable');
|
|
260
|
-
if (toolMissing) {
|
|
261
|
-
return {
|
|
262
|
-
name,
|
|
263
|
-
status: 'fail',
|
|
264
|
-
errors: [{ message: `sensor tool not available: ${raw.slice(0, 200)}` }],
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
// Exit-code sensors (tests): any genuine non-zero exit is a real failure,
|
|
268
|
-
// even when no per-line findings can be parsed from the output.
|
|
269
|
-
if (isExitCodeSensor(name)) {
|
|
270
|
-
return { name, status: 'fail', errors: [{ message: `SENSOR[${name}] failed (exit ${res.code})` }] };
|
|
271
|
-
}
|
|
272
|
-
// Residual case: it exited non-zero, the tool exists, and no finding
|
|
273
|
-
// could be parsed. We do not know what happened — say so instead of
|
|
274
|
-
// reporting a benign skip.
|
|
275
|
-
return { name, status: 'inconclusive', errors: [], skipReason: `exit ${res.code}: ${raw.slice(0, 200)}` };
|
|
97
|
+
if (opts.fast)
|
|
98
|
+
return isFast;
|
|
99
|
+
if (opts.slow)
|
|
100
|
+
return !isFast;
|
|
101
|
+
return true;
|
|
276
102
|
}
|
|
277
103
|
/**
|
|
278
104
|
* How many sensors may run at once. Sensors are separate processes over the same
|
|
@@ -302,170 +128,65 @@ async function pooled(tasks, limit) {
|
|
|
302
128
|
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
|
|
303
129
|
return results;
|
|
304
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* One orchestration path for both manifest contracts. Preparation selects the
|
|
133
|
+
* authorized command; execution, baseline handling, and verdict reduction are
|
|
134
|
+
* deliberately format-agnostic.
|
|
135
|
+
*/
|
|
305
136
|
async function runSensors(opts = {}) {
|
|
306
|
-
|
|
307
|
-
const manifestDir = findManifestDir(
|
|
137
|
+
(0, prepare_1.validateRunOptions)(opts);
|
|
138
|
+
const manifestDir = findManifestDir(opts.cwd ?? process.cwd());
|
|
308
139
|
if (!manifestDir)
|
|
309
140
|
return { sensors: [], overall: 'not_certified' };
|
|
310
|
-
|
|
311
|
-
if (!manifest)
|
|
312
|
-
return { sensors: [], overall: 'not_certified' };
|
|
313
|
-
let parsedManifest;
|
|
141
|
+
let parsed;
|
|
314
142
|
try {
|
|
315
|
-
|
|
143
|
+
parsed = (0, manifest_1.parseSensorManifest)(JSON.parse(fs_1.default.readFileSync(path_1.default.join(manifestDir, MANIFEST_FILE), 'utf8')), path_1.default.join(manifestDir, MANIFEST_FILE));
|
|
316
144
|
}
|
|
317
145
|
catch {
|
|
318
146
|
return { sensors: [], overall: 'not_certified' };
|
|
319
147
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
const selected = liveSensor?.variants.find(candidate => candidate.id === state.variantId);
|
|
341
|
-
if (!selected) {
|
|
342
|
-
tasks.push(() => Promise.resolve({ name, status: 'inconclusive', errors: [], skipReason: 'variant-drift: selected live variant has no command; run `awm sensors init`' }));
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
// Variant IDs are stable selectors, not immutable command snapshots.
|
|
346
|
-
// Only the just re-resolved pack command is authorized to execute.
|
|
347
|
-
tasks.push(() => runV2Sensor(name, selected.command, (sensor.fast ?? liveSensor?.fast ?? false) ? DEFAULT_FAST_TIMEOUT : DEFAULT_SLOW_TIMEOUT, manifestDir, selected.formatter));
|
|
348
|
-
}
|
|
349
|
-
const sensors = await pooled(tasks, Math.min(MAX_CONCURRENCY, Math.max(1, tasks.length)));
|
|
350
|
-
return { sensors, overall: sensors.some(sensor => sensor.status === 'fail') ? 'fail' : sensors.some(sensor => sensor.status === 'inconclusive') ? 'not_certified' : sensors.length ? 'pass' : 'skipped' };
|
|
351
|
-
}
|
|
352
|
-
const drift = detectPackDrift(manifestDir, manifest);
|
|
353
|
-
const activeManifest = manifest; // el manifest COMITEADO es lo que se corre; `run` no lo reescribe
|
|
354
|
-
const cwd = manifestDir; // ejecutar sensores y baseline desde donde vive el manifest
|
|
355
|
-
// Baseline suppresses already-accepted findings so sensors fail only on NEW
|
|
356
|
-
// ones (essential on repos with a large pre-existing baseline). Absent file or
|
|
357
|
-
// --ignore-baseline → every finding counts (backward-compatible).
|
|
358
|
-
const baseline = opts.ignoreBaseline ? null : (0, baseline_1.readBaseline)(cwd);
|
|
359
|
-
// A scoped run must never define the accepted set. `buildBaseline` snapshots the
|
|
360
|
-
// findings of the run it is given, so baselining a `--changed` run would write a
|
|
361
|
-
// baseline covering only the touched files and silently drop every accepted
|
|
362
|
-
// finding elsewhere in the repo — which then reports as NEW on the next full run.
|
|
363
|
-
// The two flags only ever meet by mistake, so refuse rather than pick a meaning.
|
|
364
|
-
if (opts.changed && opts.ignoreBaseline) {
|
|
365
|
-
throw new Error('refusing to combine --changed with a baseline capture: a partial run cannot define '
|
|
366
|
-
+ 'the accepted set (it would drop every accepted finding outside the diff). '
|
|
367
|
-
+ 'Run `awm sensors baseline` without --changed.');
|
|
368
|
-
}
|
|
369
|
-
// Resolved once for the whole run, not per sensor: `git` is cheap but the answer
|
|
370
|
-
// must be identical across sensors, or two of them scope to different file sets.
|
|
371
|
-
const changed = opts.changed ? (0, changed_1.changedFiles)(cwd, opts.base ?? 'HEAD') : null;
|
|
372
|
-
// Security (BatBadBut / CVE-2024-27980): on native Windows, `runCommand` spawns
|
|
373
|
-
// the sensor command through cmd.exe (`shell: true`), which parses `& | < > ^ %`
|
|
374
|
-
// as ITS OWN syntax before the target program ever sees argv — quoting does not
|
|
375
|
-
// reliably neutralize this layer (the primary research this fix is based on
|
|
376
|
-
// concludes escaping it is not safely possible). A changed filename carrying one
|
|
377
|
-
// of these is refused, not escaped: routed through the exact same fallback the
|
|
378
|
-
// module already has for "scope could not be resolved" (`changed.error`), so
|
|
379
|
-
// every sensor degrades to its full unscoped command rather than interpolating
|
|
380
|
-
// an unsafe path. POSIX is unaffected — single-quote quoting there is fully
|
|
381
|
-
// literal per POSIX shell grammar, no metacharacter exception exists.
|
|
382
|
-
if (changed && !changed.error && (0, paths_1.isWindowsNative)() && changed.files.some(changed_1.hasUnsafeWin32Chars)) {
|
|
383
|
-
changed.error = 'a changed filename contains a cmd.exe metacharacter (& | < > ^ % or newline/CR) '
|
|
384
|
-
+ 'that quoting cannot reliably neutralize on native Windows — refusing to interpolate it, '
|
|
385
|
-
+ 'falling back to the full unscoped command';
|
|
386
|
-
}
|
|
387
|
-
// Sensors are independent processes over the same tree, so they run
|
|
388
|
-
// concurrently rather than one-after-another: wall clock becomes the slowest
|
|
389
|
-
// sensor instead of the sum of all of them. Tasks are built — and dispatched —
|
|
390
|
-
// in manifest order, so the reported order stays stable.
|
|
391
|
-
const tasks = [];
|
|
392
|
-
const settled = (r) => () => Promise.resolve(r);
|
|
393
|
-
for (const [name, config] of Object.entries(activeManifest.sensors ?? {})) {
|
|
394
|
-
const isFast = config.fast ?? false;
|
|
395
|
-
if (!shouldRun(isFast, opts))
|
|
396
|
-
continue;
|
|
397
|
-
if (config.enabled === false) {
|
|
398
|
-
tasks.push(settled({ name, status: 'skipped', errors: [], skipReason: 'disabled' }));
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
401
|
-
if (!config.cmd) {
|
|
402
|
-
// Enabled but with nothing to run: broken config, not a deliberate
|
|
403
|
-
// opt-out. `enabled: false` is how a sensor is turned off.
|
|
404
|
-
tasks.push(settled({ name, status: 'inconclusive', errors: [], skipReason: 'no cmd configured' }));
|
|
148
|
+
const baseline = opts.ignoreBaseline ? null : (0, baseline_1.readBaseline)(manifestDir);
|
|
149
|
+
const changed = opts.changed ? (0, changed_1.changedFiles)(manifestDir, opts.base ?? 'HEAD') : null;
|
|
150
|
+
// Legacy commands pass changed filenames through a shell; preserve the native
|
|
151
|
+
// Windows safety fallback. Structured v2 argv never crosses that shell layer.
|
|
152
|
+
if (parsed.kind === 'legacy' && changed && !changed.error) {
|
|
153
|
+
const scopeError = (0, changed_1.changedScopeError)(changed);
|
|
154
|
+
if (scopeError)
|
|
155
|
+
changed.error = scopeError;
|
|
156
|
+
}
|
|
157
|
+
const live = parsed.kind === 'v2' ? await resolveLiveV2(manifestDir, parsed) : null;
|
|
158
|
+
const drift = parsed.kind === 'legacy' ? detectPackDrift(manifestDir, parsed.pack) : undefined;
|
|
159
|
+
const requestedScope = opts.changed ? 'changed' : 'full';
|
|
160
|
+
const prepared = [];
|
|
161
|
+
for (const [name, sensor] of Object.entries(parsed.pack.sensors)) {
|
|
162
|
+
const liveSensor = parsed.kind === 'v2' ? live?.pack.sensors[name] : undefined;
|
|
163
|
+
const fast = parsed.kind === 'v2'
|
|
164
|
+
? sensor.fast ?? liveSensor?.fast ?? false
|
|
165
|
+
: sensor.fast ?? false;
|
|
166
|
+
if (!shouldRun(fast, opts))
|
|
405
167
|
continue;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
423
|
-
const inScope = (0, changed_1.filterByExtension)(changed.files, config.changedExtensions);
|
|
424
|
-
if (inScope.length === 0) {
|
|
425
|
-
tasks.push(settled({
|
|
426
|
-
name,
|
|
427
|
-
status: 'skipped',
|
|
428
|
-
errors: [],
|
|
429
|
-
skipReason: 'no changed files in scope',
|
|
430
|
-
scope: 'changed',
|
|
431
|
-
}));
|
|
432
|
-
continue;
|
|
433
|
-
}
|
|
434
|
-
cmd = (0, changed_1.applyChangedCmd)(config.changedCmd, inScope);
|
|
435
|
-
scope = 'changed';
|
|
436
|
-
}
|
|
437
|
-
const timeout = config.timeout ?? (isFast ? DEFAULT_FAST_TIMEOUT : DEFAULT_SLOW_TIMEOUT);
|
|
438
|
-
tasks.push(async () => {
|
|
439
|
-
const result = await runSensor(name, cmd, timeout, cwd, config.formatter);
|
|
440
|
-
const scoped = scope ? { ...result, scope } : result;
|
|
441
|
-
return baseline ? applyBaseline(scoped, baseline[name]) : scoped;
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
const results = await pooled(tasks, resolveConcurrency(activeManifest, tasks.length));
|
|
445
|
-
// `fail` outranks `inconclusive`: when something is broken AND something
|
|
446
|
-
// could not be measured, the broken thing is the actionable verdict.
|
|
447
|
-
let overall = results.some(r => r.status === 'fail') ? 'fail'
|
|
448
|
-
: results.some(r => r.status === 'inconclusive') ? 'not_certified'
|
|
449
|
-
: results.length > 0 && results.every(r => r.status === 'skipped') ? 'skipped'
|
|
450
|
-
: results.length === 0 ? 'skipped'
|
|
451
|
-
: 'pass';
|
|
452
|
-
// Legacy commands retain operational compatibility but their unversioned,
|
|
453
|
-
// shell-backed contract can never certify a run.
|
|
454
|
-
if (parsedManifest.kind === 'legacy' && overall === 'pass')
|
|
168
|
+
const execution = parsed.kind === 'v2'
|
|
169
|
+
? (0, prepare_1.prepareV2Sensor)({ name, sensor, liveSensor, liveState: live?.sensors[name], requestedScope, changed: changed ?? undefined })
|
|
170
|
+
: (0, prepare_1.prepareLegacySensor)({ name, config: sensor, requestedScope, changed: changed ?? undefined });
|
|
171
|
+
prepared.push(sensor.enabled === false
|
|
172
|
+
? { ...execution, command: undefined, syntheticStatus: 'skipped', syntheticReason: 'disabled' }
|
|
173
|
+
: execution);
|
|
174
|
+
}
|
|
175
|
+
const results = await pooled(prepared.map(entry => async () => {
|
|
176
|
+
const result = await (0, result_1.executePrepared)(entry, manifestDir);
|
|
177
|
+
return baseline ? (0, result_1.applyBaseline)(result, baseline[entry.name]) : result;
|
|
178
|
+
}), resolveConcurrency(parsed.pack, prepared.length));
|
|
179
|
+
let overall = (0, verdict_1.reduceVerdict)(results);
|
|
180
|
+
// Legacy commands retain operational compatibility but their shell-backed
|
|
181
|
+
// contract cannot certify a green run.
|
|
182
|
+
if (parsed.kind === 'legacy' && overall === 'pass')
|
|
455
183
|
overall = 'not_certified';
|
|
456
|
-
|
|
457
|
-
// (indicators present) is a false green — the gate ran nothing real. Never green.
|
|
458
|
-
if (overall === 'skipped' && drift.detection.pack !== 'generic') {
|
|
184
|
+
if (overall === 'skipped' && drift && drift.detection.pack !== 'generic')
|
|
459
185
|
overall = 'not_certified';
|
|
460
|
-
}
|
|
461
186
|
return {
|
|
462
187
|
sensors: results,
|
|
463
188
|
overall,
|
|
464
|
-
...(drift
|
|
465
|
-
// Always emitted on a --changed run, including when the scope failed to
|
|
466
|
-
// resolve: a green that came back from an unscoped fallback and a green from a
|
|
467
|
-
// genuinely scoped run are different claims, and the caller cannot tell them
|
|
468
|
-
// apart from the sensor list alone.
|
|
189
|
+
...(drift?.drift ? { packDrift: drift.drift } : {}),
|
|
469
190
|
...(changed ? { changedScope: { files: changed.files.length, ...(changed.error ? { error: changed.error } : {}) } } : {}),
|
|
470
191
|
};
|
|
471
192
|
}
|
|
@@ -8,7 +8,10 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const paths_1 = require("../../core/paths");
|
|
10
10
|
const manifest_1 = require("./compatibility/manifest");
|
|
11
|
-
const
|
|
11
|
+
const contract_1 = require("./compatibility/contract");
|
|
12
|
+
const discovery_1 = require("./compatibility/discovery");
|
|
13
|
+
const pack_source_1 = require("./compatibility/pack-source");
|
|
14
|
+
const resolve_1 = require("./compatibility/resolve");
|
|
12
15
|
/** First non-flag token after `npx` — the tool the command actually runs. */
|
|
13
16
|
function npxTool(parts) {
|
|
14
17
|
for (let i = 1; i < parts.length; i++) {
|
|
@@ -55,6 +58,83 @@ function checkCmd(cmd, cwd) {
|
|
|
55
58
|
}
|
|
56
59
|
return configCheck(parts, cwd) ?? { ok: true, detail: bin };
|
|
57
60
|
}
|
|
61
|
+
/** Check a v2 command's declared local prerequisite without invoking it. */
|
|
62
|
+
function checkStructuredCommand(command, cwd, assets = []) {
|
|
63
|
+
const missingAsset = assets.find(asset => !fs_1.default.existsSync(path_1.default.join(cwd, asset)));
|
|
64
|
+
if (missingAsset)
|
|
65
|
+
return { ok: false, detail: `missing config: ${missingAsset}` };
|
|
66
|
+
if (command.resolution === 'node-modules-bin') {
|
|
67
|
+
const binary = path_1.default.join(cwd, 'node_modules', '.bin', command.executable);
|
|
68
|
+
const present = fs_1.default.existsSync(binary)
|
|
69
|
+
|| fs_1.default.existsSync(`${binary}.cmd`)
|
|
70
|
+
|| fs_1.default.existsSync(`${binary}.exe`);
|
|
71
|
+
return present
|
|
72
|
+
? { ok: true, detail: `${command.executable} (node_modules/.bin)` }
|
|
73
|
+
: { ok: false, detail: `${command.executable} not installed locally` };
|
|
74
|
+
}
|
|
75
|
+
if (command.resolution === 'python-environment') {
|
|
76
|
+
if (!command.pythonEnvironmentRoot)
|
|
77
|
+
return { ok: false, detail: `${command.executable} has no selected Python environment` };
|
|
78
|
+
const root = path_1.default.join(cwd, command.pythonEnvironmentRoot);
|
|
79
|
+
const candidates = process.platform === 'win32'
|
|
80
|
+
? [path_1.default.join(root, 'Scripts', `${command.executable}.exe`), path_1.default.join(root, 'Scripts', `${command.executable}.cmd`)]
|
|
81
|
+
: [path_1.default.join(root, 'bin', command.executable)];
|
|
82
|
+
return candidates.some(candidate => fs_1.default.existsSync(candidate))
|
|
83
|
+
? { ok: true, detail: `${command.executable} (${command.pythonEnvironmentRoot})` }
|
|
84
|
+
: { ok: false, detail: `${command.executable} not found in ${command.pythonEnvironmentRoot}` };
|
|
85
|
+
}
|
|
86
|
+
return (0, paths_1.resolveOnPath)(command.executable)
|
|
87
|
+
? { ok: true, detail: command.executable }
|
|
88
|
+
: { ok: false, detail: `${command.executable} not found in PATH` };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Re-evaluate only locally discoverable v2 compatibility evidence. Unlike the
|
|
92
|
+
* execution path, status never runs a compatibility probe: probes such as
|
|
93
|
+
* `eslint --print-config` are process execution and would turn this command
|
|
94
|
+
* into a health check. A selected variant still proves that its current tool
|
|
95
|
+
* and runtime ranges are compatible; its probe state remains unverifiable.
|
|
96
|
+
*/
|
|
97
|
+
function resolveStaticV2Compatibility(cwd, manifest) {
|
|
98
|
+
const source = manifest.registryRoot === undefined
|
|
99
|
+
? (0, pack_source_1.resolvePackSource)(manifest.pack)
|
|
100
|
+
: (0, pack_source_1.resolvePackSource)(manifest.pack, { registries: [{ name: 'manifest-provenance', remote: 'local', contentRoot: manifest.registryRoot }] });
|
|
101
|
+
const parsed = (0, contract_1.parseSensorPack)(JSON.parse(source.content), source.path);
|
|
102
|
+
if (parsed.kind !== 'v2')
|
|
103
|
+
throw new Error(`sensor pack "${manifest.pack}" does not provide a v2 compatibility contract`);
|
|
104
|
+
const evidence = (0, discovery_1.discoverProjectEvidence)(cwd, parsed.pack);
|
|
105
|
+
const resolutionEvidence = {
|
|
106
|
+
...evidence,
|
|
107
|
+
...(manifest.packSelection === 'explicit' ? { packSelection: 'explicit' } : {}),
|
|
108
|
+
};
|
|
109
|
+
const initial = (0, resolve_1.resolveProjectCompatibility)(parsed.pack, resolutionEvidence).sensors;
|
|
110
|
+
return Object.fromEntries(Object.entries(parsed.pack.sensors).map(([name, sensor]) => {
|
|
111
|
+
const variant = initial[name]?.variantId === null
|
|
112
|
+
? null
|
|
113
|
+
: sensor.variants.find(candidate => candidate.id === initial[name]?.variantId) ?? null;
|
|
114
|
+
// These two probe kinds consume discovery output only. Keep their useful
|
|
115
|
+
// static validation in status while leaving every command-backed probe
|
|
116
|
+
// inconclusive rather than dispatching it.
|
|
117
|
+
const probeStatus = variant?.probe.kind === 'config-present'
|
|
118
|
+
? (evidence.configFiles.length > 0 ? 'matched' : 'not-matched')
|
|
119
|
+
: variant?.probe.kind === 'package-script-present'
|
|
120
|
+
? (evidence.scripts.length > 0 ? 'matched' : 'not-matched')
|
|
121
|
+
: undefined;
|
|
122
|
+
return [name, probeStatus === undefined
|
|
123
|
+
? initial[name]
|
|
124
|
+
: (0, resolve_1.resolveSensorCompatibility)(sensor, { ...resolutionEvidence, probe: { status: probeStatus } }, { pack: parsed.pack.name, sensor: name })];
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
function staticCompatibilityCheck(sensor, live) {
|
|
128
|
+
if (!live)
|
|
129
|
+
return { ok: false, detail: 'live compatibility unavailable' };
|
|
130
|
+
if (live.variantId !== sensor.variantId) {
|
|
131
|
+
return { ok: false, detail: `compatibility drift: initialized ${sensor.variantId}, resolved ${live.variantId ?? live.state}` };
|
|
132
|
+
}
|
|
133
|
+
if (live.state === 'incompatible' || live.state === 'missing-tool' || live.state === 'not-applicable' || live.reason === 'probe-not-matched') {
|
|
134
|
+
return { ok: false, detail: `live compatibility ${live.state}: ${live.reason}` };
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
58
138
|
async function computeSensorStatus(cwd = process.cwd()) {
|
|
59
139
|
const manifestPath = path_1.default.join(cwd, '.awm', 'sensors.json');
|
|
60
140
|
if (!fs_1.default.existsSync(manifestPath)) {
|
|
@@ -66,32 +146,26 @@ async function computeSensorStatus(cwd = process.cwd()) {
|
|
|
66
146
|
const parsed = (0, manifest_1.parseSensorManifest)(raw, manifestPath);
|
|
67
147
|
if (parsed.kind === 'v2') {
|
|
68
148
|
const checks = {};
|
|
69
|
-
let
|
|
149
|
+
let compatibility;
|
|
70
150
|
try {
|
|
71
|
-
|
|
151
|
+
compatibility = resolveStaticV2Compatibility(cwd, parsed.pack);
|
|
72
152
|
}
|
|
73
153
|
catch (error) {
|
|
74
|
-
const detail =
|
|
154
|
+
const detail = error instanceof Error ? error.message : 'live compatibility unavailable';
|
|
75
155
|
for (const [name, sensor] of Object.entries(parsed.pack.sensors)) {
|
|
76
156
|
checks[name] = sensor.enabled === false ? { ok: true, detail: 'disabled' } : { ok: false, detail };
|
|
77
157
|
}
|
|
78
158
|
return { overall: 'DEGRADED', pack: parsed.pack.pack, checks };
|
|
79
159
|
}
|
|
80
160
|
for (const [name, sensor] of Object.entries(parsed.pack.sensors)) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
? { ok: false, detail: `variant-drift: manifest ${sensor.variantId}, live ${state.variantId ?? 'none'}; run \`awm sensors init\`` }
|
|
88
|
-
: state.state === 'certified'
|
|
89
|
-
? { ok: true, detail: `certified (${state.variantId})` }
|
|
90
|
-
: state.state === 'not-applicable'
|
|
91
|
-
? { ok: true, detail: 'not applicable' }
|
|
92
|
-
: { ok: false, detail: `${state.state}: ${state.reason}` };
|
|
161
|
+
if (sensor.enabled === false) {
|
|
162
|
+
checks[name] = { ok: true, detail: 'disabled' };
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
checks[name] = staticCompatibilityCheck(sensor, compatibility[name])
|
|
166
|
+
?? checkStructuredCommand(sensor.command, cwd, sensor.assets);
|
|
93
167
|
}
|
|
94
|
-
return { overall: Object.keys(checks).length > 0 && Object.values(checks).every(check => check.ok) ? '
|
|
168
|
+
return { overall: Object.keys(checks).length > 0 && Object.values(checks).every(check => check.ok) ? 'READY' : 'DEGRADED', pack: parsed.pack.pack, checks };
|
|
95
169
|
}
|
|
96
170
|
manifest = parsed.pack;
|
|
97
171
|
}
|
|
@@ -122,12 +196,10 @@ async function computeSensorStatus(cwd = process.cwd()) {
|
|
|
122
196
|
}
|
|
123
197
|
// `Object.values({}).every(...)` is vacuously true — a manifest with zero sensor
|
|
124
198
|
// entries (the registry had no pack.json for this stack; see init.ts) must not read
|
|
125
|
-
// as
|
|
199
|
+
// as READY just because there was nothing to fail. Same false-green `checkManifest`
|
|
126
200
|
// guards against in preflight.
|
|
127
201
|
if (Object.keys(manifest.sensors ?? {}).length === 0) {
|
|
128
202
|
return { overall: 'DEGRADED', pack: manifest.pack, checks };
|
|
129
203
|
}
|
|
130
|
-
|
|
131
|
-
// has no versioned/structured contract and must never present as certified.
|
|
132
|
-
return { overall: 'DEGRADED', pack: manifest.pack, checks };
|
|
204
|
+
return { overall: Object.values(checks).every(check => check.ok) ? 'READY' : 'DEGRADED', pack: manifest.pack, checks };
|
|
133
205
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.exitCodeForVerdict = exitCodeForVerdict;
|
|
4
|
+
exports.reduceVerdict = reduceVerdict;
|
|
5
|
+
/** Map the semantic sensor verdict to the CLI process status. Only a global pass succeeds. */
|
|
6
|
+
function exitCodeForVerdict(overall) {
|
|
7
|
+
return overall === 'pass' ? 0 : 1;
|
|
8
|
+
}
|
|
9
|
+
/** Reduce all selected sensor outcomes through one format-agnostic verdict rule. */
|
|
10
|
+
function reduceVerdict(results) {
|
|
11
|
+
if (!Array.isArray(results))
|
|
12
|
+
throw new Error('sensor results must be an array');
|
|
13
|
+
for (const result of results) {
|
|
14
|
+
if (!result || typeof result !== 'object')
|
|
15
|
+
throw new Error('sensor result must be an object');
|
|
16
|
+
if (!['pass', 'fail', 'inconclusive', 'skipped'].includes(result.status)) {
|
|
17
|
+
throw new Error('sensor result status is invalid');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (results.some(result => result.status === 'fail'))
|
|
21
|
+
return 'fail';
|
|
22
|
+
if (results.some(result => result.status === 'inconclusive'))
|
|
23
|
+
return 'not_certified';
|
|
24
|
+
if (results.some(result => result.status === 'pass'))
|
|
25
|
+
return 'pass';
|
|
26
|
+
return 'skipped';
|
|
27
|
+
}
|