agentic-workflow-manager 8.1.4 → 8.1.6

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.
Files changed (35) hide show
  1. package/dist/src/commands/preflight/checks.js +70 -1
  2. package/dist/src/commands/preflight/index.js +6 -5
  3. package/dist/src/commands/sensors/changed.js +15 -0
  4. package/dist/src/commands/sensors/compatibility/contract.js +17 -3
  5. package/dist/src/commands/sensors/compatibility/manifest.js +16 -4
  6. package/dist/src/commands/sensors/compatibility/timeout.js +21 -0
  7. package/dist/src/commands/sensors/exec.js +3 -1
  8. package/dist/src/commands/sensors/index.js +5 -13
  9. package/dist/src/commands/sensors/init.js +1 -0
  10. package/dist/src/commands/sensors/prepare.js +140 -0
  11. package/dist/src/commands/sensors/result.js +140 -0
  12. package/dist/src/commands/sensors/run.js +56 -335
  13. package/dist/src/commands/sensors/status.js +93 -21
  14. package/dist/src/commands/sensors/verdict.js +27 -0
  15. package/dist/tests/commands/preflight/preflight.test.js +50 -0
  16. package/dist/tests/commands/sensors/baseline.test.js +14 -0
  17. package/dist/tests/commands/sensors/changed-windows.test.js +3 -0
  18. package/dist/tests/commands/sensors/compatibility/contract.test.js +51 -0
  19. package/dist/tests/commands/sensors/compatibility/manifest.test.js +27 -11
  20. package/dist/tests/commands/sensors/compatibility/probe.test.js +13 -3
  21. package/dist/tests/commands/sensors/exec-fixtures.js +1 -1
  22. package/dist/tests/commands/sensors/exec.test.js +36 -0
  23. package/dist/tests/commands/sensors/index.test.js +46 -8
  24. package/dist/tests/commands/sensors/init.test.js +30 -1
  25. package/dist/tests/commands/sensors/prepare.test.js +88 -0
  26. package/dist/tests/commands/sensors/router.test.js +15 -1
  27. package/dist/tests/commands/sensors/run-changed.test.js +4 -4
  28. package/dist/tests/commands/sensors/run.test.js +29 -0
  29. package/dist/tests/commands/sensors/status-windows.test.js +1 -1
  30. package/dist/tests/commands/sensors/status.test.js +52 -20
  31. package/dist/tests/integration/preflight-json-pipe.e2e.test.js +79 -3
  32. package/dist/tests/integration/sensor-compatibility.e2e.test.js +40 -1
  33. package/dist/tests/structural/sensor-documentation-contract.test.js +33 -5
  34. package/dist/tests/structural/support-matrix-is-current.test.js +25 -0
  35. package/package.json +1 -1
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.interpretResult = interpretResult;
4
+ exports.executePrepared = executePrepared;
5
+ exports.applyBaseline = applyBaseline;
6
+ const exec_1 = require("./exec");
7
+ const baseline_1 = require("./baseline");
8
+ const tsc_1 = require("./formatters/tsc");
9
+ const eslint_1 = require("./formatters/eslint");
10
+ const semgrep_1 = require("./formatters/semgrep");
11
+ const generic_1 = require("./formatters/generic");
12
+ const test_1 = require("./formatters/test");
13
+ const mypy_1 = require("./formatters/mypy");
14
+ const ruff_1 = require("./formatters/ruff");
15
+ const shellcheck_1 = require("./formatters/shellcheck");
16
+ const MAX_BUFFER = 64 * 1024 * 1024;
17
+ function formatterFor(name, formatter) {
18
+ if (formatter !== undefined) {
19
+ switch (formatter) {
20
+ case 'tsc': return tsc_1.parseTscOutput;
21
+ case 'eslint-llm': return eslint_1.parseEslintOutput;
22
+ case 'semgrep': return semgrep_1.parseSemgrepOutput;
23
+ case 'test': return test_1.parseTestOutput;
24
+ case 'mypy': return mypy_1.parseMypyOutput;
25
+ case 'ruff': return ruff_1.parseRuffOutput;
26
+ case 'shellcheck': return shellcheck_1.parseShellcheckOutput;
27
+ case 'generic': return generic_1.parseGenericOutput;
28
+ default: return generic_1.parseGenericOutput;
29
+ }
30
+ }
31
+ if (name === 'typecheck')
32
+ return tsc_1.parseTscOutput;
33
+ if (name === 'lint')
34
+ return eslint_1.parseEslintOutput;
35
+ if (name === 'security')
36
+ return semgrep_1.parseSemgrepOutput;
37
+ if (name === 'test')
38
+ return test_1.parseTestOutput;
39
+ return generic_1.parseGenericOutput;
40
+ }
41
+ function executionEvidence(prepared, elapsedMs) {
42
+ return {
43
+ timeoutMs: prepared.timeoutMs,
44
+ timeoutSource: prepared.timeoutSource,
45
+ elapsedMs,
46
+ requestedScope: prepared.requestedScope,
47
+ effectiveScope: prepared.effectiveScope,
48
+ ...(prepared.files !== undefined ? { files: prepared.files } : {}),
49
+ ...(prepared.scopeReason ? { scopeReason: prepared.scopeReason } : {}),
50
+ };
51
+ }
52
+ function validatePrepared(prepared) {
53
+ if (!prepared || typeof prepared !== 'object' || typeof prepared.name !== 'string' || prepared.name === '')
54
+ throw new Error('prepared sensor requires a nonempty name');
55
+ if (!Number.isSafeInteger(prepared.timeoutMs) || prepared.timeoutMs <= 0)
56
+ throw new Error('prepared sensor timeoutMs must be a positive safe integer');
57
+ if (!['project', 'pack', 'fallback'].includes(prepared.timeoutSource))
58
+ throw new Error('prepared sensor timeoutSource is invalid');
59
+ if (!['full', 'changed'].includes(prepared.requestedScope) || !['full', 'changed'].includes(prepared.effectiveScope))
60
+ throw new Error('prepared sensor scope is invalid');
61
+ if (prepared.files !== undefined && (!Number.isSafeInteger(prepared.files) || prepared.files < 0))
62
+ throw new Error('prepared sensor files must be a non-negative safe integer');
63
+ if (prepared.command !== undefined && (prepared.command.kind !== 'legacy' && prepared.command.kind !== 'structured'))
64
+ throw new Error('prepared sensor command kind is invalid');
65
+ }
66
+ function scoped(result, prepared) {
67
+ return {
68
+ ...result,
69
+ ...(prepared.effectiveScope === 'changed' ? { scope: 'changed' } : {}),
70
+ };
71
+ }
72
+ /** Interpret one raw bounded process result without knowing its manifest format. */
73
+ function interpretResult(prepared, raw) {
74
+ validatePrepared(prepared);
75
+ if (!raw || typeof raw !== 'object' || !Number.isSafeInteger(raw.elapsedMs) || raw.elapsedMs < 0)
76
+ throw new Error('execution result requires a non-negative safe-integer elapsedMs');
77
+ const execution = executionEvidence(prepared, raw.elapsedMs);
78
+ const withEvidence = (result) => ({
79
+ ...result,
80
+ ...(prepared.effectiveScope === 'changed' ? { scope: 'changed' } : {}),
81
+ execution,
82
+ });
83
+ const format = formatterFor(prepared.name, prepared.formatter);
84
+ if (raw.spawnError)
85
+ return withEvidence({ name: prepared.name, status: 'fail', errors: [{ message: `sensor could not be started: ${raw.spawnError.message}` }] });
86
+ if (raw.timedOut || raw.overflowed) {
87
+ const reason = raw.timedOut ? `timeout after ${prepared.timeoutMs}ms` : `output exceeded ${MAX_BUFFER} bytes`;
88
+ const errors = format(raw.stdout + raw.stderr);
89
+ if (errors.length > 0)
90
+ return withEvidence({ name: prepared.name, status: 'fail', errors, incomplete: `${reason} — findings below are from partial output; the run did not finish` });
91
+ return withEvidence({ name: prepared.name, status: 'inconclusive', errors: [], skipReason: reason });
92
+ }
93
+ if (raw.code === 0) {
94
+ const errors = format(raw.stdout);
95
+ return withEvidence({ name: prepared.name, status: errors.length ? 'fail' : 'pass', errors });
96
+ }
97
+ const output = raw.stdout + raw.stderr;
98
+ const errors = format(output);
99
+ if (errors.length > 0)
100
+ return withEvidence({ name: prepared.name, status: 'fail', errors });
101
+ const lower = output.toLowerCase();
102
+ const toolMissing = raw.code === 127
103
+ || lower.includes('command not found')
104
+ || lower.includes('is not recognized as an internal or external command')
105
+ || lower.includes('enoent')
106
+ || lower.includes('could not determine executable');
107
+ if (toolMissing)
108
+ return withEvidence({ name: prepared.name, status: 'fail', errors: [{ message: `sensor tool not available: ${output.slice(0, 200)}` }] });
109
+ if (prepared.name === 'test')
110
+ return withEvidence({ name: prepared.name, status: 'fail', errors: [{ message: `SENSOR[${prepared.name}] failed (exit ${raw.code})` }] });
111
+ return withEvidence({ name: prepared.name, status: 'inconclusive', errors: [], skipReason: `exit ${raw.code}: ${output.slice(0, 200)}` });
112
+ }
113
+ /** Execute a validated prepared command, or render its deliberate synthetic result. */
114
+ async function executePrepared(prepared, cwd = process.cwd()) {
115
+ validatePrepared(prepared);
116
+ if (prepared.syntheticStatus !== undefined || prepared.command === undefined) {
117
+ const status = prepared.syntheticStatus ?? 'inconclusive';
118
+ return scoped({
119
+ name: prepared.name,
120
+ status,
121
+ errors: [],
122
+ ...(prepared.syntheticReason ? { skipReason: prepared.syntheticReason } : {}),
123
+ execution: executionEvidence(prepared, 0),
124
+ }, prepared);
125
+ }
126
+ const options = { timeout: prepared.timeoutMs, cwd, maxBuffer: MAX_BUFFER };
127
+ const raw = prepared.command.kind === 'legacy'
128
+ ? await (0, exec_1.runCommand)(prepared.command.value, options)
129
+ : await (0, exec_1.runStructuredCommand)(prepared.command.value, options);
130
+ return interpretResult(prepared, raw);
131
+ }
132
+ /** Apply baseline suppression only to completed verdicts. */
133
+ function applyBaseline(result, accepted) {
134
+ if (result.status === 'skipped' || result.status === 'inconclusive')
135
+ return result;
136
+ const { newErrors, suppressed } = (0, baseline_1.partition)(result.name, result.errors, accepted);
137
+ if (suppressed === 0)
138
+ return result;
139
+ return { ...result, errors: newErrors, status: newErrors.length > 0 ? 'fail' : 'pass', newCount: newErrors.length, baselineCount: suppressed };
140
+ }
@@ -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 = 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 && isFast)
146
- return true;
147
- if (opts.slow && !isFast)
95
+ if (opts.fast && opts.slow)
148
96
  return true;
