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.
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 +136 -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 +17 -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
@@ -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
  }
@@ -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 live_1 = require("./compatibility/live");
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 live;
149
+ let compatibility;
70
150
  try {
71
- live = await (0, live_1.resolveLiveCompatibility)(cwd, parsed.pack.pack, parsed.pack.registryRoot, { packSelection: parsed.pack.packSelection });
151
+ compatibility = resolveStaticV2Compatibility(cwd, parsed.pack);
72
152
  }
73
153
  catch (error) {
74
- const detail = `compatibility revalidation failed: ${error instanceof Error ? error.message : String(error)}`;
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
- const state = live.sensors[name];
82
- checks[name] = sensor.enabled === false
83
- ? { ok: true, detail: 'disabled' }
84
- : !state
85
- ? { ok: false, detail: 'variant-drift: sensor no longer exists in the live pack; run `awm sensors init`' }
86
- : state.variantId !== sensor.variantId
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) ? 'HEALTHY' : 'DEGRADED', pack: parsed.pack.pack, checks };
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 HEALTHY just because there was nothing to fail. Same false-green `checkManifest`
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
- // A legacy manifest may be operational (the checks are still useful), but it
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
+ }