149
- if (!opts.fast && !opts.slow && !opts.all)
150
- return true;
151
- return false;
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
- const startCwd = opts.cwd ?? process.cwd();
307
- const manifestDir = findManifestDir(startCwd);
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
- const manifest = readManifest(manifestDir);
311
- if (!manifest)
312
- return { sensors: [], overall: 'not_certified' };
313
- let parsedManifest;
141
+ let parsed;
314
142
  try {
315
- parsedManifest = (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));
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
- if (parsedManifest.kind === 'v2') {
321
- const live = await resolveLiveV2(manifestDir, parsedManifest);
322
- const tasks = [];
323
- for (const [name, sensor] of Object.entries(parsedManifest.pack.sensors)) {
324
- const state = live?.sensors[name];
325
- const liveSensor = live?.pack.sensors[name];
326
- if (!shouldRun(sensor.fast ?? liveSensor?.fast ?? false, opts))
327
- continue;
328
- if (sensor.enabled === false) {
329
- tasks.push(() => Promise.resolve({ name, status: 'skipped', errors: [], skipReason: 'disabled' }));
330
- continue;
331
- }
332
- if (!state || state.state !== 'certified') {
333
- tasks.push(() => Promise.resolve({ name, status: 'inconclusive', errors: [], skipReason: state ? `${state.state}: ${state.reason}` : 'compatibility could not be revalidated' }));
334
- continue;
335
- }
336
- if (state.variantId !== sensor.variantId) {
337
- tasks.push(() => Promise.resolve({ name, status: 'inconclusive', errors: [], skipReason: `variant-drift: manifest ${sensor.variantId}, live ${state.variantId ?? 'none'}; run \`awm sensors init\`` }));
338
- continue;
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
- // Scoping applies only where the pack opted in AND the scope resolved. Any
408
- // other combination falls back to the full command: slower, never wrong.
409
- let cmd = config.cmd;
410
- let scope;
411
- if (changed && !changed.error && config.changedCmd) {
412
- if (!config.changedCmd.includes('{files}')) {
413
- // Running the template as-is would cover the whole repo while the
414
- // result claimed to be scoped — a mislabel, not a slow path.
415
- tasks.push(settled({
416
- name,
417
- status: 'inconclusive',
418
- errors: [],
419
- skipReason: 'changedCmd has no {files} placeholder',
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
- // Honest floor: a benign-green 'skipped' over a tree that clearly HAS a stack
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.drift ? { packDrift: drift.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
  }